Skip to content

Commit 6b95bba

Browse files
feat: Separate timeout from interval in location watch (#8)
* feat: Separate timeout from watchLocation interval References: https://outsystemsrd.atlassian.net/browse/RMET-4688 * 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 * fix: Only trigger watch timeout before first location update References: https://outsystemsrd.atlassian.net/browse/RMET-4688 * refactor: Clear watch behavior on timeout References: https://outsystemsrd.atlassian.net/browse/RMET-4688 * chore: Prepare to release version 2.1.0 References: https://outsystemsrd.atlassian.net/browse/RMET-4688 * docs: Add information on new feature to README References: https://outsystemsrd.atlassian.net/browse/RMET-4688 * fix: Logic to use fallback in watchPosition References: https://outsystemsrd.atlassian.net/browse/RMET-4688 * chore: Natively clear watch on timeout References: https://outsystemsrd.atlassian.net/browse/RMET-4688 * fix: duplicate flow creation causing two watches References: https://outsystemsrd.atlassian.net/browse/RMET-4688
1 parent e820114 commit 6b95bba

File tree

9 files changed

+215
-50
lines changed

9 files changed

+215
-50
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
44
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
55
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
## [2.1.0]
8+
9+
### 2025-10-31
10+
11+
- Feature: Add native timeout to `watchPosition` Add new `interval` to control location updates without `timeout` variable.
12+
713
## [2.0.0]
814

915
### 2025-09-30

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ In your app-level gradle file, import the `ion-android-geolocation` library like
3131

3232
```
3333
dependencies {
34-
implementation("io.ionic.libs:iongeolocation-android:2.0.0")
34+
implementation("io.ionic.libs:iongeolocation-android:2.1.0")
3535
}
3636
```
3737

@@ -100,6 +100,11 @@ Common issues and solutions:
100100
- Try setting `IONGLOCLocationOptions.enableLocationManagerFallback` to true - available since version 2.0.0
101101
- 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).
102102

103+
4. Timeout received in `watchPosition`
104+
- 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.
105+
- Increase the `IONGLOCLocationOptions.timeout` value, if your use case can wait for some time.
106+
- Increase `IONGLOCLocationOptions.maximumAge` to allow to retrieve an older location quickly for the first update.
107+
103108
## Contributing
104109

105110
1. Fork the repository

pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,5 @@
66
<modelVersion>4.0.0</modelVersion>
77
<groupId>io.ionic.libs</groupId>
88
<artifactId>iongeolocation-android</artifactId>
9-
<version>2.0.0</version>
9+
<version>2.1.0</version>
1010
</project>

src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCController.kt

Lines changed: 90 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,27 @@ import com.google.android.gms.location.LocationResult
1616
import com.google.android.gms.location.LocationServices
1717
import io.ionic.libs.iongeolocationlib.controller.helper.IONGLOCFallbackHelper
1818
import io.ionic.libs.iongeolocationlib.controller.helper.IONGLOCGoogleServicesHelper
19+
import io.ionic.libs.iongeolocationlib.controller.helper.emitOrTimeoutBeforeFirstEmission
1920
import io.ionic.libs.iongeolocationlib.controller.helper.toOSLocationResult
2021
import io.ionic.libs.iongeolocationlib.model.IONGLOCException
2122
import io.ionic.libs.iongeolocationlib.model.IONGLOCLocationOptions
2223
import io.ionic.libs.iongeolocationlib.model.IONGLOCLocationResult
2324
import io.ionic.libs.iongeolocationlib.model.internal.LocationHandler
2425
import io.ionic.libs.iongeolocationlib.model.internal.LocationSettingsResult
26+
import kotlinx.coroutines.CancellationException
27+
import kotlinx.coroutines.ExperimentalCoroutinesApi
28+
import kotlinx.coroutines.FlowPreview
2529
import kotlinx.coroutines.channels.awaitClose
30+
import kotlinx.coroutines.currentCoroutineContext
2631
import kotlinx.coroutines.flow.Flow
2732
import kotlinx.coroutines.flow.MutableSharedFlow
2833
import kotlinx.coroutines.flow.callbackFlow
2934
import kotlinx.coroutines.flow.first
35+
import kotlinx.coroutines.flow.flatMapConcat
36+
import kotlinx.coroutines.flow.flow
37+
import kotlinx.coroutines.flow.flowOf
38+
import kotlinx.coroutines.flow.onEach
39+
import kotlinx.coroutines.isActive
3040

3141
/**
3242
* Entry point in IONGeolocationLib-Android
@@ -127,44 +137,33 @@ class IONGLOCController internal constructor(
127137
* @param activity the Android activity from which the location request is being triggered
128138
* @param options location request options to use
129139
* @param watchId a unique id identifying the watch
130-
* @return Flow in which location updates will be emitted
140+
* @return Flow in which location updates will be emitted, or failure if something went wrong in retrieving updates
131141
*/
142+
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
132143
fun addWatch(
133144
activity: Activity,
134145
options: IONGLOCLocationOptions,
135146
watchId: String
136-
): Flow<Result<List<IONGLOCLocationResult>>> = callbackFlow {
137-
try {
138-
fun onNewLocations(locations: List<Location>) {
139-
if (checkWatchInBlackList(watchId)) {
140-
return
141-
}
142-
val locationResultList = locations.map { currentLocation ->
143-
currentLocation.toOSLocationResult()
144-
}
145-
trySend(Result.success(locationResultList))
146-
}
147+
): Flow<Result<List<IONGLOCLocationResult>>> {
147148

148-
val checkResult: Result<Unit> =
149-
checkLocationPreconditions(activity, options, isSingleLocationRequest = false)
150-
if (checkResult.shouldNotProceed(options)) {
151-
trySend(
152-
Result.failure(checkResult.exceptionOrNull() ?: NullPointerException())
153-
)
149+
val setupFlow = watchSetupPreconditionsFlow(activity, options)
150+
// Concatenate flows: only proceed with watch if setup is successful
151+
return setupFlow.flatMapConcat { setupResult ->
152+
if (setupResult.isFailure) {
153+
flowOf(Result.failure(setupResult.exceptionOrNull() ?: NullPointerException()))
154154
} else {
155-
requestLocationUpdates(
156-
watchId,
155+
watchLocationUpdatesFlow(
157156
options,
158-
useFallback = checkResult.isFailure && options.enableLocationManagerFallback
159-
) { onNewLocations(it) }
157+
useFallback = setupResult.getOrNull() ?: false,
158+
watchId
159+
)
160+
.emitOrTimeoutBeforeFirstEmission(timeoutMillis = options.timeout)
161+
.onEach { emission ->
162+
if (emission.exceptionOrNull() is IONGLOCException.IONGLOCLocationRetrievalTimeoutException) {
163+
clearWatch(watchId)
164+
}
165+
}
160166
}
161-
} catch (exception: Exception) {
162-
Log.d(LOG_TAG, "Error requesting location updates: ${exception.message}")
163-
trySend(Result.failure(exception))
164-
}
165-
166-
awaitClose {
167-
clearWatch(watchId)
168167
}
169168
}
170169

@@ -175,6 +174,68 @@ class IONGLOCController internal constructor(
175174
*/
176175
fun clearWatch(id: String): Boolean = clearWatch(id, addToBlackList = true)
177176

177+
/**
178+
* Create a flow for setup and checking preconditions for watch location
179+
* @param activity the Android activity from which the location request is being triggered
180+
* @param options location request options to use
181+
* @return Flow with success if pre-condition checks passed and boolean flag to decide whether or not fallback is required, or failure otherwise.
182+
*/
183+
private fun watchSetupPreconditionsFlow(
184+
activity: Activity,
185+
options: IONGLOCLocationOptions
186+
): Flow<Result<Boolean>> = flow {
187+
try {
188+
val checkResult: Result<Unit> =
189+
checkLocationPreconditions(activity, options, isSingleLocationRequest = false)
190+
if (checkResult.shouldNotProceed(options)) {
191+
emit(Result.failure(checkResult.exceptionOrNull() ?: NullPointerException()))
192+
} else {
193+
val useFallback = checkResult.isFailure && options.enableLocationManagerFallback
194+
emit(Result.success(useFallback))
195+
}
196+
} catch (exception: Exception) {
197+
Log.d(LOG_TAG, "Error getting pre-conditions for watch: ${exception.message}")
198+
if (currentCoroutineContext().isActive) {
199+
emit(Result.failure(exception))
200+
} else if (exception is CancellationException) {
201+
throw exception
202+
}
203+
}
204+
}
205+
206+
/**
207+
* Create a flow where location updates are emitted for a watch.
208+
* @param options location request options to use
209+
* @param useFallback whether or not the fallback should be used
210+
* @param watchId a unique id identifying the watch
211+
* @return Flow in which location updates will be emitted
212+
*/
213+
private fun watchLocationUpdatesFlow(
214+
options: IONGLOCLocationOptions,
215+
useFallback: Boolean,
216+
watchId: String,
217+
): Flow<Result<List<IONGLOCLocationResult>>> = callbackFlow {
218+
fun onNewLocations(locations: List<Location>) {
219+
if (checkWatchInBlackList(watchId)) return
220+
val locationResultList = locations.map { it.toOSLocationResult() }
221+
trySend(Result.success(locationResultList))
222+
}
223+
224+
try {
225+
requestLocationUpdates(
226+
watchId,
227+
options,
228+
useFallback = useFallback
229+
) { onNewLocations(it) }
230+
} catch (e: Exception) {
231+
trySend(Result.failure(e))
232+
}
233+
234+
awaitClose {
235+
Log.d(LOG_TAG, "channel closed")
236+
}
237+
}
238+
178239
/**
179240
* Checks if all preconditions for retrieving location are met
180241
* @param activity the Android activity from which the location request is being triggered

src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/helper/IONGLOCExtensions.kt

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ import android.os.Build
88
import androidx.core.location.LocationManagerCompat
99
import io.ionic.libs.iongeolocationlib.model.IONGLOCException
1010
import io.ionic.libs.iongeolocationlib.model.IONGLOCLocationResult
11+
import kotlinx.coroutines.delay
12+
import kotlinx.coroutines.flow.Flow
13+
import kotlinx.coroutines.flow.channelFlow
14+
import kotlinx.coroutines.launch
15+
import kotlinx.coroutines.withTimeoutOrNull
1116

1217
/**
1318
* @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
5964
heading = this.bearing,
6065
speed = this.speed,
6166
timestamp = this.time
62-
)
67+
)
68+
69+
/**
70+
* Flow extension to either emit its values, or emit a timeout error if [timeoutMillis] is reached before any emission
71+
*/
72+
fun <T> Flow<Result<T>>.emitOrTimeoutBeforeFirstEmission(timeoutMillis: Long): Flow<Result<T>> =
73+
channelFlow {
74+
var firstValue: Result<T>? = null
75+
76+
val job = launch {
77+
collect { value ->
78+
if (firstValue == null) firstValue = value
79+
send(value)
80+
}
81+
}
82+
83+
// Poll until first emission, or timeout
84+
withTimeoutOrNull(timeMillis = timeoutMillis) {
85+
while (firstValue == null) {
86+
delay(timeMillis = 10)
87+
}
88+
} ?: run {
89+
send(
90+
Result.failure(
91+
IONGLOCException.IONGLOCLocationRetrievalTimeoutException(
92+
"Location request timed out before first emission"
93+
)
94+
)
95+
)
96+
job.cancel()
97+
}
98+
}

src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/helper/IONGLOCFallbackHelper.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ internal class IONGLOCFallbackHelper(
9494
locationListener.onLocationChanged(validCacheLocation)
9595
}
9696

97-
val locationRequest = LocationRequestCompat.Builder(options.timeout).apply {
97+
val locationRequest = LocationRequestCompat.Builder(options.interval).apply {
9898
setQuality(getQualityToUse(options))
9999
if (options.minUpdateInterval != null) {
100100
setMinUpdateIntervalMillis(options.minUpdateInterval)

src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/helper/IONGLOCGoogleServicesHelper.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ internal class IONGLOCGoogleServicesHelper(
5151
): LocationSettingsResult {
5252
val request = LocationRequest.Builder(
5353
if (options.enableHighAccuracy) Priority.PRIORITY_HIGH_ACCURACY else Priority.PRIORITY_BALANCED_POWER_ACCURACY,
54-
options.timeout
54+
options.interval
5555
).build()
5656

5757
val builder = LocationSettingsRequest.Builder()
@@ -144,7 +144,7 @@ internal class IONGLOCGoogleServicesHelper(
144144
options: IONGLOCLocationOptions,
145145
locationCallback: LocationCallback
146146
) {
147-
val locationRequest = LocationRequest.Builder(options.timeout).apply {
147+
val locationRequest = LocationRequest.Builder(options.interval).apply {
148148
setMaxUpdateAgeMillis(options.maximumAge)
149149
setPriority(if (options.enableHighAccuracy) Priority.PRIORITY_HIGH_ACCURACY else Priority.PRIORITY_BALANCED_POWER_ACCURACY)
150150
if (options.minUpdateInterval != null) {

src/main/kotlin/io/ionic/libs/iongeolocationlib/model/IONGLOCLocationOptions.kt

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,8 @@ package io.ionic.libs.iongeolocationlib.model
33
/**
44
* Data class representing the options passed to getCurrentPosition and watchPosition
55
*
6-
* @property timeout Depending on the method:
7-
* 1. for `getCurrentPosition`, it's the maximum time in **milliseconds** to wait for a fresh
8-
* location fix before throwing a timeout exception.
9-
* 2. for `addWatch` the interval at which new location updates will be returned (if available)
6+
* @property timeout The maximum time in **milliseconds** to wait for a new location fix before
7+
* throwing a timeout exception.
108
* @property maximumAge Maximum acceptable age in **milliseconds** of a cached location to return.
119
* If the cached location is older than this value, then a fresh location will always be fetched.
1210
* @property enableHighAccuracy Whether or not the requested location should have high accuracy.
@@ -21,6 +19,9 @@ package io.ionic.libs.iongeolocationlib.model
2119
* This means that to receive location, you may need a higher timeout.
2220
* If the device's in airplane mode, only the GPS provider is used, which may only return a location
2321
* if there's movement (e.g. walking or driving), otherwise it may time out.
22+
* @property interval Default interval in **milliseconds** to receive location updates in `addWatch`.
23+
* By default equal to [timeout]. If you are experiencing location timeouts, try setting
24+
* [interval] to a value lower than [timeout].
2425
* @property minUpdateInterval Optional minimum interval in **milliseconds** between consecutive
2526
* location updates when using `addWatch`.
2627
*/
@@ -29,5 +30,6 @@ data class IONGLOCLocationOptions(
2930
val maximumAge: Long,
3031
val enableHighAccuracy: Boolean,
3132
val enableLocationManagerFallback: Boolean,
33+
val interval: Long = timeout,
3234
val minUpdateInterval: Long? = null,
3335
)

0 commit comments

Comments
 (0)