From be4385bc16ef4e1867377c0ce3127c56662f3c50 Mon Sep 17 00:00:00 2001 From: Chace Daniels Date: Mon, 2 Feb 2026 10:24:16 -0600 Subject: [PATCH 1/9] feat: add support for heading --- CHANGELOG.md | 6 + README.md | 2 +- build.gradle | 4 +- .../controller/IONGLOCController.kt | 32 +++++- .../controller/IONGLOCSensorHandler.kt | 108 ++++++++++++++++++ .../controller/helper/IONGLOCExtensions.kt | 31 +++-- .../model/IONGLOCLocationResult.kt | 6 +- 7 files changed, 170 insertions(+), 19 deletions(-) create mode 100644 src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCSensorHandler.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index b939415..5779f76 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. +- 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..d4f621a 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,16 @@ class IONGLOCController internal constructor( } else { googleServicesHelper.getCurrentLocation(options) } - Result.success(location.toOSLocationResult()) + sensorHandler.start() + // Wait briefly for sensors to stabilize if needed, but generally we just take what's available + sensorHandler.updateLocation(location) + val result = location.toOSLocationResult( + magneticHeading = sensorHandler.magneticHeading, + trueHeading = sensorHandler.trueHeading, + headingAccuracy = sensorHandler.headingAccuracy + ) + sensorHandler.stop() + Result.success(result) } } catch (exception: Exception) { Log.d(LOG_TAG, "Error fetching location: ${exception.message}") @@ -217,21 +228,32 @@ 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.trueHeading, + headingAccuracy = sensorHandler.headingAccuracy + ) + } trySend(Result.success(locationResultList)) } + sensorHandler.start() try { requestLocationUpdates( watchId, options, useFallback = useFallback - ) { onNewLocations(it) } + ) { + it.lastOrNull()?.let { last -> sensorHandler.updateLocation(last) } + 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..33c568a --- /dev/null +++ b/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCSensorHandler.kt @@ -0,0 +1,108 @@ +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 + + var magneticHeading: Float? = null + private set + var trueHeading: Float? = null + private set + var headingAccuracy: Float? = null + private set + + private var lastLocation: Location? = null + + fun start() { + accelerometer?.let { + sensorManager.registerListener(this, it, SensorManager.SENSOR_DELAY_UI) + } + magnetometer?.let { + sensorManager.registerListener(this, it, SensorManager.SENSOR_DELAY_UI) + } + } + + fun stop() { + sensorManager.unregisterListener(this) + } + + fun updateLocation(location: Location) { + lastLocation = location + updateHeadings() + } + + 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 = when (event.accuracy) { + SensorManager.SENSOR_STATUS_ACCURACY_HIGH -> 10f + SensorManager.SENSOR_STATUS_ACCURACY_MEDIUM -> 20f + SensorManager.SENSOR_STATUS_ACCURACY_LOW -> 30f + else -> 45f + } + } + + 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 + + lastLocation?.let { + val geoField = GeomagneticField( + it.latitude.toFloat(), + it.longitude.toFloat(), + it.altitude.toFloat(), + System.currentTimeMillis() + ) + trueHeading = (magneticHeading!! + geoField.declination + 360) % 360 + } ?: run { + trueHeading = magneticHeading + } + } + } + + override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) { + if (sensor.type == Sensor.TYPE_MAGNETIC_FIELD) { + headingAccuracy = 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 ) From 9620caf7b7e83360ce6adec2ab3422cfadd58f56 Mon Sep 17 00:00:00 2001 From: Chace Daniels Date: Wed, 4 Feb 2026 09:50:03 -0600 Subject: [PATCH 2/9] remove heading from getCurrentPosition --- CHANGELOG.md | 2 +- .../iongeolocationlib/controller/IONGLOCController.kt | 10 +--------- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5779f76..bad7ac9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [2.2.0] ### 2026-02-02 -- Feature: Add support for magnetic and true heading using device sensors. +- 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] 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 d4f621a..89df782 100644 --- a/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCController.kt +++ b/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCController.kt @@ -99,15 +99,7 @@ class IONGLOCController internal constructor( } else { googleServicesHelper.getCurrentLocation(options) } - sensorHandler.start() - // Wait briefly for sensors to stabilize if needed, but generally we just take what's available - sensorHandler.updateLocation(location) - val result = location.toOSLocationResult( - magneticHeading = sensorHandler.magneticHeading, - trueHeading = sensorHandler.trueHeading, - headingAccuracy = sensorHandler.headingAccuracy - ) - sensorHandler.stop() + val result = location.toOSLocationResult() Result.success(result) } } catch (exception: Exception) { From 4cc83cfae26e6c73380aa7fe5297c19b28256433 Mon Sep 17 00:00:00 2001 From: Chace Daniels Date: Thu, 5 Feb 2026 09:44:54 -0600 Subject: [PATCH 3/9] fix tests --- .../controller/IONGLOCSensorHandler.kt | 3 +++ .../controller/IONGLOCControllerTest.kt | 12 +++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCSensorHandler.kt b/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCSensorHandler.kt index 33c568a..5b3787a 100644 --- a/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCSensorHandler.kt +++ b/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCSensorHandler.kt @@ -19,10 +19,13 @@ internal class IONGLOCSensorHandler(context: Context) : SensorEventListener { private var gravity: FloatArray? = null private var geomagnetic: FloatArray? = null + @Volatile var magneticHeading: Float? = null private set + @Volatile var trueHeading: Float? = null private set + @Volatile var headingAccuracy: Float? = null private set 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..01703f8 100644 --- a/src/test/java/io/ionic/libs/iongeolocationlib/controller/IONGLOCControllerTest.kt +++ b/src/test/java/io/ionic/libs/iongeolocationlib/controller/IONGLOCControllerTest.kt @@ -85,6 +85,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 +118,15 @@ class IONGLOCControllerTest { connectivityManager = connectivityManager, activityLauncher = activityResultLauncher, googleServicesHelper = googleServicesHelper, - fallbackHelper = fallbackHelper + fallbackHelper = fallbackHelper, + sensorHandler = sensorHandler ) + + every { sensorHandler.magneticHeading } returns null + every { sensorHandler.trueHeading } returns null + every { sensorHandler.headingAccuracy } returns null + every { sensorHandler.start() } just runs + every { sensorHandler.stop() } just runs } @After @@ -738,6 +747,7 @@ class IONGLOCControllerTest { every { bearing } returns 4.0f every { speed } returns 0.2f every { time } returns 1L + every { hasBearing() } returns true overrideDefaultMocks() } From dae13bf57596f59af6070a3135888975778a1ec3 Mon Sep 17 00:00:00 2001 From: Chace Daniels Date: Thu, 5 Feb 2026 10:28:39 -0600 Subject: [PATCH 4/9] fix tests --- .../libs/iongeolocationlib/controller/IONGLOCControllerTest.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 01703f8..06001a9 100644 --- a/src/test/java/io/ionic/libs/iongeolocationlib/controller/IONGLOCControllerTest.kt +++ b/src/test/java/io/ionic/libs/iongeolocationlib/controller/IONGLOCControllerTest.kt @@ -790,7 +790,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 From a3de742dd587c4a3e028cd18711d3308a12dab55 Mon Sep 17 00:00:00 2001 From: Chace Daniels Date: Thu, 12 Feb 2026 09:38:01 -0600 Subject: [PATCH 5/9] refactor sensor handler --- .../controller/IONGLOCSensorHandler.kt | 64 ++++--- .../controller/IONGLOCControllerTest.kt | 31 +++- .../controller/IONGLOCSensorHandlerTest.kt | 159 ++++++++++++++++++ 3 files changed, 225 insertions(+), 29 deletions(-) create mode 100644 src/test/java/io/ionic/libs/iongeolocationlib/controller/IONGLOCSensorHandlerTest.kt diff --git a/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCSensorHandler.kt b/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCSensorHandler.kt index 5b3787a..9d50038 100644 --- a/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCSensorHandler.kt +++ b/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCSensorHandler.kt @@ -31,17 +31,28 @@ internal class IONGLOCSensorHandler(context: Context) : SensorEventListener { private var lastLocation: Location? = null + private var watcherCount = 0 + + @Synchronized fun start() { - accelerometer?.let { - sensorManager.registerListener(this, it, SensorManager.SENSOR_DELAY_UI) - } - magnetometer?.let { - sensorManager.registerListener(this, it, SensorManager.SENSOR_DELAY_UI) + 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() { - sensorManager.unregisterListener(this) + watcherCount-- + if (watcherCount <= 0) { + watcherCount = 0 + sensorManager.unregisterListener(this) + } } fun updateLocation(location: Location) { @@ -56,12 +67,7 @@ internal class IONGLOCSensorHandler(context: Context) : SensorEventListener { if (event.sensor.type == Sensor.TYPE_MAGNETIC_FIELD) { geomagnetic = event.values // Using magnetometer accuracy as heading accuracy proxy - headingAccuracy = when (event.accuracy) { - SensorManager.SENSOR_STATUS_ACCURACY_HIGH -> 10f - SensorManager.SENSOR_STATUS_ACCURACY_MEDIUM -> 20f - SensorManager.SENSOR_STATUS_ACCURACY_LOW -> 30f - else -> 45f - } + headingAccuracy = getHeadingAccuracy(event.accuracy) } updateHeadings() @@ -84,14 +90,16 @@ internal class IONGLOCSensorHandler(context: Context) : SensorEventListener { val azimuthInDegrees = Math.toDegrees(azimuthInRadians.toDouble()).toFloat() magneticHeading = (azimuthInDegrees + 360) % 360 - lastLocation?.let { - val geoField = GeomagneticField( - it.latitude.toFloat(), - it.longitude.toFloat(), - it.altitude.toFloat(), - System.currentTimeMillis() - ) - trueHeading = (magneticHeading!! + geoField.declination + 360) % 360 + lastLocation?.let { location -> + magneticHeading?.let { mh -> + val geoField = GeomagneticField( + location.latitude.toFloat(), + location.longitude.toFloat(), + location.altitude.toFloat(), + System.currentTimeMillis() + ) + trueHeading = (mh + geoField.declination + 360) % 360 + } } ?: run { trueHeading = magneticHeading } @@ -100,12 +108,16 @@ internal class IONGLOCSensorHandler(context: Context) : SensorEventListener { override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) { if (sensor.type == Sensor.TYPE_MAGNETIC_FIELD) { - headingAccuracy = when (accuracy) { - SensorManager.SENSOR_STATUS_ACCURACY_HIGH -> 10f - SensorManager.SENSOR_STATUS_ACCURACY_MEDIUM -> 20f - SensorManager.SENSOR_STATUS_ACCURACY_LOW -> 30f - else -> 45f - } + headingAccuracy = getHeadingAccuracy(accuracy) + } + } + + 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/test/java/io/ionic/libs/iongeolocationlib/controller/IONGLOCControllerTest.kt b/src/test/java/io/ionic/libs/iongeolocationlib/controller/IONGLOCControllerTest.kt index 06001a9..5820751 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 @@ -248,6 +249,7 @@ class IONGLOCControllerTest { ) } } + // endregion getCurrentLocation tests // region addWatch tests @@ -372,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.trueHeading } 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 @@ -533,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) 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..7188e3f --- /dev/null +++ b/src/test/java/io/ionic/libs/iongeolocationlib/controller/IONGLOCSensorHandlerTest.kt @@ -0,0 +1,159 @@ +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 + + sensorHandler.updateLocation(location) + + assertNotNull(sensorHandler.trueHeading) + 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 + // Set the mock's values field to our array. + // Note: In a real SensorEvent, this is a final float[], but mockk objects might behave differently. + // Actually, we can just mock the property access if it's a property in Kotlin, but SensorEvent.values is a public field in Java. + // MockK cannot easily mock public fields. We have to instantiate a real SensorEvent or use Unsafe. + // Easier approach: Just rely on the fact that onSensorChanged reads event.values. + // We can't easily mock field access on a mock object. + // Alternative: Create a real SensorEvent via reflection constructor if possible, or use a helper. + // Limitation: SensorEvent has package-private constructor. + + // Let's try mocking the field access using every { } is not possible for fields. + // The simplest way to test onSensorChanged logic logic involving values is to wrap the logic + // or accept that we can't fully unit test `onSensorChanged` without instrumentation or Robolectric. + // However, we CAN write to the field of the mock object! + + valuesField.set(sensorEvent, values) + + val sensorField = SensorEvent::class.java.getField("sensor") + sensorField.isAccessible = true + sensorField.set(sensorEvent, sensor) + + return sensorEvent + } +} From 00d4ff2e4ad4d8f03e7fdbefcf64256b425b1f62 Mon Sep 17 00:00:00 2001 From: Chace Daniels Date: Fri, 20 Feb 2026 08:11:12 -0600 Subject: [PATCH 6/9] refactor true heading --- .../controller/IONGLOCController.kt | 3 +- .../controller/IONGLOCSensorHandler.kt | 44 +++++++++---------- .../controller/IONGLOCControllerTest.kt | 4 +- .../controller/IONGLOCSensorHandlerTest.kt | 18 +------- 4 files changed, 25 insertions(+), 44 deletions(-) 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 89df782..1387ac4 100644 --- a/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCController.kt +++ b/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCController.kt @@ -223,7 +223,7 @@ class IONGLOCController internal constructor( val locationResultList = locations.map { it.toOSLocationResult( magneticHeading = sensorHandler.magneticHeading, - trueHeading = sensorHandler.trueHeading, + trueHeading = sensorHandler.getTrueHeading(it), headingAccuracy = sensorHandler.headingAccuracy ) } @@ -237,7 +237,6 @@ class IONGLOCController internal constructor( options, useFallback = useFallback ) { - it.lastOrNull()?.let { last -> sensorHandler.updateLocation(last) } onNewLocations(it) } } catch (e: Exception) { diff --git a/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCSensorHandler.kt b/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCSensorHandler.kt index 9d50038..10b95d7 100644 --- a/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCSensorHandler.kt +++ b/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCSensorHandler.kt @@ -22,15 +22,11 @@ internal class IONGLOCSensorHandler(context: Context) : SensorEventListener { @Volatile var magneticHeading: Float? = null private set - @Volatile - var trueHeading: Float? = null - private set + @Volatile var headingAccuracy: Float? = null private set - private var lastLocation: Location? = null - private var watcherCount = 0 @Synchronized @@ -55,11 +51,6 @@ internal class IONGLOCSensorHandler(context: Context) : SensorEventListener { } } - fun updateLocation(location: Location) { - lastLocation = location - updateHeadings() - } - override fun onSensorChanged(event: SensorEvent) { if (event.sensor.type == Sensor.TYPE_ACCELEROMETER) { gravity = event.values @@ -89,20 +80,23 @@ internal class IONGLOCSensorHandler(context: Context) : SensorEventListener { val azimuthInRadians = orientation[0] val azimuthInDegrees = Math.toDegrees(azimuthInRadians.toDouble()).toFloat() magneticHeading = (azimuthInDegrees + 360) % 360 + } + } - lastLocation?.let { location -> - magneticHeading?.let { mh -> - val geoField = GeomagneticField( - location.latitude.toFloat(), - location.longitude.toFloat(), - location.altitude.toFloat(), - System.currentTimeMillis() - ) - trueHeading = (mh + geoField.declination + 360) % 360 - } - } ?: run { - trueHeading = magneticHeading - } + /** + * 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 } } @@ -112,6 +106,10 @@ internal class IONGLOCSensorHandler(context: Context) : SensorEventListener { } } + /** + * 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 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 5820751..ec95ebe 100644 --- a/src/test/java/io/ionic/libs/iongeolocationlib/controller/IONGLOCControllerTest.kt +++ b/src/test/java/io/ionic/libs/iongeolocationlib/controller/IONGLOCControllerTest.kt @@ -124,7 +124,7 @@ class IONGLOCControllerTest { ) every { sensorHandler.magneticHeading } returns null - every { sensorHandler.trueHeading } returns null + every { sensorHandler.getTrueHeading(any()) } returns null every { sensorHandler.headingAccuracy } returns null every { sensorHandler.start() } just runs every { sensorHandler.stop() } just runs @@ -380,7 +380,7 @@ class IONGLOCControllerTest { runTest { givenSuccessConditions() every { sensorHandler.magneticHeading } returns 100f - every { sensorHandler.trueHeading } returns 110f + every { sensorHandler.getTrueHeading(any()) } returns 110f every { sensorHandler.headingAccuracy } returns 5f sut.addWatch(mockk(), locationOptions, "1").test { diff --git a/src/test/java/io/ionic/libs/iongeolocationlib/controller/IONGLOCSensorHandlerTest.kt b/src/test/java/io/ionic/libs/iongeolocationlib/controller/IONGLOCSensorHandlerTest.kt index 7188e3f..615519c 100644 --- a/src/test/java/io/ionic/libs/iongeolocationlib/controller/IONGLOCSensorHandlerTest.kt +++ b/src/test/java/io/ionic/libs/iongeolocationlib/controller/IONGLOCSensorHandlerTest.kt @@ -120,9 +120,7 @@ class IONGLOCSensorHandlerTest { every { location.longitude } returns -122.4194 every { location.altitude } returns 0.0 - sensorHandler.updateLocation(location) - - assertNotNull(sensorHandler.trueHeading) + assertNotNull(sensorHandler.getTrueHeading(location)) assertNotNull(sensorHandler.magneticHeading) } @@ -134,20 +132,6 @@ class IONGLOCSensorHandlerTest { // 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 - // Set the mock's values field to our array. - // Note: In a real SensorEvent, this is a final float[], but mockk objects might behave differently. - // Actually, we can just mock the property access if it's a property in Kotlin, but SensorEvent.values is a public field in Java. - // MockK cannot easily mock public fields. We have to instantiate a real SensorEvent or use Unsafe. - // Easier approach: Just rely on the fact that onSensorChanged reads event.values. - // We can't easily mock field access on a mock object. - // Alternative: Create a real SensorEvent via reflection constructor if possible, or use a helper. - // Limitation: SensorEvent has package-private constructor. - - // Let's try mocking the field access using every { } is not possible for fields. - // The simplest way to test onSensorChanged logic logic involving values is to wrap the logic - // or accept that we can't fully unit test `onSensorChanged` without instrumentation or Robolectric. - // However, we CAN write to the field of the mock object! - valuesField.set(sensorEvent, values) val sensorField = SensorEvent::class.java.getField("sensor") From 7116bc261cc8b2be8db725dc7d56c37f725fbb6d Mon Sep 17 00:00:00 2001 From: Chace Daniels Date: Mon, 23 Feb 2026 10:39:24 -0600 Subject: [PATCH 7/9] stop sensor on clear --- .../libs/iongeolocationlib/controller/IONGLOCController.kt | 4 ++++ 1 file changed, 4 insertions(+) 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 1387ac4..3a67682 100644 --- a/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCController.kt +++ b/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCController.kt @@ -71,6 +71,7 @@ class IONGLOCController internal constructor( ) private lateinit var resolveLocationSettingsResultFlow: MutableSharedFlow> + private val watchChannels: MutableMap>>> = mutableMapOf() private val watchLocationHandlers: MutableMap = mutableMapOf() private val watchIdsBlacklist: MutableList = mutableListOf() @@ -230,6 +231,7 @@ class IONGLOCController internal constructor( trySend(Result.success(locationResultList)) } + watchChannels[watchId] = this sensorHandler.start() try { requestLocationUpdates( @@ -244,6 +246,7 @@ class IONGLOCController internal constructor( } awaitClose { + watchChannels.remove(watchId) sensorHandler.stop() Log.d(LOG_TAG, "channel closed") } @@ -331,6 +334,7 @@ class IONGLOCController internal constructor( * @return true if watch was cleared, false if watch was not found */ private fun clearWatch(id: String, addToBlackList: Boolean): Boolean { + watchChannels.remove(id)?.close() val watchHandler = watchLocationHandlers.remove(key = id) return when (watchHandler) { is LocationHandler.Callback -> { From 4d49acc673cdc245ee6a622a228b6d0283c74f0b Mon Sep 17 00:00:00 2001 From: Chace Daniels Date: Wed, 25 Feb 2026 08:07:07 -0600 Subject: [PATCH 8/9] move sensor handler stop to clearWatch --- .../libs/iongeolocationlib/controller/IONGLOCController.kt | 4 +++- .../iongeolocationlib/controller/IONGLOCControllerTest.kt | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) 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 3a67682..4c1317e 100644 --- a/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCController.kt +++ b/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCController.kt @@ -247,7 +247,6 @@ class IONGLOCController internal constructor( awaitClose { watchChannels.remove(watchId) - sensorHandler.stop() Log.d(LOG_TAG, "channel closed") } } @@ -336,6 +335,9 @@ class IONGLOCController internal constructor( private fun clearWatch(id: String, addToBlackList: Boolean): Boolean { watchChannels.remove(id)?.close() val watchHandler = watchLocationHandlers.remove(key = id) + + sensorHandler.stop() + return when (watchHandler) { is LocationHandler.Callback -> { googleServicesHelper.removeLocationUpdates(watchHandler.callback) 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 ec95ebe..4a92ee0 100644 --- a/src/test/java/io/ionic/libs/iongeolocationlib/controller/IONGLOCControllerTest.kt +++ b/src/test/java/io/ionic/libs/iongeolocationlib/controller/IONGLOCControllerTest.kt @@ -416,6 +416,7 @@ class IONGLOCControllerTest { expectNoEvents() } verify { fusedLocationProviderClient.removeLocationUpdates(locationCallback) } + verify { sensorHandler.stop() } } @Test @@ -427,6 +428,7 @@ class IONGLOCControllerTest { assertFalse(result) verify(inverse = true) { fusedLocationProviderClient.removeLocationUpdates(any()) } + verify { sensorHandler.stop() } } @Test From 9975e478213279d78b328c30c80eaef6cb616b8c Mon Sep 17 00:00:00 2001 From: Chace Daniels Date: Thu, 26 Feb 2026 10:36:02 -0600 Subject: [PATCH 9/9] remove watch channels --- .../libs/iongeolocationlib/controller/IONGLOCController.kt | 4 ---- 1 file changed, 4 deletions(-) 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 4c1317e..9735890 100644 --- a/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCController.kt +++ b/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCController.kt @@ -71,7 +71,6 @@ class IONGLOCController internal constructor( ) private lateinit var resolveLocationSettingsResultFlow: MutableSharedFlow> - private val watchChannels: MutableMap>>> = mutableMapOf() private val watchLocationHandlers: MutableMap = mutableMapOf() private val watchIdsBlacklist: MutableList = mutableListOf() @@ -231,7 +230,6 @@ class IONGLOCController internal constructor( trySend(Result.success(locationResultList)) } - watchChannels[watchId] = this sensorHandler.start() try { requestLocationUpdates( @@ -246,7 +244,6 @@ class IONGLOCController internal constructor( } awaitClose { - watchChannels.remove(watchId) Log.d(LOG_TAG, "channel closed") } } @@ -333,7 +330,6 @@ class IONGLOCController internal constructor( * @return true if watch was cleared, false if watch was not found */ private fun clearWatch(id: String, addToBlackList: Boolean): Boolean { - watchChannels.remove(id)?.close() val watchHandler = watchLocationHandlers.remove(key = id) sensorHandler.stop()