From aa83045184631f371dc5cc3025ae2385ba77a8cc Mon Sep 17 00:00:00 2001 From: OS-pedrogustavobilro Date: Thu, 30 Oct 2025 17:16:58 +0000 Subject: [PATCH 1/9] feat: Separate timeout from watchLocation interval References: https://outsystemsrd.atlassian.net/browse/RMET-4688 --- .../controller/IONGLOCController.kt | 31 ++++++++++ .../helper/IONGLOCFallbackHelper.kt | 2 +- .../helper/IONGLOCGoogleServicesHelper.kt | 4 +- .../model/IONGLOCLocationOptions.kt | 10 ++-- .../controller/IONGLOCControllerTest.kt | 60 ++++++++++++++++--- 5 files changed, 91 insertions(+), 16 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 0df2be5..6cdeb6c 100644 --- a/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCController.kt +++ b/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCController.kt @@ -8,6 +8,7 @@ import android.net.ConnectivityManager import android.util.Log import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.IntentSenderRequest +import androidx.annotation.VisibleForTesting import androidx.core.location.LocationListenerCompat import androidx.core.location.LocationManagerCompat import com.google.android.gms.location.FusedLocationProviderClient @@ -22,11 +23,17 @@ 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.FlowPreview +import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.timeout +import kotlin.time.DurationUnit +import kotlin.time.toDuration /** * Entry point in IONGeolocationLib-Android @@ -129,10 +136,34 @@ class IONGLOCController internal constructor( * @param watchId a unique id identifying the watch * @return Flow in which location updates will be emitted */ + @OptIn(FlowPreview::class) fun addWatch( activity: Activity, options: IONGLOCLocationOptions, watchId: String + ): Flow>> = addWatchInternal(activity, options, watchId) + .timeout(options.timeout.toDuration(DurationUnit.MILLISECONDS)) + .catch { e -> + if (e is TimeoutCancellationException) { + emit( + Result.failure( + IONGLOCException.IONGLOCLocationRetrievalTimeoutException( + "Watch location request timed out. Try a higher timeout value.", + e + ) + ) + ) + clearWatch(watchId) + } else { + throw e + } + } + + @VisibleForTesting + internal fun addWatchInternal( + activity: Activity, + options: IONGLOCLocationOptions, + watchId: String ): Flow>> = callbackFlow { try { fun onNewLocations(locations: List) { 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..2972e4d 100644 --- a/src/test/java/io/ionic/libs/iongeolocationlib/controller/IONGLOCControllerTest.kt +++ b/src/test/java/io/ionic/libs/iongeolocationlib/controller/IONGLOCControllerTest.kt @@ -249,7 +249,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) @@ -280,7 +281,8 @@ class IONGLOCControllerTest { runTest { givenPlayServicesNotAvailableWithResolvableError() - sut.addWatch(mockk(), locationOptions, "1").test { + // skip timeout in tests by calling internal method + sut.addWatchInternal(mockk(), locationOptions, "1").test { val result = awaitItem() assertTrue(result.isFailure) @@ -299,7 +301,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() @@ -332,7 +335,8 @@ class IONGLOCControllerTest { val error = RuntimeException() coEvery { locationSettingsTask.await() } throws error - sut.addWatch(mockk(), locationOptions, "1").test { + // skip timeout in tests by calling internal method + sut.addWatchInternal(mockk(), locationOptions, "1").test { testScheduler.advanceTimeBy(DELAY) val result = awaitItem() @@ -347,6 +351,21 @@ class IONGLOCControllerTest { expectNoEvents() } } + + @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 // region clearWatch tests @@ -355,7 +374,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 +404,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 +545,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) @@ -558,7 +580,8 @@ class IONGLOCControllerTest { every { time } returns currentTime } - sut.addWatch(mockk(), locationOptionsWithFallback, "1").test { + // skip timeout in tests by calling internal method + sut.addWatchInternal(mockk(), locationOptionsWithFallback, "1").test { advanceUntilIdle() // to wait until locationListenerCompat is instantiated val result = awaitItem() @@ -571,6 +594,22 @@ 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 watch was added via fallback, when clearWatch is called, true is returned`() = runTest { @@ -578,7 +617,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) From a585ffb2dc50d42802564762349b7302355e009d Mon Sep 17 00:00:00 2001 From: OS-pedrogustavobilro Date: Fri, 31 Oct 2025 11:26:07 +0000 Subject: [PATCH 2/9] fix: Only watch start timeout in location updates So that users don't get timeout e.g. in resolving pre-conditions like turning on location. References: https://outsystemsrd.atlassian.net/browse/RMET-4688 --- .../controller/IONGLOCController.kt | 140 ++++++++++++------ .../controller/IONGLOCControllerTest.kt | 18 +-- 2 files changed, 99 insertions(+), 59 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 6cdeb6c..cfbc057 100644 --- a/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCController.kt +++ b/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCController.kt @@ -8,7 +8,6 @@ import android.net.ConnectivityManager import android.util.Log import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.IntentSenderRequest -import androidx.annotation.VisibleForTesting import androidx.core.location.LocationListenerCompat import androidx.core.location.LocationManagerCompat import com.google.android.gms.location.FusedLocationProviderClient @@ -23,15 +22,23 @@ 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.TimeoutCancellationException 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.catch import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapConcat +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.timeout +import kotlinx.coroutines.isActive +import org.jetbrains.annotations.VisibleForTesting import kotlin.time.DurationUnit import kotlin.time.toDuration @@ -134,64 +141,106 @@ 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) + @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) fun addWatch( activity: Activity, options: IONGLOCLocationOptions, watchId: String - ): Flow>> = addWatchInternal(activity, options, watchId) - .timeout(options.timeout.toDuration(DurationUnit.MILLISECONDS)) - .catch { e -> - if (e is TimeoutCancellationException) { - emit( - Result.failure( - IONGLOCException.IONGLOCLocationRetrievalTimeoutException( - "Watch location request timed out. Try a higher timeout value.", - e - ) - ) - ) - clearWatch(watchId) + ): Flow>> { + + val setupFlow = watchSetupPreconditionsFlow(activity, options) + + val updatesFlow = watchLocationUpdatesFlow(options, watchId) + + // Concatenate flows: only proceed with watch if setup is successful + return setupFlow.flatMapConcat { setupResult -> + if (setupResult.isFailure) { + flowOf(Result.failure(setupResult.exceptionOrNull() ?: NullPointerException())) } else { - throw e + updatesFlow + .timeout(options.timeout.toDuration(DurationUnit.MILLISECONDS)) + .catch { e -> + if (e is TimeoutCancellationException) { + emit( + Result.failure( + IONGLOCException.IONGLOCLocationRetrievalTimeoutException( + "Watch location request timed out. Try a higher timeout value.", + e + ) + ) + ) + clearWatch(watchId) + } else { + throw e + } + } } } + } - @VisibleForTesting - internal fun addWatchInternal( + /** + * Clears a watch by removing its location update request + * @param id the watch id + * @return true if watch was cleared, false if watch was not found + */ + 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, or failure otherwise. + */ + private fun watchSetupPreconditionsFlow( activity: Activity, - options: IONGLOCLocationOptions, - watchId: String - ): Flow>> = callbackFlow { + options: IONGLOCLocationOptions + ): Flow> = flow { try { - fun onNewLocations(locations: List) { - if (checkWatchInBlackList(watchId)) { - return - } - val locationResultList = locations.map { currentLocation -> - currentLocation.toOSLocationResult() - } - trySend(Result.success(locationResultList)) - } - val checkResult: Result = checkLocationPreconditions(activity, options, isSingleLocationRequest = false) if (checkResult.shouldNotProceed(options)) { - trySend( - Result.failure(checkResult.exceptionOrNull() ?: NullPointerException()) - ) + emit(Result.failure(checkResult.exceptionOrNull() ?: NullPointerException())) } else { - requestLocationUpdates( - watchId, - options, - useFallback = checkResult.isFailure && options.enableLocationManagerFallback - ) { onNewLocations(it) } + emit(Result.success(Unit)) } } catch (exception: Exception) { - Log.d(LOG_TAG, "Error requesting location updates: ${exception.message}") - trySend(Result.failure(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. + * Internal visibility to be accessible by tests. + * @param options location request options to use + * @param watchId a unique id identifying the watch + * @return Flow in which location updates will be emitted + */ + @VisibleForTesting + internal fun watchLocationUpdatesFlow( + options: IONGLOCLocationOptions, + 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 = options.enableLocationManagerFallback + ) { onNewLocations(it) } + } catch (e: Exception) { + trySend(Result.failure(e)) } awaitClose { @@ -199,13 +248,6 @@ class IONGLOCController internal constructor( } } - /** - * Clears a watch by removing its location update request - * @param id the watch id - * @return true if watch was cleared, false if watch was not found - */ - fun clearWatch(id: String): Boolean = clearWatch(id, addToBlackList = true) - /** * 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/test/java/io/ionic/libs/iongeolocationlib/controller/IONGLOCControllerTest.kt b/src/test/java/io/ionic/libs/iongeolocationlib/controller/IONGLOCControllerTest.kt index 2972e4d..3ee6168 100644 --- a/src/test/java/io/ionic/libs/iongeolocationlib/controller/IONGLOCControllerTest.kt +++ b/src/test/java/io/ionic/libs/iongeolocationlib/controller/IONGLOCControllerTest.kt @@ -281,8 +281,7 @@ class IONGLOCControllerTest { runTest { givenPlayServicesNotAvailableWithResolvableError() - // skip timeout in tests by calling internal method - sut.addWatchInternal(mockk(), locationOptions, "1").test { + sut.addWatch(mockk(), locationOptions, "1").test { val result = awaitItem() assertTrue(result.isFailure) @@ -290,7 +289,7 @@ class IONGLOCControllerTest { assertTrue(exception is IONGLOCException.IONGLOCGoogleServicesException) assertTrue((exception as IONGLOCException.IONGLOCGoogleServicesException).resolvable) } - expectNoEvents() + awaitComplete() } } @@ -324,7 +323,7 @@ class IONGLOCControllerTest { assertTrue(result.isFailure) assertTrue(result.exceptionOrNull() is IONGLOCException.IONGLOCRequestDeniedException) - expectNoEvents() + awaitComplete() } } @@ -335,8 +334,7 @@ class IONGLOCControllerTest { val error = RuntimeException() coEvery { locationSettingsTask.await() } throws error - // skip timeout in tests by calling internal method - sut.addWatchInternal(mockk(), locationOptions, "1").test { + sut.addWatch(mockk(), locationOptions, "1").test { testScheduler.advanceTimeBy(DELAY) val result = awaitItem() @@ -348,7 +346,7 @@ class IONGLOCControllerTest { (exception as IONGLOCException.IONGLOCSettingsException).cause ) } - expectNoEvents() + awaitComplete() } } @@ -580,9 +578,9 @@ class IONGLOCControllerTest { every { time } returns currentTime } - // skip timeout in tests by calling internal method - sut.addWatchInternal(mockk(), locationOptionsWithFallback, "1").test { - advanceUntilIdle() // to wait until locationListenerCompat is instantiated + // call internal method to skip timeout + sut.watchLocationUpdatesFlow(locationOptionsWithFallback, "1").test { + advanceUntilIdle() val result = awaitItem() assertTrue(result.isSuccess) From d5b3fe7d2e1965ef5162bf93e644e8f31ef29afb Mon Sep 17 00:00:00 2001 From: OS-pedrogustavobilro Date: Fri, 31 Oct 2025 12:11:03 +0000 Subject: [PATCH 3/9] fix: Only trigger watch timeout before first location update References: https://outsystemsrd.atlassian.net/browse/RMET-4688 --- .../controller/IONGLOCController.kt | 28 ++--------------- .../controller/helper/IONGLOCExtensions.kt | 31 ++++++++++++++++++- .../controller/IONGLOCControllerTest.kt | 6 ++-- 3 files changed, 35 insertions(+), 30 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 cfbc057..e69b495 100644 --- a/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCController.kt +++ b/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCController.kt @@ -16,6 +16,7 @@ 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 @@ -25,22 +26,17 @@ import io.ionic.libs.iongeolocationlib.model.internal.LocationSettingsResult import kotlinx.coroutines.CancellationException import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.TimeoutCancellationException 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.catch import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapConcat import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.timeout import kotlinx.coroutines.isActive import org.jetbrains.annotations.VisibleForTesting -import kotlin.time.DurationUnit -import kotlin.time.toDuration /** * Entry point in IONGeolocationLib-Android @@ -159,23 +155,7 @@ class IONGLOCController internal constructor( if (setupResult.isFailure) { flowOf(Result.failure(setupResult.exceptionOrNull() ?: NullPointerException())) } else { - updatesFlow - .timeout(options.timeout.toDuration(DurationUnit.MILLISECONDS)) - .catch { e -> - if (e is TimeoutCancellationException) { - emit( - Result.failure( - IONGLOCException.IONGLOCLocationRetrievalTimeoutException( - "Watch location request timed out. Try a higher timeout value.", - e - ) - ) - ) - clearWatch(watchId) - } else { - throw e - } - } + updatesFlow.emitOrTimeoutBeforeFirstEmission(timeoutMillis = options.timeout) } } } @@ -217,13 +197,11 @@ class IONGLOCController internal constructor( /** * Create a flow where location updates are emitted for a watch. - * Internal visibility to be accessible by tests. * @param options location request options to use * @param watchId a unique id identifying the watch * @return Flow in which location updates will be emitted */ - @VisibleForTesting - internal fun watchLocationUpdatesFlow( + private fun watchLocationUpdatesFlow( options: IONGLOCLocationOptions, watchId: String ): Flow>> = callbackFlow { 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..fc2ca09 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.TimeoutCancellationException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.withTimeout /** * @return true if there's any active network capability that could be used to improve location, false otherwise. @@ -59,4 +64,28 @@ internal fun Location.toOSLocationResult(): IONGLOCLocationResult = IONGLOCLocat heading = this.bearing, speed = this.speed, timestamp = this.time -) \ No newline at end of file +) + +/** + * + */ +fun Flow>.emitOrTimeoutBeforeFirstEmission(timeoutMillis: Long): Flow> = + flow { + // Wait for the first emission with timeout + val firstValue = try { + withTimeout(timeoutMillis) { first() } + } catch (e: TimeoutCancellationException) { + emit( + Result.failure( + IONGLOCException.IONGLOCLocationRetrievalTimeoutException( + "Location request timed out before first emission", + e + ) + ) + ) + return@flow + } + + emit(firstValue) + collect { if (it != firstValue) emit(it) } + } \ No newline at end of file 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 3ee6168..989c5ae 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 @@ -578,9 +577,8 @@ class IONGLOCControllerTest { every { time } returns currentTime } - // call internal method to skip timeout - sut.watchLocationUpdatesFlow(locationOptionsWithFallback, "1").test { - advanceUntilIdle() + sut.addWatch(mockk(), locationOptionsWithFallback, "1").test { + testScheduler.advanceUntilIdle() val result = awaitItem() assertTrue(result.isSuccess) From 0a12b11dca60ca0fbdf2cbb0deba44a428b6356e Mon Sep 17 00:00:00 2001 From: OS-pedrogustavobilro Date: Fri, 31 Oct 2025 12:27:02 +0000 Subject: [PATCH 4/9] refactor: Clear watch behavior on timeout References: https://outsystemsrd.atlassian.net/browse/RMET-4688 --- .../controller/IONGLOCController.kt | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 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 e69b495..de1a453 100644 --- a/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCController.kt +++ b/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCController.kt @@ -35,8 +35,8 @@ 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 -import org.jetbrains.annotations.VisibleForTesting /** * Entry point in IONGeolocationLib-Android @@ -155,7 +155,13 @@ class IONGLOCController internal constructor( if (setupResult.isFailure) { flowOf(Result.failure(setupResult.exceptionOrNull() ?: NullPointerException())) } else { - updatesFlow.emitOrTimeoutBeforeFirstEmission(timeoutMillis = options.timeout) + updatesFlow + .emitOrTimeoutBeforeFirstEmission(timeoutMillis = options.timeout) + .onEach { emission -> + if (emission.exceptionOrNull() is IONGLOCException.IONGLOCLocationRetrievalTimeoutException) { + watchIdsBlacklist.add(watchId) + } + } } } } @@ -222,7 +228,7 @@ class IONGLOCController internal constructor( } awaitClose { - clearWatch(watchId) + Log.d(LOG_TAG, "channel closed") } } From 197879d79330eb8f058fd34c223dc44b366eb4c2 Mon Sep 17 00:00:00 2001 From: OS-pedrogustavobilro Date: Fri, 31 Oct 2025 12:43:17 +0000 Subject: [PATCH 5/9] chore: Prepare to release version 2.1.0 References: https://outsystemsrd.atlassian.net/browse/RMET-4688 --- CHANGELOG.md | 6 ++++++ README.md | 2 +- pom.xml | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) 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..8bdf37e 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") } ``` 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 From 0e620430da4b767b1dda825322d26c7b1890bf67 Mon Sep 17 00:00:00 2001 From: OS-pedrogustavobilro Date: Fri, 31 Oct 2025 12:47:55 +0000 Subject: [PATCH 6/9] docs: Add information on new feature to README References: https://outsystemsrd.atlassian.net/browse/RMET-4688 --- README.md | 5 +++++ .../iongeolocationlib/controller/helper/IONGLOCExtensions.kt | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8bdf37e..978541c 100644 --- a/README.md +++ b/README.md @@ -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/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/helper/IONGLOCExtensions.kt b/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/helper/IONGLOCExtensions.kt index fc2ca09..a481ba3 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 @@ -67,7 +67,7 @@ internal fun Location.toOSLocationResult(): IONGLOCLocationResult = IONGLOCLocat ) /** - * + * 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> = flow { From fcb0d211c19be2eb24d2126479aacd89a0f943b6 Mon Sep 17 00:00:00 2001 From: OS-pedrogustavobilro Date: Fri, 31 Oct 2025 13:10:31 +0000 Subject: [PATCH 7/9] fix: Logic to use fallback in watchPosition References: https://outsystemsrd.atlassian.net/browse/RMET-4688 --- .../controller/IONGLOCController.kt | 22 +++++++++++-------- .../controller/IONGLOCControllerTest.kt | 17 ++++++++++++++ 2 files changed, 30 insertions(+), 9 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 de1a453..d056d83 100644 --- a/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCController.kt +++ b/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCController.kt @@ -147,15 +147,16 @@ class IONGLOCController internal constructor( ): Flow>> { val setupFlow = watchSetupPreconditionsFlow(activity, options) - - val updatesFlow = watchLocationUpdatesFlow(options, watchId) - // Concatenate flows: only proceed with watch if setup is successful return setupFlow.flatMapConcat { setupResult -> if (setupResult.isFailure) { flowOf(Result.failure(setupResult.exceptionOrNull() ?: NullPointerException())) } else { - updatesFlow + watchLocationUpdatesFlow( + options, + useFallback = setupResult.getOrNull() ?: false, + watchId + ) .emitOrTimeoutBeforeFirstEmission(timeoutMillis = options.timeout) .onEach { emission -> if (emission.exceptionOrNull() is IONGLOCException.IONGLOCLocationRetrievalTimeoutException) { @@ -177,19 +178,20 @@ class IONGLOCController internal constructor( * 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, or failure otherwise. + * @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 { + ): Flow> = flow { try { val checkResult: Result = checkLocationPreconditions(activity, options, isSingleLocationRequest = false) if (checkResult.shouldNotProceed(options)) { emit(Result.failure(checkResult.exceptionOrNull() ?: NullPointerException())) } else { - emit(Result.success(Unit)) + 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}") @@ -204,12 +206,14 @@ class IONGLOCController internal constructor( /** * 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, - watchId: String + useFallback: Boolean, + watchId: String, ): Flow>> = callbackFlow { fun onNewLocations(locations: List) { if (checkWatchInBlackList(watchId)) return @@ -221,7 +225,7 @@ class IONGLOCController internal constructor( requestLocationUpdates( watchId, options, - useFallback = options.enableLocationManagerFallback + useFallback = useFallback ) { onNewLocations(it) } } catch (e: Exception) { trySend(Result.failure(e)) 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 989c5ae..a3a2373 100644 --- a/src/test/java/io/ionic/libs/iongeolocationlib/controller/IONGLOCControllerTest.kt +++ b/src/test/java/io/ionic/libs/iongeolocationlib/controller/IONGLOCControllerTest.kt @@ -606,6 +606,23 @@ class IONGLOCControllerTest { } } + @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 { From a05b11716e6e2f5b82eada3cfffc37d653a4095e Mon Sep 17 00:00:00 2001 From: OS-pedrogustavobilro Date: Fri, 31 Oct 2025 13:17:46 +0000 Subject: [PATCH 8/9] chore: Natively clear watch on timeout References: https://outsystemsrd.atlassian.net/browse/RMET-4688 --- .../libs/iongeolocationlib/controller/IONGLOCController.kt | 2 +- 1 file changed, 1 insertion(+), 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 d056d83..269399b 100644 --- a/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCController.kt +++ b/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCController.kt @@ -160,7 +160,7 @@ class IONGLOCController internal constructor( .emitOrTimeoutBeforeFirstEmission(timeoutMillis = options.timeout) .onEach { emission -> if (emission.exceptionOrNull() is IONGLOCException.IONGLOCLocationRetrievalTimeoutException) { - watchIdsBlacklist.add(watchId) + clearWatch(watchId) } } } From fed5bfec50b792becdfe5dccb4615583ef2baf26 Mon Sep 17 00:00:00 2001 From: OS-pedrogustavobilro Date: Wed, 5 Nov 2025 11:26:59 +0000 Subject: [PATCH 9/9] fix: duplicate flow creation causing two watches References: https://outsystemsrd.atlassian.net/browse/RMET-4688 --- .../controller/helper/IONGLOCExtensions.kt | 39 +++++++++++-------- 1 file changed, 23 insertions(+), 16 deletions(-) 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 a481ba3..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,11 +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.TimeoutCancellationException +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.withTimeout +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. @@ -70,22 +70,29 @@ internal fun Location.toOSLocationResult(): IONGLOCLocationResult = IONGLOCLocat * 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> = - flow { - // Wait for the first emission with timeout - val firstValue = try { - withTimeout(timeoutMillis) { first() } - } catch (e: TimeoutCancellationException) { - emit( + 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", - e + "Location request timed out before first emission" ) ) ) - return@flow + job.cancel() } - - emit(firstValue) - collect { if (it != firstValue) emit(it) } } \ No newline at end of file