Skip to content
Merged
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.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
Expand Down
7 changes: 6 additions & 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.0.0")
implementation("io.ionic.libs:iongeolocation-android:2.1.0")
}
```

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@
<modelVersion>4.0.0</modelVersion>
<groupId>io.ionic.libs</groupId>
<artifactId>iongeolocation-android</artifactId>
<version>2.0.0</version>
<version>2.1.0</version>
</project>
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Result<List<IONGLOCLocationResult>>> = callbackFlow {
try {
fun onNewLocations(locations: List<Location>) {
if (checkWatchInBlackList(watchId)) {
return
}
val locationResultList = locations.map { currentLocation ->
currentLocation.toOSLocationResult()
}
trySend(Result.success(locationResultList))
}
): Flow<Result<List<IONGLOCLocationResult>>> {

val checkResult: Result<Unit> =
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)
}
}

Expand All @@ -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<Result<Boolean>> = flow {
try {
val checkResult: Result<Unit> =
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<Result<List<IONGLOCLocationResult>>> = callbackFlow {
fun onNewLocations(locations: List<Location>) {
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -59,4 +64,35 @@ internal fun Location.toOSLocationResult(): IONGLOCLocationResult = IONGLOCLocat
heading = this.bearing,
speed = this.speed,
timestamp = this.time
)
)

/**
* Flow extension to either emit its values, or emit a timeout error if [timeoutMillis] is reached before any emission
*/
fun <T> Flow<Result<T>>.emitOrTimeoutBeforeFirstEmission(timeoutMillis: Long): Flow<Result<T>> =
channelFlow {
var firstValue: Result<T>? = 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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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`.
*/
Expand All @@ -29,5 +30,6 @@ data class IONGLOCLocationOptions(
val maximumAge: Long,
val enableHighAccuracy: Boolean,
val enableLocationManagerFallback: Boolean,
val interval: Long = timeout,
val minUpdateInterval: Long? = null,
)
Loading