Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
```

Expand Down
4 changes: 2 additions & 2 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ class IONGLOCController internal constructor(
),
private val fallbackHelper: IONGLOCFallbackHelper = IONGLOCFallbackHelper(
locationManager, connectivityManager
)
),
private val sensorHandler: IONGLOCSensorHandler
) {

constructor(
Expand All @@ -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<Result<Unit>>
Expand Down Expand Up @@ -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}")
Expand Down Expand Up @@ -217,21 +220,31 @@ class IONGLOCController internal constructor(
): Flow<Result<List<IONGLOCLocationResult>>> = callbackFlow {
fun onNewLocations(locations: List<Location>) {
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()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm how about if a user sets multiple watches?

Would think this call would make more sense inside clearWatch when watchLocationHandlers is empty. But I guess then there's the case of the app closing, we'll want to unregister the listener as well?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since sensorHandler tracks the watcherCount internally, it safely handles multiple watches. Tying it to awaitClose is a safer bet so we don't accidentally leak the sensor listeners if the app closes or the flow gets cancelled unexpectedly. If we relied solely on clearWatch, we would risk memory leaks whenever the user forgets to explicitly clear the watch

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, that's fair, but we're not calling sensorHandler.stop() on clearWatch, perhaps we should, or you don't think it's needed?

Log.d(LOG_TAG, "channel closed")
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -85,6 +86,8 @@ class IONGLOCControllerTest {
)
private val fallbackHelper = spyk(IONGLOCFallbackHelper(locationManager, connectivityManager))

private val sensorHandler = mockk<IONGLOCSensorHandler>(relaxed = true)

private val mockAndroidLocation = mockkLocation()
private val locationSettingsTask = mockk<Task<LocationSettingsResponse>>(relaxed = true)
private val currentLocationTask = mockk<Task<Location?>>(relaxed = true)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -239,6 +249,7 @@ class IONGLOCControllerTest {
)
}
}

// endregion getCurrentLocation tests

// region addWatch tests
Expand Down Expand Up @@ -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<Activity>(), 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
Expand Down Expand Up @@ -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<ApiException> {
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<Activity>(), locationOptionsWithFallback)
Expand Down Expand Up @@ -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()
}

Expand Down Expand Up @@ -780,7 +815,8 @@ class IONGLOCControllerTest {
altitudeAccuracy = 1.5f,
heading = 4.0f,
speed = 0.2f,
timestamp = 1L
timestamp = 1L,
course = 4.0f
)
}
}
Loading