diff --git a/CHANGELOG.md b/CHANGELOG.md
index f196b01..b939415 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.1.0]
+
+### 2025-10-31
+
+- Feature: Add native timeout to `watchPosition` Add new `interval` to control location updates without `timeout` variable.
+
## [2.0.0]
### 2025-09-30
diff --git a/README.md b/README.md
index 0b61919..978541c 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.0.0")
+ implementation("io.ionic.libs:iongeolocation-android:2.1.0")
}
```
@@ -100,6 +100,11 @@ Common issues and solutions:
- Try setting `IONGLOCLocationOptions.enableLocationManagerFallback` to true - available since version 2.0.0
- Keep in mind that only GPS signal can be used if there's no network, in which case it may only be triggered if the actual GPS coordinates are changing (e.g. walking or driving).
+4. Timeout received in `watchPosition`
+ - Use the `IONGLOCLocationOptions.interval` parameter, introduced in version 2.1.0, and set it to below `timeout`, in order to try to receive a first location update before timing out.
+ - Increase the `IONGLOCLocationOptions.timeout` value, if your use case can wait for some time.
+ - Increase `IONGLOCLocationOptions.maximumAge` to allow to retrieve an older location quickly for the first update.
+
## Contributing
1. Fork the repository
diff --git a/pom.xml b/pom.xml
index 8179603..fb52e7f 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,5 +6,5 @@
4.0.0
io.ionic.libs
iongeolocation-android
- 2.0.0
+ 2.1.0
\ No newline at end of file
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 0df2be5..269399b 100644
--- a/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCController.kt
+++ b/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCController.kt
@@ -16,17 +16,27 @@ import com.google.android.gms.location.LocationResult
import com.google.android.gms.location.LocationServices
import io.ionic.libs.iongeolocationlib.controller.helper.IONGLOCFallbackHelper
import io.ionic.libs.iongeolocationlib.controller.helper.IONGLOCGoogleServicesHelper
+import io.ionic.libs.iongeolocationlib.controller.helper.emitOrTimeoutBeforeFirstEmission
import io.ionic.libs.iongeolocationlib.controller.helper.toOSLocationResult
import io.ionic.libs.iongeolocationlib.model.IONGLOCException
import io.ionic.libs.iongeolocationlib.model.IONGLOCLocationOptions
import io.ionic.libs.iongeolocationlib.model.IONGLOCLocationResult
import io.ionic.libs.iongeolocationlib.model.internal.LocationHandler
import io.ionic.libs.iongeolocationlib.model.internal.LocationSettingsResult
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.flatMapConcat
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.isActive
/**
* Entry point in IONGeolocationLib-Android
@@ -127,44 +137,33 @@ class IONGLOCController internal constructor(
* @param activity the Android activity from which the location request is being triggered
* @param options location request options to use
* @param watchId a unique id identifying the watch
- * @return Flow in which location updates will be emitted
+ * @return Flow in which location updates will be emitted, or failure if something went wrong in retrieving updates
*/
+ @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
fun addWatch(
activity: Activity,
options: IONGLOCLocationOptions,
watchId: String
- ): Flow>> = callbackFlow {
- try {
- fun onNewLocations(locations: List) {
- if (checkWatchInBlackList(watchId)) {
- return
- }
- val locationResultList = locations.map { currentLocation ->
- currentLocation.toOSLocationResult()
- }
- trySend(Result.success(locationResultList))
- }
+ ): Flow>> {
- val checkResult: Result =
- checkLocationPreconditions(activity, options, isSingleLocationRequest = false)
- if (checkResult.shouldNotProceed(options)) {
- trySend(
- Result.failure(checkResult.exceptionOrNull() ?: NullPointerException())
- )
+ val setupFlow = watchSetupPreconditionsFlow(activity, options)
+ // Concatenate flows: only proceed with watch if setup is successful
+ return setupFlow.flatMapConcat { setupResult ->
+ if (setupResult.isFailure) {
+ flowOf(Result.failure(setupResult.exceptionOrNull() ?: NullPointerException()))
} else {
- requestLocationUpdates(
- watchId,
+ watchLocationUpdatesFlow(
options,
- useFallback = checkResult.isFailure && options.enableLocationManagerFallback
- ) { onNewLocations(it) }
+ useFallback = setupResult.getOrNull() ?: false,
+ watchId
+ )
+ .emitOrTimeoutBeforeFirstEmission(timeoutMillis = options.timeout)
+ .onEach { emission ->
+ if (emission.exceptionOrNull() is IONGLOCException.IONGLOCLocationRetrievalTimeoutException) {
+ clearWatch(watchId)
+ }
+ }
}
- } catch (exception: Exception) {
- Log.d(LOG_TAG, "Error requesting location updates: ${exception.message}")
- trySend(Result.failure(exception))
- }
-
- awaitClose {
- clearWatch(watchId)
}
}
@@ -175,6 +174,68 @@ class IONGLOCController internal constructor(
*/
fun clearWatch(id: String): Boolean = clearWatch(id, addToBlackList = true)
+ /**
+ * Create a flow for setup and checking preconditions for watch location
+ * @param activity the Android activity from which the location request is being triggered
+ * @param options location request options to use
+ * @return Flow with success if pre-condition checks passed and boolean flag to decide whether or not fallback is required, or failure otherwise.
+ */
+ private fun watchSetupPreconditionsFlow(
+ activity: Activity,
+ options: IONGLOCLocationOptions
+ ): Flow> = flow {
+ try {
+ val checkResult: Result =
+ checkLocationPreconditions(activity, options, isSingleLocationRequest = false)
+ if (checkResult.shouldNotProceed(options)) {
+ emit(Result.failure(checkResult.exceptionOrNull() ?: NullPointerException()))
+ } else {
+ val useFallback = checkResult.isFailure && options.enableLocationManagerFallback
+ emit(Result.success(useFallback))
+ }
+ } catch (exception: Exception) {
+ Log.d(LOG_TAG, "Error getting pre-conditions for watch: ${exception.message}")
+ if (currentCoroutineContext().isActive) {
+ emit(Result.failure(exception))
+ } else if (exception is CancellationException) {
+ throw exception
+ }
+ }
+ }
+
+ /**
+ * Create a flow where location updates are emitted for a watch.
+ * @param options location request options to use
+ * @param useFallback whether or not the fallback should be used
+ * @param watchId a unique id identifying the watch
+ * @return Flow in which location updates will be emitted
+ */
+ private fun watchLocationUpdatesFlow(
+ options: IONGLOCLocationOptions,
+ useFallback: Boolean,
+ watchId: String,
+ ): Flow>> = callbackFlow {
+ fun onNewLocations(locations: List) {
+ if (checkWatchInBlackList(watchId)) return
+ val locationResultList = locations.map { it.toOSLocationResult() }
+ trySend(Result.success(locationResultList))
+ }
+
+ try {
+ requestLocationUpdates(
+ watchId,
+ options,
+ useFallback = useFallback
+ ) { onNewLocations(it) }
+ } catch (e: Exception) {
+ trySend(Result.failure(e))
+ }
+
+ awaitClose {
+ Log.d(LOG_TAG, "channel closed")
+ }
+ }
+
/**
* Checks if all preconditions for retrieving location are met
* @param activity the Android activity from which the location request is being triggered
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 e6197bd..d3cc91c 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
@@ -8,6 +8,11 @@ import android.os.Build
import androidx.core.location.LocationManagerCompat
import io.ionic.libs.iongeolocationlib.model.IONGLOCException
import io.ionic.libs.iongeolocationlib.model.IONGLOCLocationResult
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.channelFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withTimeoutOrNull
/**
* @return true if there's any active network capability that could be used to improve location, false otherwise.
@@ -59,4 +64,35 @@ internal fun Location.toOSLocationResult(): IONGLOCLocationResult = IONGLOCLocat
heading = this.bearing,
speed = this.speed,
timestamp = this.time
-)
\ No newline at end of file
+)
+
+/**
+ * Flow extension to either emit its values, or emit a timeout error if [timeoutMillis] is reached before any emission
+ */
+fun Flow>.emitOrTimeoutBeforeFirstEmission(timeoutMillis: Long): Flow> =
+ channelFlow {
+ var firstValue: Result? = null
+
+ val job = launch {
+ collect { value ->
+ if (firstValue == null) firstValue = value
+ send(value)
+ }
+ }
+
+ // Poll until first emission, or timeout
+ withTimeoutOrNull(timeMillis = timeoutMillis) {
+ while (firstValue == null) {
+ delay(timeMillis = 10)
+ }
+ } ?: run {
+ send(
+ Result.failure(
+ IONGLOCException.IONGLOCLocationRetrievalTimeoutException(
+ "Location request timed out before first emission"
+ )
+ )
+ )
+ job.cancel()
+ }
+ }
\ No newline at end of file
diff --git a/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/helper/IONGLOCFallbackHelper.kt b/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/helper/IONGLOCFallbackHelper.kt
index 5d6f66f..83f42a0 100644
--- a/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/helper/IONGLOCFallbackHelper.kt
+++ b/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/helper/IONGLOCFallbackHelper.kt
@@ -94,7 +94,7 @@ internal class IONGLOCFallbackHelper(
locationListener.onLocationChanged(validCacheLocation)
}
- val locationRequest = LocationRequestCompat.Builder(options.timeout).apply {
+ val locationRequest = LocationRequestCompat.Builder(options.interval).apply {
setQuality(getQualityToUse(options))
if (options.minUpdateInterval != null) {
setMinUpdateIntervalMillis(options.minUpdateInterval)
diff --git a/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/helper/IONGLOCGoogleServicesHelper.kt b/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/helper/IONGLOCGoogleServicesHelper.kt
index 9dbcb87..8ddd01e 100644
--- a/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/helper/IONGLOCGoogleServicesHelper.kt
+++ b/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/helper/IONGLOCGoogleServicesHelper.kt
@@ -51,7 +51,7 @@ internal class IONGLOCGoogleServicesHelper(
): LocationSettingsResult {
val request = LocationRequest.Builder(
if (options.enableHighAccuracy) Priority.PRIORITY_HIGH_ACCURACY else Priority.PRIORITY_BALANCED_POWER_ACCURACY,
- options.timeout
+ options.interval
).build()
val builder = LocationSettingsRequest.Builder()
@@ -144,7 +144,7 @@ internal class IONGLOCGoogleServicesHelper(
options: IONGLOCLocationOptions,
locationCallback: LocationCallback
) {
- val locationRequest = LocationRequest.Builder(options.timeout).apply {
+ val locationRequest = LocationRequest.Builder(options.interval).apply {
setMaxUpdateAgeMillis(options.maximumAge)
setPriority(if (options.enableHighAccuracy) Priority.PRIORITY_HIGH_ACCURACY else Priority.PRIORITY_BALANCED_POWER_ACCURACY)
if (options.minUpdateInterval != null) {
diff --git a/src/main/kotlin/io/ionic/libs/iongeolocationlib/model/IONGLOCLocationOptions.kt b/src/main/kotlin/io/ionic/libs/iongeolocationlib/model/IONGLOCLocationOptions.kt
index e5eb3e5..2c0f06c 100644
--- a/src/main/kotlin/io/ionic/libs/iongeolocationlib/model/IONGLOCLocationOptions.kt
+++ b/src/main/kotlin/io/ionic/libs/iongeolocationlib/model/IONGLOCLocationOptions.kt
@@ -3,10 +3,8 @@ package io.ionic.libs.iongeolocationlib.model
/**
* Data class representing the options passed to getCurrentPosition and watchPosition
*
- * @property timeout Depending on the method:
- * 1. for `getCurrentPosition`, it's the maximum time in **milliseconds** to wait for a fresh
- * location fix before throwing a timeout exception.
- * 2. for `addWatch` the interval at which new location updates will be returned (if available)
+ * @property timeout The maximum time in **milliseconds** to wait for a new location fix before
+ * throwing a timeout exception.
* @property maximumAge Maximum acceptable age in **milliseconds** of a cached location to return.
* If the cached location is older than this value, then a fresh location will always be fetched.
* @property enableHighAccuracy Whether or not the requested location should have high accuracy.
@@ -21,6 +19,9 @@ package io.ionic.libs.iongeolocationlib.model
* This means that to receive location, you may need a higher timeout.
* If the device's in airplane mode, only the GPS provider is used, which may only return a location
* if there's movement (e.g. walking or driving), otherwise it may time out.
+ * @property interval Default interval in **milliseconds** to receive location updates in `addWatch`.
+ * By default equal to [timeout]. If you are experiencing location timeouts, try setting
+ * [interval] to a value lower than [timeout].
* @property minUpdateInterval Optional minimum interval in **milliseconds** between consecutive
* location updates when using `addWatch`.
*/
@@ -29,5 +30,6 @@ data class IONGLOCLocationOptions(
val maximumAge: Long,
val enableHighAccuracy: Boolean,
val enableLocationManagerFallback: Boolean,
+ val interval: Long = timeout,
val minUpdateInterval: Long? = 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 a2154e2..a3a2373 100644
--- a/src/test/java/io/ionic/libs/iongeolocationlib/controller/IONGLOCControllerTest.kt
+++ b/src/test/java/io/ionic/libs/iongeolocationlib/controller/IONGLOCControllerTest.kt
@@ -56,7 +56,6 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await
import kotlinx.coroutines.test.advanceTimeBy
-import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.After
@@ -249,7 +248,8 @@ class IONGLOCControllerTest {
givenSuccessConditions()
sut.addWatch(mockk(), locationOptions, "1").test {
- advanceUntilIdle() // to wait until locationCallback is instantiated
+ // to wait until locationListenerCompat is instantiated, but not long enough for timeout to trigger
+ advanceTimeBy(locationOptionsWithFallback.timeout / 2)
emitLocationsGMS(listOf(mockAndroidLocation))
var result = awaitItem()
assertTrue(result.isSuccess)
@@ -288,7 +288,7 @@ class IONGLOCControllerTest {
assertTrue(exception is IONGLOCException.IONGLOCGoogleServicesException)
assertTrue((exception as IONGLOCException.IONGLOCGoogleServicesException).resolvable)
}
- expectNoEvents()
+ awaitComplete()
}
}
@@ -299,7 +299,8 @@ class IONGLOCControllerTest {
givenResolvableApiException(Activity.RESULT_OK)
sut.addWatch(mockk(), locationOptions, "1").test {
- advanceUntilIdle() // to wait until locationCallback is instantiated
+ // to wait until locationListenerCompat is instantiated, but not long enough for timeout to trigger
+ advanceTimeBy(locationOptionsWithFallback.timeout / 2)
emitLocationsGMS(listOf(mockAndroidLocation))
val result = awaitItem()
@@ -321,7 +322,7 @@ class IONGLOCControllerTest {
assertTrue(result.isFailure)
assertTrue(result.exceptionOrNull() is IONGLOCException.IONGLOCRequestDeniedException)
- expectNoEvents()
+ awaitComplete()
}
}
@@ -344,7 +345,22 @@ class IONGLOCControllerTest {
(exception as IONGLOCException.IONGLOCSettingsException).cause
)
}
- expectNoEvents()
+ awaitComplete()
+ }
+ }
+
+ @Test
+ fun `given there are no location updates, when addWatch is called, IONGLOCLocationRetrievalTimeoutException is returned`() =
+ runTest {
+ givenSuccessConditions() // to instantiate mocks
+
+ sut.addWatch(mockk(), locationOptions, "1").test {
+ testScheduler.advanceUntilIdle()
+ val result = awaitItem()
+
+ assertTrue(result.isFailure)
+ assertTrue(result.exceptionOrNull() is IONGLOCException.IONGLOCLocationRetrievalTimeoutException)
+ awaitComplete()
}
}
// endregion addWatch tests
@@ -355,7 +371,8 @@ class IONGLOCControllerTest {
val watchId = "id"
givenSuccessConditions()
sut.addWatch(mockk(), locationOptions, watchId).test {
- advanceUntilIdle() // to wait until locationCallback is instantiated
+ // to wait until locationListenerCompat is instantiated, but not long enough for timeout to trigger
+ advanceTimeBy(locationOptionsWithFallback.timeout / 2)
val result = sut.clearWatch(watchId)
@@ -384,7 +401,8 @@ class IONGLOCControllerTest {
sut.clearWatch(watchId)
sut.addWatch(mockk(), locationOptions, watchId).test {
- advanceUntilIdle() // to wait until locationCallback is instantiated
+ // to wait until locationListenerCompat is instantiated, but not long enough for timeout to trigger
+ advanceTimeBy(locationOptionsWithFallback.timeout / 2)
emitLocationsGMS(listOf(mockAndroidLocation))
@@ -524,7 +542,8 @@ class IONGLOCControllerTest {
givenPlayServicesNotAvailableWithResolvableError()
sut.addWatch(mockk(), locationOptionsWithFallback, "1").test {
- advanceUntilIdle() // to wait until locationListenerCompat is instantiated
+ // to wait until locationListenerCompat is instantiated, but not long enough for timeout to trigger
+ advanceTimeBy(locationOptionsWithFallback.timeout / 2)
emitLocationsFallback(listOf(mockAndroidLocation))
var result = awaitItem()
assertTrue(result.isSuccess)
@@ -559,7 +578,7 @@ class IONGLOCControllerTest {
}
sut.addWatch(mockk(), locationOptionsWithFallback, "1").test {
- advanceUntilIdle() // to wait until locationListenerCompat is instantiated
+ testScheduler.advanceUntilIdle()
val result = awaitItem()
assertTrue(result.isSuccess)
@@ -571,6 +590,39 @@ class IONGLOCControllerTest {
}
}
+ @Test
+ fun `given fallback is being used but there are no location updates, when addWatch is called, IONGLOCLocationRetrievalTimeoutException is returned`() =
+ runTest {
+ givenSuccessConditions()
+ givenPlayServicesNotAvailableWithUnResolvableError()
+
+ sut.addWatch(mockk(), locationOptionsWithFallback, "1").test {
+ testScheduler.advanceUntilIdle()
+ val result = awaitItem()
+
+ assertTrue(result.isFailure)
+ assertTrue(result.exceptionOrNull() is IONGLOCException.IONGLOCLocationRetrievalTimeoutException)
+ awaitComplete()
+ }
+ }
+
+ @Test
+ fun `given all preconditions pass and enableLocationManagerFallback=true, when addWatch is called, the fallback is not called`() =
+ runTest {
+ givenSuccessConditions() // to instantiate mocks
+
+ sut.addWatch(mockk(), locationOptionsWithFallback, "1").test {
+ // to wait until locationListenerCompat is instantiated, but not long enough for timeout to trigger
+ advanceTimeBy(locationOptionsWithFallback.timeout / 2)
+ emitLocationsGMS(listOf(mockAndroidLocation))
+ assertTrue(awaitItem().isSuccess)
+ }
+
+ coVerify(inverse = true) {
+ fallbackHelper.requestLocationUpdates(any(), any())
+ }
+ }
+
@Test
fun `given watch was added via fallback, when clearWatch is called, true is returned`() =
runTest {
@@ -578,7 +630,10 @@ class IONGLOCControllerTest {
givenSuccessConditions()
givenPlayServicesNotAvailableWithUnResolvableError()
sut.addWatch(mockk(), locationOptionsWithFallback, watchId).test {
- advanceUntilIdle() // to wait until locationListenerCompat is instantiated
+ // to wait until locationListenerCompat is instantiated, but not long enough for timeout to trigger
+ advanceTimeBy(locationOptionsWithFallback.timeout / 2)
+ emitLocationsFallback(listOf(mockAndroidLocation))
+ awaitItem()
val result = sut.clearWatch(watchId)