diff --git a/CHANGELOG.md b/CHANGELOG.md index b939415..bad7ac9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.2.0] + +### 2026-02-02 +- Feature: Add support for magnetic and true heading using device sensors (available for location updates only). +- Feature: Add `magneticHeading`, `trueHeading`, `headingAccuracy`, and `course` to `IONGLOCLocationResult`. + ## [2.1.0] ### 2025-10-31 diff --git a/README.md b/README.md index 978541c..cad201c 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ In your app-level gradle file, import the `ion-android-geolocation` library like ``` dependencies { - implementation("io.ionic.libs:iongeolocation-android:2.1.0") + implementation("io.ionic.libs:iongeolocation-android:2.2.0") } ``` diff --git a/build.gradle b/build.gradle index 90419ef..66416cb 100644 --- a/build.gradle +++ b/build.gradle @@ -46,8 +46,8 @@ android { defaultConfig { minSdk 23 targetSdk 36 - versionCode 1 - versionName "1.0" + versionCode 3 + versionName "2.2.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCController.kt b/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCController.kt index 269399b..1387ac4 100644 --- a/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCController.kt +++ b/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCController.kt @@ -55,7 +55,8 @@ class IONGLOCController internal constructor( ), private val fallbackHelper: IONGLOCFallbackHelper = IONGLOCFallbackHelper( locationManager, connectivityManager - ) + ), + private val sensorHandler: IONGLOCSensorHandler ) { constructor( @@ -65,7 +66,8 @@ class IONGLOCController internal constructor( fusedLocationClient = LocationServices.getFusedLocationProviderClient(context), locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager, connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager, - activityLauncher = activityLauncher + activityLauncher = activityLauncher, + sensorHandler = IONGLOCSensorHandler(context) ) private lateinit var resolveLocationSettingsResultFlow: MutableSharedFlow> @@ -97,7 +99,8 @@ class IONGLOCController internal constructor( } else { googleServicesHelper.getCurrentLocation(options) } - Result.success(location.toOSLocationResult()) + val result = location.toOSLocationResult() + Result.success(result) } } catch (exception: Exception) { Log.d(LOG_TAG, "Error fetching location: ${exception.message}") @@ -217,21 +220,31 @@ class IONGLOCController internal constructor( ): Flow>> = callbackFlow { fun onNewLocations(locations: List) { if (checkWatchInBlackList(watchId)) return - val locationResultList = locations.map { it.toOSLocationResult() } + val locationResultList = locations.map { + it.toOSLocationResult( + magneticHeading = sensorHandler.magneticHeading, + trueHeading = sensorHandler.getTrueHeading(it), + headingAccuracy = sensorHandler.headingAccuracy + ) + } trySend(Result.success(locationResultList)) } + sensorHandler.start() try { requestLocationUpdates( watchId, options, useFallback = useFallback - ) { onNewLocations(it) } + ) { + onNewLocations(it) + } } catch (e: Exception) { trySend(Result.failure(e)) } awaitClose { + sensorHandler.stop() Log.d(LOG_TAG, "channel closed") } } diff --git a/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCSensorHandler.kt b/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCSensorHandler.kt new file mode 100644 index 0000000..10b95d7 --- /dev/null +++ b/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCSensorHandler.kt @@ -0,0 +1,121 @@ +package io.ionic.libs.iongeolocationlib.controller + +import android.content.Context +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager +import android.location.Location +import android.hardware.GeomagneticField + +/** + * Handler for device sensors to calculate heading. + */ +internal class IONGLOCSensorHandler(context: Context) : SensorEventListener { + private val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager + private val accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) + private val magnetometer = sensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD) + + private var gravity: FloatArray? = null + private var geomagnetic: FloatArray? = null + + @Volatile + var magneticHeading: Float? = null + private set + + @Volatile + var headingAccuracy: Float? = null + private set + + private var watcherCount = 0 + + @Synchronized + fun start() { + if (watcherCount == 0) { + accelerometer?.let { + sensorManager.registerListener(this, it, SensorManager.SENSOR_DELAY_UI) + } + magnetometer?.let { + sensorManager.registerListener(this, it, SensorManager.SENSOR_DELAY_UI) + } + } + watcherCount++ + } + + @Synchronized + fun stop() { + watcherCount-- + if (watcherCount <= 0) { + watcherCount = 0 + sensorManager.unregisterListener(this) + } + } + + override fun onSensorChanged(event: SensorEvent) { + if (event.sensor.type == Sensor.TYPE_ACCELEROMETER) { + gravity = event.values + } + if (event.sensor.type == Sensor.TYPE_MAGNETIC_FIELD) { + geomagnetic = event.values + // Using magnetometer accuracy as heading accuracy proxy + headingAccuracy = getHeadingAccuracy(event.accuracy) + } + + updateHeadings() + } + + private fun updateHeadings() { + val g = gravity ?: return + val m = geomagnetic ?: return + + val r = FloatArray(9) + val i = FloatArray(9) + + if (SensorManager.getRotationMatrix(r, i, g, m)) { + val orientation = FloatArray(3) + SensorManager.getOrientation(r, orientation) + + // Azimuth is orientation[0], in radians. + // Convert to degrees and normalize to 0-360. + val azimuthInRadians = orientation[0] + val azimuthInDegrees = Math.toDegrees(azimuthInRadians.toDouble()).toFloat() + magneticHeading = (azimuthInDegrees + 360) % 360 + } + } + + /** + * Calculates the true heading on the fly based on a given location. + * @param location the location to use for calculating the geomagnetic declination + * @return the calculated true heading or null if magnetic heading is not yet available + */ + fun getTrueHeading(location: Location): Float? { + return magneticHeading?.let { mh -> + val geoField = GeomagneticField( + location.latitude.toFloat(), + location.longitude.toFloat(), + location.altitude.toFloat(), + location.time + ) + (mh + geoField.declination + 360) % 360 + } + } + + override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) { + if (sensor.type == Sensor.TYPE_MAGNETIC_FIELD) { + headingAccuracy = getHeadingAccuracy(accuracy) + } + } + + /** + * Uses SensorManager accuracy status as a heuristic proxy for heading accuracy, + * as Android does not provide direct heading accuracy in degrees for the magnetometer. + */ + private fun getHeadingAccuracy(accuracy: Int): Float { + return when (accuracy) { + SensorManager.SENSOR_STATUS_ACCURACY_HIGH -> 10f + SensorManager.SENSOR_STATUS_ACCURACY_MEDIUM -> 20f + SensorManager.SENSOR_STATUS_ACCURACY_LOW -> 30f + else -> 45f + } + } +} diff --git a/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/helper/IONGLOCExtensions.kt b/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/helper/IONGLOCExtensions.kt index d3cc91c..45714e0 100644 --- a/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/helper/IONGLOCExtensions.kt +++ b/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/helper/IONGLOCExtensions.kt @@ -55,16 +55,27 @@ internal fun sendResultWithGoogleServicesException( * Extension function to convert Location object into OSLocationResult object * @return OSLocationResult object */ -internal fun Location.toOSLocationResult(): IONGLOCLocationResult = IONGLOCLocationResult( - latitude = this.latitude, - longitude = this.longitude, - altitude = this.altitude, - accuracy = this.accuracy, - altitudeAccuracy = if (IONGLOCBuildConfig.getAndroidSdkVersionCode() >= Build.VERSION_CODES.O) this.verticalAccuracyMeters else null, - heading = this.bearing, - speed = this.speed, - timestamp = this.time -) +internal fun Location.toOSLocationResult( + magneticHeading: Float? = null, + trueHeading: Float? = null, + headingAccuracy: Float? = null +): IONGLOCLocationResult { + val course = if (this.hasBearing()) this.bearing else null + return IONGLOCLocationResult( + latitude = this.latitude, + longitude = this.longitude, + altitude = this.altitude, + accuracy = this.accuracy, + altitudeAccuracy = if (IONGLOCBuildConfig.getAndroidSdkVersionCode() >= Build.VERSION_CODES.O) this.verticalAccuracyMeters else null, + heading = trueHeading ?: magneticHeading ?: course ?: -1f, + speed = this.speed, + timestamp = this.time, + magneticHeading = magneticHeading, + trueHeading = trueHeading, + headingAccuracy = headingAccuracy, + course = course + ) +} /** * Flow extension to either emit its values, or emit a timeout error if [timeoutMillis] is reached before any emission diff --git a/src/main/kotlin/io/ionic/libs/iongeolocationlib/model/IONGLOCLocationResult.kt b/src/main/kotlin/io/ionic/libs/iongeolocationlib/model/IONGLOCLocationResult.kt index da8ffe0..b985630 100644 --- a/src/main/kotlin/io/ionic/libs/iongeolocationlib/model/IONGLOCLocationResult.kt +++ b/src/main/kotlin/io/ionic/libs/iongeolocationlib/model/IONGLOCLocationResult.kt @@ -11,5 +11,9 @@ data class IONGLOCLocationResult( val altitudeAccuracy: Float? = null, val heading: Float, val speed: Float, - val timestamp: Long + val timestamp: Long, + val magneticHeading: Float? = null, + val trueHeading: Float? = null, + val headingAccuracy: Float? = null, + val course: Float? = null ) diff --git a/src/test/java/io/ionic/libs/iongeolocationlib/controller/IONGLOCControllerTest.kt b/src/test/java/io/ionic/libs/iongeolocationlib/controller/IONGLOCControllerTest.kt index a3a2373..ec95ebe 100644 --- a/src/test/java/io/ionic/libs/iongeolocationlib/controller/IONGLOCControllerTest.kt +++ b/src/test/java/io/ionic/libs/iongeolocationlib/controller/IONGLOCControllerTest.kt @@ -61,6 +61,7 @@ import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -85,6 +86,8 @@ class IONGLOCControllerTest { ) private val fallbackHelper = spyk(IONGLOCFallbackHelper(locationManager, connectivityManager)) + private val sensorHandler = mockk(relaxed = true) + private val mockAndroidLocation = mockkLocation() private val locationSettingsTask = mockk>(relaxed = true) private val currentLocationTask = mockk>(relaxed = true) @@ -116,8 +119,15 @@ class IONGLOCControllerTest { connectivityManager = connectivityManager, activityLauncher = activityResultLauncher, googleServicesHelper = googleServicesHelper, - fallbackHelper = fallbackHelper + fallbackHelper = fallbackHelper, + sensorHandler = sensorHandler ) + + every { sensorHandler.magneticHeading } returns null + every { sensorHandler.getTrueHeading(any()) } returns null + every { sensorHandler.headingAccuracy } returns null + every { sensorHandler.start() } just runs + every { sensorHandler.stop() } just runs } @After @@ -239,6 +249,7 @@ class IONGLOCControllerTest { ) } } + // endregion getCurrentLocation tests // region addWatch tests @@ -363,6 +374,31 @@ class IONGLOCControllerTest { awaitComplete() } } + + @Test + fun `given sensor handler has data, when addWatch is called, result includes sensor data`() = + runTest { + givenSuccessConditions() + every { sensorHandler.magneticHeading } returns 100f + every { sensorHandler.getTrueHeading(any()) } returns 110f + every { sensorHandler.headingAccuracy } returns 5f + + sut.addWatch(mockk(), locationOptions, "1").test { + advanceTimeBy(locationOptionsWithFallback.timeout / 2) + emitLocationsGMS(listOf(mockAndroidLocation)) + val result = awaitItem() + + assertTrue(result.isSuccess) + val locations = result.getOrNull() + assertNotNull(locations) + val location = locations?.first() + assertEquals(100f, location?.magneticHeading) + assertEquals(110f, location?.trueHeading) + assertEquals(5f, location?.headingAccuracy) + // Heading should prefer trueHeading + assertEquals(110f, location?.heading) + } + } // endregion addWatch tests // region clearWatch tests @@ -524,9 +560,7 @@ class IONGLOCControllerTest { fun `given SETTINGS_CHANGE_UNAVAILABLE error and network+location disabled and enableLocationManagerFallback=true, when getCurrentLocation is called, IONGLOCLocationAndNetworkDisabledException is returned`() = runTest { givenSuccessConditions() // to instantiate mocks - coEvery { locationSettingsTask.await() } throws mockk { - every { message } returns "8502: SETTINGS_CHANGE_UNAVAILABLE" - } + coEvery { locationSettingsTask.await() } throws ApiException(Status(8502, "SETTINGS_CHANGE_UNAVAILABLE")) every { LocationManagerCompat.isLocationEnabled(any()) } returns false val result = sut.getCurrentPosition(mockk(), locationOptionsWithFallback) @@ -738,6 +772,7 @@ class IONGLOCControllerTest { every { bearing } returns 4.0f every { speed } returns 0.2f every { time } returns 1L + every { hasBearing() } returns true overrideDefaultMocks() } @@ -780,7 +815,8 @@ class IONGLOCControllerTest { altitudeAccuracy = 1.5f, heading = 4.0f, speed = 0.2f, - timestamp = 1L + timestamp = 1L, + course = 4.0f ) } } \ No newline at end of file diff --git a/src/test/java/io/ionic/libs/iongeolocationlib/controller/IONGLOCSensorHandlerTest.kt b/src/test/java/io/ionic/libs/iongeolocationlib/controller/IONGLOCSensorHandlerTest.kt new file mode 100644 index 0000000..615519c --- /dev/null +++ b/src/test/java/io/ionic/libs/iongeolocationlib/controller/IONGLOCSensorHandlerTest.kt @@ -0,0 +1,143 @@ +package io.ionic.libs.iongeolocationlib.controller + +import android.content.Context +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager +import android.location.Location +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.verify +import io.mockk.unmockkStatic +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import java.lang.reflect.Field + +class IONGLOCSensorHandlerTest { + + private lateinit var context: Context + private lateinit var sensorManager: SensorManager + private lateinit var accelerometer: Sensor + private lateinit var magnetometer: Sensor + private lateinit var sensorHandler: IONGLOCSensorHandler + + @Before + fun setUp() { + context = mockk(relaxed = true) + sensorManager = mockk(relaxed = true) + accelerometer = mockk(relaxed = true) + magnetometer = mockk(relaxed = true) + + mockkStatic(SensorManager::class) + // Mock getRotationMatrix to return true and fill the R matrix + every { SensorManager.getRotationMatrix(any(), any(), any(), any()) } answers { + val r = args[0] as FloatArray + // Identity matrix for simplicity + r[0] = 1f; r[4] = 1f; r[8] = 1f + true + } + // Mock getOrientation to return a fixed azimuth (e.g., 90 degrees = 1.57 radians) + every { SensorManager.getOrientation(any(), any()) } answers { + val values = args[1] as FloatArray + values[0] = 1.5708f // 90 degrees in radians + values + } + + every { context.getSystemService(Context.SENSOR_SERVICE) } returns sensorManager + every { sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) } returns accelerometer + every { sensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD) } returns magnetometer + + sensorHandler = IONGLOCSensorHandler(context) + } + + @org.junit.After + fun tearDown() { + io.mockk.unmockkStatic(SensorManager::class) + } + + @Test + fun `when start is called, sensors are registered`() { + sensorHandler.start() + + verify { sensorManager.registerListener(sensorHandler, accelerometer, SensorManager.SENSOR_DELAY_UI) } + verify { sensorManager.registerListener(sensorHandler, magnetometer, SensorManager.SENSOR_DELAY_UI) } + } + + @Test + fun `when stop is called, sensors are unregistered`() { + sensorHandler.start() + sensorHandler.stop() + + verify { sensorManager.unregisterListener(sensorHandler) } + } + + @Test + fun `when start is called multiple times, sensors are registered only once`() { + sensorHandler.start() + sensorHandler.start() + + verify(exactly = 1) { sensorManager.registerListener(sensorHandler, accelerometer, SensorManager.SENSOR_DELAY_UI) } + } + + @Test + fun `when stop is called but watchers remain, sensors are not unregistered`() { + sensorHandler.start() + sensorHandler.start() + sensorHandler.stop() + + verify(exactly = 0) { sensorManager.unregisterListener(sensorHandler) } + } + + @Test + fun `when orientation is calculated, magneticHeading is updated`() { + // Mock accelerometer data (gravity pointing down) + val gravityEvent = createSensorEvent(Sensor.TYPE_ACCELEROMETER, floatArrayOf(0f, 0f, 9.8f)) + sensorHandler.onSensorChanged(gravityEvent) + + // Mock magnetometer data (pointing North) + val geoEvent = createSensorEvent(Sensor.TYPE_MAGNETIC_FIELD, floatArrayOf(0f, 50f, 0f)) + sensorHandler.onSensorChanged(geoEvent) + + assertNotNull(sensorHandler.magneticHeading) + } + + @Test + fun `when location is updated, trueHeading is calculated`() { + // Setup headings first + val gravityEvent = createSensorEvent(Sensor.TYPE_ACCELEROMETER, floatArrayOf(0f, 0f, 9.8f)) + sensorHandler.onSensorChanged(gravityEvent) + val geoEvent = createSensorEvent(Sensor.TYPE_MAGNETIC_FIELD, floatArrayOf(0f, 50f, 0f)) + sensorHandler.onSensorChanged(geoEvent) + + val location = mockk(relaxed = true) + every { location.latitude } returns 37.7749 // San Francisco + every { location.longitude } returns -122.4194 + every { location.altitude } returns 0.0 + + assertNotNull(sensorHandler.getTrueHeading(location)) + assertNotNull(sensorHandler.magneticHeading) + } + + private fun createSensorEvent(sensorType: Int, values: FloatArray): SensorEvent { + val sensorEvent = mockk(relaxed = true) + val sensor = mockk(relaxed = true) + every { sensor.type } returns sensorType + + // Use reflection to set values since setters are not available/mockable easily on final field + val valuesField = SensorEvent::class.java.getField("values") + valuesField.isAccessible = true + valuesField.set(sensorEvent, values) + + val sensorField = SensorEvent::class.java.getField("sensor") + sensorField.isAccessible = true + sensorField.set(sensorEvent, sensor) + + return sensorEvent + } +}