Skip to content

Commit e820114

Browse files
feat!: Fallback for no network or Play Services (#7)
* feat!: Allow using LocationManager fallback This fallback is used if IONGLOCLocationOptions#useLocationManagerFallback is true, and when there is an error in checking location settings / google play services. BREAKING CHANGE: The constructor and some methods of `IONGLOCController` have changed signatures. Updating the library will require changes to fix compilation errors. * refactor: Rename new fallback attribute References: https://outsystemsrd.atlassian.net/browse/RMET-2991 * refactor: Extract code to separate methods * fix: Improve `getCurrentLocation` fallback and fix fallback condition References: https://outsystemsrd.atlassian.net/browse/RMET-2991 * chore: update Gradle, AGP, and Android SDK * fix: Fallback Quality based on network connectivity * fix: Use maximumAge in addWatch fallback * fix: Improve use of providers in fallback References: https://outsystemsrd.atlassian.net/browse/RMET-2991 * refactor: Extract methods and classes to separate files References: https://outsystemsrd.atlassian.net/browse/RMET-2991 * chore: Return specific error on no network+location * docs: Document the IONGLOCLocationOptions properties * chore: Prepare to release 2.0.0 1.0.0->2.0.0 because it includes breaking changes References: https://outsystemsrd.atlassian.net/browse/RMET-2991 * chore: remove outdate doc References: https://outsystemsrd.atlassian.net/browse/RMET-2991 * chore: remove irrelevant portion of PR template References: https://outsystemsrd.atlassian.net/browse/RMET-2991 * refactor: minor changes from PR comments References: https://outsystemsrd.atlassian.net/browse/RMET-2991
1 parent 60228ff commit e820114

File tree

17 files changed

+816
-130
lines changed

17 files changed

+816
-130
lines changed

CHANGELOG.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,14 @@ 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-
## [Unreleased]
7+
## [2.0.0]
8+
9+
### 2025-09-30
10+
11+
- Feature: Allow using a fallback if Google Play Services fails.
12+
13+
BREAKING CHANGE: The constructor for the controller and some of its methods have changed signature.
14+
You will need to change how your application calls the library if you update to this version.
815

916
### 2025-06-26
1017

README.md

Lines changed: 5 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:1.0.0")
34+
implementation("io.ionic.libs:iongeolocation-android:2.0.0")
3535
}
3636
```
3737

@@ -96,6 +96,10 @@ Common issues and solutions:
9696
- Ensure clear sky view
9797
- Wait for better GPS signal
9898

99+
3. Error received when in airplane mode
100+
- Try setting `IONGLOCLocationOptions.enableLocationManagerFallback` to true - available since version 2.0.0
101+
- 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).
102+
99103
## Contributing
100104

101105
1. Fork the repository

build.gradle

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ buildscript {
1212
if (System.getenv("SHOULD_PUBLISH") == "true") {
1313
classpath("io.github.gradle-nexus:publish-plugin:1.1.0")
1414
}
15-
classpath 'com.android.tools.build:gradle:8.2.2'
15+
classpath 'com.android.tools.build:gradle:8.12.3'
1616
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
1717
classpath "org.jacoco:org.jacoco.core:$jacocoVersion"
1818
}
@@ -41,11 +41,11 @@ apply plugin: "jacoco"
4141

4242
android {
4343
namespace "io.ionic.libs.iongeolocationlib"
44-
compileSdk 35
44+
compileSdk 36
4545

4646
defaultConfig {
4747
minSdk 23
48-
targetSdk 35
48+
targetSdk 36
4949
versionCode 1
5050
versionName "1.0"
5151

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
#Fri Apr 08 08:58:08 WEST 2022
22
distributionBase=GRADLE_USER_HOME
3-
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip
3+
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
44
distributionPath=wrapper/dists
55
zipStorePath=wrapper/dists
66
zipStoreBase=GRADLE_USER_HOME

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>1.0.0</version>
9+
<version>2.0.0</version>
1010
</project>

pull_request_template.md

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,6 @@
1212
- [ ] Refactor (cosmetic changes)
1313
- [ ] Breaking change (change that would cause existing functionality to not work as expected)
1414

15-
## Platforms affected
16-
- [ ] Android
17-
- [ ] iOS
18-
- [ ] JavaScript
19-
2015
## Tests
2116
<!--- Describe how you tested your changes in detail -->
2217
<!--- Include details of your test environment if relevant -->

src/main/AndroidManifest.xml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
11
<?xml version="1.0" encoding="utf-8"?>
2-
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
2+
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
3+
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
4+
</manifest>

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

Lines changed: 141 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,24 @@ import android.app.Activity
44
import android.content.Context
55
import android.location.Location
66
import android.location.LocationManager
7-
import android.os.Build
7+
import android.net.ConnectivityManager
88
import android.util.Log
99
import androidx.activity.result.ActivityResultLauncher
1010
import androidx.activity.result.IntentSenderRequest
11+
import androidx.core.location.LocationListenerCompat
1112
import androidx.core.location.LocationManagerCompat
1213
import com.google.android.gms.location.FusedLocationProviderClient
1314
import com.google.android.gms.location.LocationCallback
1415
import com.google.android.gms.location.LocationResult
16+
import com.google.android.gms.location.LocationServices
17+
import io.ionic.libs.iongeolocationlib.controller.helper.IONGLOCFallbackHelper
18+
import io.ionic.libs.iongeolocationlib.controller.helper.IONGLOCGoogleServicesHelper
19+
import io.ionic.libs.iongeolocationlib.controller.helper.toOSLocationResult
1520
import io.ionic.libs.iongeolocationlib.model.IONGLOCException
1621
import io.ionic.libs.iongeolocationlib.model.IONGLOCLocationOptions
1722
import io.ionic.libs.iongeolocationlib.model.IONGLOCLocationResult
23+
import io.ionic.libs.iongeolocationlib.model.internal.LocationHandler
24+
import io.ionic.libs.iongeolocationlib.model.internal.LocationSettingsResult
1825
import kotlinx.coroutines.channels.awaitClose
1926
import kotlinx.coroutines.flow.Flow
2027
import kotlinx.coroutines.flow.MutableSharedFlow
@@ -25,16 +32,34 @@ import kotlinx.coroutines.flow.first
2532
* Entry point in IONGeolocationLib-Android
2633
*
2734
*/
28-
class IONGLOCController(
35+
class IONGLOCController internal constructor(
2936
fusedLocationClient: FusedLocationProviderClient,
37+
private val locationManager: LocationManager,
38+
connectivityManager: ConnectivityManager,
3039
activityLauncher: ActivityResultLauncher<IntentSenderRequest>,
31-
private val helper: IONGLOCServiceHelper = IONGLOCServiceHelper(
40+
private val googleServicesHelper: IONGLOCGoogleServicesHelper = IONGLOCGoogleServicesHelper(
41+
locationManager,
42+
connectivityManager,
3243
fusedLocationClient,
3344
activityLauncher
45+
),
46+
private val fallbackHelper: IONGLOCFallbackHelper = IONGLOCFallbackHelper(
47+
locationManager, connectivityManager
3448
)
3549
) {
50+
51+
constructor(
52+
context: Context,
53+
activityLauncher: ActivityResultLauncher<IntentSenderRequest>
54+
) : this(
55+
fusedLocationClient = LocationServices.getFusedLocationProviderClient(context),
56+
locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager,
57+
connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager,
58+
activityLauncher = activityLauncher
59+
)
60+
3661
private lateinit var resolveLocationSettingsResultFlow: MutableSharedFlow<Result<Unit>>
37-
private val locationCallbacks: MutableMap<String, LocationCallback> = mutableMapOf()
62+
private val watchLocationHandlers: MutableMap<String, LocationHandler> = mutableMapOf()
3863
private val watchIdsBlacklist: MutableList<String> = mutableListOf()
3964

4065
/**
@@ -48,20 +73,25 @@ class IONGLOCController(
4873
activity: Activity,
4974
options: IONGLOCLocationOptions
5075
): Result<IONGLOCLocationResult> {
51-
try {
76+
return try {
5277
val checkResult: Result<Unit> =
5378
checkLocationPreconditions(activity, options, isSingleLocationRequest = true)
54-
return if (checkResult.isFailure) {
79+
if (checkResult.shouldNotProceed(options)) {
5580
Result.failure(
5681
checkResult.exceptionOrNull() ?: NullPointerException()
5782
)
5883
} else {
59-
val location = helper.getCurrentLocation(options)
60-
return Result.success(location.toOSLocationResult())
84+
val location: Location =
85+
if (checkResult.isFailure && options.enableLocationManagerFallback) {
86+
fallbackHelper.getCurrentLocation(options)
87+
} else {
88+
googleServicesHelper.getCurrentLocation(options)
89+
}
90+
Result.success(location.toOSLocationResult())
6191
}
6292
} catch (exception: Exception) {
6393
Log.d(LOG_TAG, "Error fetching location: ${exception.message}")
64-
return Result.failure(exception)
94+
Result.failure(exception)
6595
}
6696
}
6797

@@ -86,10 +116,10 @@ class IONGLOCController(
86116

87117
/**
88118
* Checks if location services are enabled
89-
* @param context Context to use when determining if location is enabled
119+
* @return true if location is enabled, false otherwise
90120
*/
91-
fun areLocationServicesEnabled(context: Context): Boolean {
92-
return LocationManagerCompat.isLocationEnabled(context.getSystemService(Context.LOCATION_SERVICE) as LocationManager)
121+
fun areLocationServicesEnabled(): Boolean {
122+
return LocationManagerCompat.isLocationEnabled(locationManager)
93123
}
94124

95125
/**
@@ -104,28 +134,29 @@ class IONGLOCController(
104134
options: IONGLOCLocationOptions,
105135
watchId: String
106136
): Flow<Result<List<IONGLOCLocationResult>>> = callbackFlow {
107-
108137
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+
109148
val checkResult: Result<Unit> =
110-
checkLocationPreconditions(activity, options, isSingleLocationRequest = true)
111-
if (checkResult.isFailure) {
149+
checkLocationPreconditions(activity, options, isSingleLocationRequest = false)
150+
if (checkResult.shouldNotProceed(options)) {
112151
trySend(
113152
Result.failure(checkResult.exceptionOrNull() ?: NullPointerException())
114153
)
115154
} else {
116-
locationCallbacks[watchId] = object : LocationCallback() {
117-
override fun onLocationResult(location: LocationResult) {
118-
if (checkWatchInBlackList(watchId)) {
119-
return
120-
}
121-
val locations = location.locations.map { currentLocation ->
122-
currentLocation.toOSLocationResult()
123-
}
124-
trySend(Result.success(locations))
125-
}
126-
}.also {
127-
helper.requestLocationUpdates(options, it)
128-
}
155+
requestLocationUpdates(
156+
watchId,
157+
options,
158+
useFallback = checkResult.isFailure && options.enableLocationManagerFallback
159+
) { onNewLocations(it) }
129160
}
130161
} catch (exception: Exception) {
131162
Log.d(LOG_TAG, "Error requesting location updates: ${exception.message}")
@@ -163,23 +194,59 @@ class IONGLOCController(
163194
)
164195
)
165196
}
166-
167-
val playServicesResult = helper.checkGooglePlayServicesAvailable(activity)
197+
// if meant to use fallback, then resolvable errors from Play Services Location don't need to be addressed
198+
val playServicesResult = googleServicesHelper.checkGooglePlayServicesAvailable(
199+
activity, shouldTryResolve = !options.enableLocationManagerFallback
200+
)
168201
if (playServicesResult.isFailure) {
169202
return Result.failure(playServicesResult.exceptionOrNull() ?: NullPointerException())
170203
}
171204

172205
resolveLocationSettingsResultFlow = MutableSharedFlow()
173-
val locationSettingsChecked = helper.checkLocationSettings(
206+
val locationSettingsResult = googleServicesHelper.checkLocationSettings(
174207
activity,
175-
options,
176-
interval = if (isSingleLocationRequest) 0 else options.timeout
208+
options.copy(timeout = if (isSingleLocationRequest) 0 else options.timeout),
209+
shouldTryResolve = !options.enableLocationManagerFallback
177210
)
178211

179-
return if (locationSettingsChecked) {
180-
Result.success(Unit)
212+
return locationSettingsResult.toKotlinResult()
213+
}
214+
215+
/**
216+
* Request location updates using the appropriate helper class
217+
* @param watchId a unique id to associate with the location update request (so that it may be cleared later)
218+
* @param options location request options to use
219+
* @param useFallback whether or not the fallback should be used
220+
* @param onNewLocations lambda to notify of new location requests
221+
*/
222+
private fun requestLocationUpdates(
223+
watchId: String,
224+
options: IONGLOCLocationOptions,
225+
useFallback: Boolean,
226+
onNewLocations: (List<Location>) -> Unit
227+
) {
228+
watchLocationHandlers[watchId] = if (!useFallback) {
229+
LocationHandler.Callback(object : LocationCallback() {
230+
override fun onLocationResult(location: LocationResult) {
231+
onNewLocations(location.locations)
232+
}
233+
}).also {
234+
googleServicesHelper.requestLocationUpdates(options, it.callback)
235+
}
181236
} else {
182-
resolveLocationSettingsResultFlow.first()
237+
LocationHandler.Listener(object : LocationListenerCompat {
238+
override fun onLocationChanged(location: Location) {
239+
onNewLocations(listOf(location))
240+
}
241+
242+
override fun onLocationChanged(locations: List<Location?>) {
243+
locations.filterNotNull().takeIf { it.isNotEmpty() }?.let {
244+
onNewLocations(it)
245+
}
246+
}
247+
}).also {
248+
fallbackHelper.requestLocationUpdates(options, it.listener)
249+
}
183250
}
184251
}
185252

@@ -190,17 +257,26 @@ class IONGLOCController(
190257
* @return true if watch was cleared, false if watch was not found
191258
*/
192259
private fun clearWatch(id: String, addToBlackList: Boolean): Boolean {
193-
val locationCallback = locationCallbacks.remove(key = id)
194-
return if (locationCallback != null) {
195-
helper.removeLocationUpdates(locationCallback)
196-
true
197-
} else {
198-
if (addToBlackList) {
199-
// It is possible that clearWatch is being called before requestLocationUpdates is triggered (e.g. very low timeout on JavaScript side.)
200-
// add to a blacklist in order to remove the location callback in the future
201-
watchIdsBlacklist.add(id)
260+
val watchHandler = watchLocationHandlers.remove(key = id)
261+
return when (watchHandler) {
262+
is LocationHandler.Callback -> {
263+
googleServicesHelper.removeLocationUpdates(watchHandler.callback)
264+
true
265+
}
266+
267+
is LocationHandler.Listener -> {
268+
fallbackHelper.removeLocationUpdates(watchHandler.listener)
269+
true
270+
}
271+
272+
else -> {
273+
if (addToBlackList) {
274+
// It is possible that clearWatch is being called before requestLocationUpdates is triggered (e.g. very low timeout on JavaScript side.)
275+
// add to a blacklist in order to remove the location callback in the future
276+
watchIdsBlacklist.add(id)
277+
}
278+
false
202279
}
203-
false
204280
}
205281
}
206282

@@ -223,19 +299,26 @@ class IONGLOCController(
223299
}
224300

225301
/**
226-
* Extension function to convert Location object into OSLocationResult object
227-
* @return OSLocationResult object
302+
* Extension function to convert the [LocationSettingsResult].
303+
* Depending on the result value, it may suspend to await a flow
304+
* @return a regular Kotlin [Result], which may be either Success or Error.
228305
*/
229-
private fun Location.toOSLocationResult(): IONGLOCLocationResult = IONGLOCLocationResult(
230-
latitude = this.latitude,
231-
longitude = this.longitude,
232-
altitude = this.altitude,
233-
accuracy = this.accuracy,
234-
altitudeAccuracy = if (IONGLOCBuildConfig.getAndroidSdkVersionCode() >= Build.VERSION_CODES.O) this.verticalAccuracyMeters else null,
235-
heading = this.bearing,
236-
speed = this.speed,
237-
timestamp = this.time
238-
)
306+
private suspend fun LocationSettingsResult.toKotlinResult(): Result<Unit> {
307+
return when (this) {
308+
LocationSettingsResult.Success -> Result.success(Unit)
309+
LocationSettingsResult.Resolving -> resolveLocationSettingsResultFlow.first()
310+
is LocationSettingsResult.ResolveSkipped -> Result.failure(resolvableError)
311+
is LocationSettingsResult.UnresolvableError -> Result.failure(error)
312+
}
313+
}
314+
315+
/**
316+
* @return true if the the settings result is such that the location request must fail
317+
* (even if enableLocationManagerFallback=true), or false otherwise
318+
*/
319+
private fun Result<Unit>.shouldNotProceed(options: IONGLOCLocationOptions): Boolean =
320+
isFailure && (!options.enableLocationManagerFallback ||
321+
exceptionOrNull() is IONGLOCException.IONGLOCLocationAndNetworkDisabledException)
239322

240323
companion object {
241324
private const val LOG_TAG = "IONGeolocationController"

0 commit comments

Comments
 (0)