From ae406a34ab7476752e29f1de00d4e2e4557cae7a Mon Sep 17 00:00:00 2001 From: OS-pedrogustavobilro Date: Fri, 26 Sep 2025 18:12:44 +0100 Subject: [PATCH 01/15] 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. --- .../controller/IONGLOCController.kt | 125 +++++++++---- .../{ => helper}/IONGLOCBuildConfig.kt | 2 +- .../helper/IONGLOCFallbackHelper.kt | 93 ++++++++++ .../IONGLOCGoogleServicesHelper.kt} | 100 ++++++++--- .../model/IONGLOCLocationOptions.kt | 3 +- .../controller/IONGLOCControllerTest.kt | 169 ++++++++++++++++-- 6 files changed, 419 insertions(+), 73 deletions(-) rename src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/{ => helper}/IONGLOCBuildConfig.kt (74%) create mode 100644 src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/helper/IONGLOCFallbackHelper.kt rename src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/{IONGLOCServiceHelper.kt => helper/IONGLOCGoogleServicesHelper.kt} (63%) 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 627c105..af02aa3 100644 --- a/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCController.kt +++ b/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCController.kt @@ -8,10 +8,15 @@ import android.os.Build import android.util.Log import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.IntentSenderRequest +import androidx.core.location.LocationListenerCompat import androidx.core.location.LocationManagerCompat import com.google.android.gms.location.FusedLocationProviderClient import com.google.android.gms.location.LocationCallback import com.google.android.gms.location.LocationResult +import com.google.android.gms.location.LocationServices +import io.ionic.libs.iongeolocationlib.controller.helper.IONGLOCBuildConfig +import io.ionic.libs.iongeolocationlib.controller.helper.IONGLOCFallbackHelper +import io.ionic.libs.iongeolocationlib.controller.helper.IONGLOCGoogleServicesHelper import io.ionic.libs.iongeolocationlib.model.IONGLOCException import io.ionic.libs.iongeolocationlib.model.IONGLOCLocationOptions import io.ionic.libs.iongeolocationlib.model.IONGLOCLocationResult @@ -25,16 +30,28 @@ import kotlinx.coroutines.flow.first * Entry point in IONGeolocationLib-Android * */ -class IONGLOCController( +class IONGLOCController internal constructor( fusedLocationClient: FusedLocationProviderClient, + private val locationManager: LocationManager, activityLauncher: ActivityResultLauncher, - private val helper: IONGLOCServiceHelper = IONGLOCServiceHelper( + private val googleServicesHelper: IONGLOCGoogleServicesHelper = IONGLOCGoogleServicesHelper( fusedLocationClient, activityLauncher - ) + ), + private val fallbackHelper: IONGLOCFallbackHelper = IONGLOCFallbackHelper(locationManager) ) { + + constructor( + context: Context, + activityLauncher: ActivityResultLauncher + ) : this( + fusedLocationClient = LocationServices.getFusedLocationProviderClient(context), + locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager, + activityLauncher = activityLauncher + ) + private lateinit var resolveLocationSettingsResultFlow: MutableSharedFlow> - private val locationCallbacks: MutableMap = mutableMapOf() + private val watchLocationHandlers: MutableMap = mutableMapOf() private val watchIdsBlacklist: MutableList = mutableListOf() /** @@ -51,12 +68,16 @@ class IONGLOCController( try { val checkResult: Result = checkLocationPreconditions(activity, options, isSingleLocationRequest = true) - return if (checkResult.isFailure) { + return if (checkResult.isFailure && !options.useLocationManagerFallback) { Result.failure( checkResult.exceptionOrNull() ?: NullPointerException() ) } else { - val location = helper.getCurrentLocation(options) + val location: Location = if (!options.useLocationManagerFallback) { + googleServicesHelper.getCurrentLocation(options) + } else { + fallbackHelper.getCurrentLocation() + } return Result.success(location.toOSLocationResult()) } } catch (exception: Exception) { @@ -86,10 +107,10 @@ class IONGLOCController( /** * Checks if location services are enabled - * @param context Context to use when determining if location is enabled + * @return true if location is enabled, false otherwise */ - fun areLocationServicesEnabled(context: Context): Boolean { - return LocationManagerCompat.isLocationEnabled(context.getSystemService(Context.LOCATION_SERVICE) as LocationManager) + fun areLocationServicesEnabled(): Boolean { + return LocationManagerCompat.isLocationEnabled(locationManager) } /** @@ -104,27 +125,45 @@ class IONGLOCController( options: IONGLOCLocationOptions, watchId: String ): Flow>> = callbackFlow { - try { + fun onNewLocations(locations: List) { + if (checkWatchInBlackList(watchId)) { + return + } + val locations = locations.map { currentLocation -> + currentLocation.toOSLocationResult() + } + trySend(Result.success(locations)) + } val checkResult: Result = checkLocationPreconditions(activity, options, isSingleLocationRequest = true) - if (checkResult.isFailure) { + if (checkResult.isFailure && !options.useLocationManagerFallback) { trySend( Result.failure(checkResult.exceptionOrNull() ?: NullPointerException()) ) } else { - locationCallbacks[watchId] = object : LocationCallback() { - override fun onLocationResult(location: LocationResult) { - if (checkWatchInBlackList(watchId)) { - return + watchLocationHandlers[watchId] = if (!options.useLocationManagerFallback) { + LocationHandler.Callback(object : LocationCallback() { + override fun onLocationResult(location: LocationResult) { + onNewLocations(location.locations) + } + }).also { + googleServicesHelper.requestLocationUpdates(options, it.callback) + } + } else { + LocationHandler.Listener(object : LocationListenerCompat { + override fun onLocationChanged(location: Location) { + onNewLocations(listOf(location)) } - val locations = location.locations.map { currentLocation -> - currentLocation.toOSLocationResult() + + override fun onLocationChanged(locations: List) { + locations.filterNotNull().takeIf { it.isNotEmpty() }?.let { + onNewLocations(it) + } } - trySend(Result.success(locations)) + }).also { + fallbackHelper.requestLocationUpdates(options, it.listener) } - }.also { - helper.requestLocationUpdates(options, it) } } } catch (exception: Exception) { @@ -163,23 +202,34 @@ class IONGLOCController( ) ) } - - val playServicesResult = helper.checkGooglePlayServicesAvailable(activity) - if (playServicesResult.isFailure) { + // if meant to use fallback, then resolvable errors from Play Services Location don't need to be addressed + val playServicesResult = googleServicesHelper.checkGooglePlayServicesAvailable( + activity, shouldTryResolve = !options.useLocationManagerFallback + ) + if (playServicesResult.isFailure && !options.useLocationManagerFallback) { return Result.failure(playServicesResult.exceptionOrNull() ?: NullPointerException()) } resolveLocationSettingsResultFlow = MutableSharedFlow() - val locationSettingsChecked = helper.checkLocationSettings( + val locationSettingsResult = googleServicesHelper.checkLocationSettings( activity, - options, - interval = if (isSingleLocationRequest) 0 else options.timeout + locationManager, + options.copy(timeout = if (isSingleLocationRequest) 0 else options.timeout), + shouldTryResolve = !options.useLocationManagerFallback ) - return if (locationSettingsChecked) { - Result.success(Unit) - } else { - resolveLocationSettingsResultFlow.first() + return when (locationSettingsResult) { + IONGLOCGoogleServicesHelper.LocationSettingsResult.Success -> + Result.success(Unit) + + IONGLOCGoogleServicesHelper.LocationSettingsResult.Resolving -> + resolveLocationSettingsResultFlow.first() + + is IONGLOCGoogleServicesHelper.LocationSettingsResult.ResolveSkipped -> + Result.failure(locationSettingsResult.resolvableError) + + is IONGLOCGoogleServicesHelper.LocationSettingsResult.UnresolvableError -> + Result.failure(locationSettingsResult.error) } } @@ -190,9 +240,13 @@ class IONGLOCController( * @return true if watch was cleared, false if watch was not found */ private fun clearWatch(id: String, addToBlackList: Boolean): Boolean { - val locationCallback = locationCallbacks.remove(key = id) - return if (locationCallback != null) { - helper.removeLocationUpdates(locationCallback) + val watchHandler = watchLocationHandlers.remove(key = id) + return if (watchHandler != null) { + if (watchHandler is LocationHandler.Callback) { + googleServicesHelper.removeLocationUpdates(watchHandler.callback) + } else if (watchHandler is LocationHandler.Listener) { + fallbackHelper.removeLocationUpdates(watchHandler.listener) + } true } else { if (addToBlackList) { @@ -237,6 +291,11 @@ class IONGLOCController( timestamp = this.time ) + sealed interface LocationHandler { + data class Callback(val callback: LocationCallback) : LocationHandler + data class Listener(val listener: LocationListenerCompat) : LocationHandler + } + companion object { private const val LOG_TAG = "IONGeolocationController" } diff --git a/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCBuildConfig.kt b/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/helper/IONGLOCBuildConfig.kt similarity index 74% rename from src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCBuildConfig.kt rename to src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/helper/IONGLOCBuildConfig.kt index 04df312..9adb8e3 100644 --- a/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCBuildConfig.kt +++ b/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/helper/IONGLOCBuildConfig.kt @@ -1,4 +1,4 @@ -package io.ionic.libs.iongeolocationlib.controller +package io.ionic.libs.iongeolocationlib.controller.helper import android.os.Build 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 new file mode 100644 index 0000000..ef69fe5 --- /dev/null +++ b/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/helper/IONGLOCFallbackHelper.kt @@ -0,0 +1,93 @@ +package io.ionic.libs.iongeolocationlib.controller.helper + +import android.annotation.SuppressLint +import android.location.Location +import android.location.LocationManager +import android.os.CancellationSignal +import android.os.Looper +import androidx.core.location.LocationListenerCompat +import androidx.core.location.LocationManagerCompat +import androidx.core.location.LocationRequestCompat +import io.ionic.libs.iongeolocationlib.model.IONGLOCException +import io.ionic.libs.iongeolocationlib.model.IONGLOCLocationOptions +import kotlinx.coroutines.suspendCancellableCoroutine +import java.util.concurrent.Executors +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +/** + * Helper class that wraps the functionality of Android's [LocationManager]. + * Meant to be used only as a fallback in case we cannot used the Fused Location Provider from Play Services. + */ +internal class IONGLOCFallbackHelper( + private val locationManager: LocationManager +) { + /** + * Obtains a fresh device location. + * This fallback method does not receive options because Android API does not allow to configure specific options. + * @return Location object representing the location + */ + @SuppressLint("MissingPermission") + internal suspend fun getCurrentLocation(): Location = + suspendCancellableCoroutine { continuation -> + val cancellationSignal: CancellationSignal? = null + LocationManagerCompat.getCurrentLocation( + locationManager, + LocationManager.GPS_PROVIDER, + cancellationSignal, + Executors.newSingleThreadExecutor() + ) { location -> + if (location != null) { + continuation.resume(location) + } else { + continuation.resumeWithException( + IONGLOCException.IONGLOCLocationRetrievalTimeoutException( + message = "Location request timed out" + ) + ) + } + } + } + + /** + * Requests updates of device location. + * + * Locations returned in callback associated with watchId + * @param options location request options to use + * @param locationListener the [LocationListenerCompat] to receive location updates in + */ + @SuppressLint("MissingPermission") + internal fun requestLocationUpdates( + options: IONGLOCLocationOptions, + locationListener: LocationListenerCompat + ) { + val locationRequest = LocationRequestCompat.Builder(options.timeout).apply { + // note: setMaxUpdateAgeMillis unavailable in this API, so options.maximumAge is not used + setQuality(if (options.enableHighAccuracy) LocationRequestCompat.QUALITY_HIGH_ACCURACY else LocationRequestCompat.QUALITY_BALANCED_POWER_ACCURACY) + if (options.minUpdateInterval != null) { + setMinUpdateIntervalMillis(options.minUpdateInterval) + } + }.build() + + LocationManagerCompat.requestLocationUpdates( + locationManager, + LocationManager.GPS_PROVIDER, + locationRequest, + locationListener, + Looper.getMainLooper() + ) + } + + /** + * Remove location updates for a specific listener. + * + * This method only triggers the removal, it does not await to see if the listener was actually removed. + * + * @param locationListener the location listener to be removed + */ + internal fun removeLocationUpdates( + locationListener: LocationListenerCompat + ) { + LocationManagerCompat.removeUpdates(locationManager, locationListener) + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCServiceHelper.kt b/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/helper/IONGLOCGoogleServicesHelper.kt similarity index 63% rename from src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCServiceHelper.kt rename to src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/helper/IONGLOCGoogleServicesHelper.kt index a5e305d..34c9e6d 100644 --- a/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCServiceHelper.kt +++ b/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/helper/IONGLOCGoogleServicesHelper.kt @@ -1,11 +1,13 @@ -package io.ionic.libs.iongeolocationlib.controller +package io.ionic.libs.iongeolocationlib.controller.helper import android.annotation.SuppressLint import android.app.Activity import android.location.Location +import android.location.LocationManager import android.os.Looper import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.IntentSenderRequest +import androidx.core.location.LocationManagerCompat import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.GoogleApiAvailability import com.google.android.gms.common.api.ResolvableApiException @@ -21,30 +23,32 @@ import io.ionic.libs.iongeolocationlib.model.IONGLOCLocationOptions import kotlinx.coroutines.tasks.await /** - * Helper class that wraps the functionality of FusedLocationProviderClient + * Helper class that wraps the functionality of [FusedLocationProviderClient] */ -class IONGLOCServiceHelper( +internal class IONGLOCGoogleServicesHelper( private val fusedLocationClient: FusedLocationProviderClient, private val activityLauncher: ActivityResultLauncher ) { /** * Checks if location is on, as well as other conditions for retrieving device location * @param activity the Android activity from which the location request is being triggered + * @param locationManager the [LocationManager] to get additional location settings from * @param options location request options to use - * @param interval interval for requesting location updates; use 0 if meant to retrieve a single location - * @return true if location was checked and is on, false if it requires user to resolve issue (e.g. turn on location) - * If false, the result is returned in `resolveLocationSettingsResultFlow` + * @param shouldTryResolve true if should try to resolve errors; false otherwise. + * Dictates whether [LocationSettingsResult.Resolving] or [LocationSettingsResult.ResolveSkipped] is returned. + * The exception being if location is off, in which case it will always be resolved. + * @return result of type [LocationSettingsResult] * @throws [IONGLOCException.IONGLOCSettingsException] if an error occurs that is not resolvable by user */ internal suspend fun checkLocationSettings( activity: Activity, + locationManager: LocationManager, options: IONGLOCLocationOptions, - interval: Long - ): Boolean { - + shouldTryResolve: Boolean + ): LocationSettingsResult { val request = LocationRequest.Builder( if (options.enableHighAccuracy) Priority.PRIORITY_HIGH_ACCURACY else Priority.PRIORITY_BALANCED_POWER_ACCURACY, - interval + options.timeout ).build() val builder = LocationSettingsRequest.Builder() @@ -53,37 +57,48 @@ class IONGLOCServiceHelper( try { client.checkLocationSettings(builder.build()).await() - return true + return LocationSettingsResult.Success } catch (e: ResolvableApiException) { - - // Show the dialog to enable location by calling startResolutionForResult(), - // and then handle the result in onActivityResult - val resolutionBuilder: IntentSenderRequest.Builder = - IntentSenderRequest.Builder(e.resolution) - val resolution: IntentSenderRequest = resolutionBuilder.build() - - activityLauncher.launch(resolution) + val locationOn = LocationManagerCompat.isLocationEnabled(locationManager) + if (!locationOn || shouldTryResolve) { + // Show the dialog to enable location by calling startResolutionForResult(), + // and then handle the result in onActivityResult + val resolutionBuilder: IntentSenderRequest.Builder = + IntentSenderRequest.Builder(e.resolution) + val resolution: IntentSenderRequest = resolutionBuilder.build() + activityLauncher.launch(resolution) + return LocationSettingsResult.Resolving + } else { + return LocationSettingsResult.ResolveSkipped(e) + } } catch (e: Exception) { - throw IONGLOCException.IONGLOCSettingsException( - message = "There is an error with the location settings.", - cause = e + return LocationSettingsResult.UnresolvableError( + IONGLOCException.IONGLOCSettingsException( + message = "There is an error with the location settings.", + cause = e + ) ) } - return false } /** * Checks if the device has google play services, required to use [FusedLocationProviderClient] * @param activity the Android activity from which the location request is being triggered - * @return true if google play services is available, false otherwise + * @param shouldTryResolve true if should try to resolve errors; false otherwise. + * @return Success if google play services is available, Error otherwise */ - internal fun checkGooglePlayServicesAvailable(activity: Activity): Result { + internal fun checkGooglePlayServicesAvailable( + activity: Activity, + shouldTryResolve: Boolean + ): Result { val googleApiAvailability = GoogleApiAvailability.getInstance() val status = googleApiAvailability.isGooglePlayServicesAvailable(activity) return if (status != ConnectionResult.SUCCESS) { if (googleApiAvailability.isUserResolvableError(status)) { - googleApiAvailability.getErrorDialog(activity, status, 1)?.show() + if (shouldTryResolve) { + googleApiAvailability.getErrorDialog(activity, status, 1)?.show() + } sendResultWithGoogleServicesException( resolvable = true, message = "Google Play Services error user resolvable." @@ -107,7 +122,10 @@ class IONGLOCServiceHelper( * @return Result object with the exception to return * */ - private fun sendResultWithGoogleServicesException(resolvable: Boolean, message: String): Result { + private fun sendResultWithGoogleServicesException( + resolvable: Boolean, + message: String + ): Result { return Result.failure( IONGLOCException.IONGLOCGoogleServicesException( resolvable = resolvable, @@ -175,4 +193,32 @@ class IONGLOCServiceHelper( ) { fusedLocationClient.removeLocationUpdates(locationCallback) } + + /** + * Result returned from checking Location Settings + */ + internal sealed class LocationSettingsResult { + /** + * Location settings checked successfully - Able to request location via Google Play Services + */ + data object Success : LocationSettingsResult() + + /** + * Received an error from location settings that may be resolved by the user. + * Check `resolveLocationSettingsResultFlow` in `IONGLOCController` to receive this result + */ + data object Resolving : LocationSettingsResult() + + /** + * Received a resolvable error from location settings, but resolving was skipped. + * Check the docs on `checkLocationSettings` for more information + */ + data class ResolveSkipped(val resolvableError: ResolvableApiException) : + LocationSettingsResult() + + /** + * An unresolvable error occurred - Cannot request location via Google Play Services + */ + data class UnresolvableError(val error: Exception) : LocationSettingsResult() + } } \ No newline at end of file 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 ace5ac6..4f0453b 100644 --- a/src/main/kotlin/io/ionic/libs/iongeolocationlib/model/IONGLOCLocationOptions.kt +++ b/src/main/kotlin/io/ionic/libs/iongeolocationlib/model/IONGLOCLocationOptions.kt @@ -7,5 +7,6 @@ data class IONGLOCLocationOptions( val timeout: Long, val maximumAge: Long, val enableHighAccuracy: Boolean, - val minUpdateInterval: Long? = null + val useLocationManagerFallback: Boolean, + 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 df6c4b7..5f591b0 100644 --- a/src/test/java/io/ionic/libs/iongeolocationlib/controller/IONGLOCControllerTest.kt +++ b/src/test/java/io/ionic/libs/iongeolocationlib/controller/IONGLOCControllerTest.kt @@ -3,11 +3,17 @@ package io.ionic.libs.iongeolocationlib.controller import android.app.Activity import android.app.PendingIntent import android.location.Location +import android.location.LocationManager import android.os.Build +import android.os.CancellationSignal import android.os.Looper import android.util.Log import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.IntentSenderRequest +import androidx.core.location.LocationListenerCompat +import androidx.core.location.LocationManagerCompat +import androidx.core.location.LocationRequestCompat +import androidx.core.util.Consumer import app.cash.turbine.test import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.GoogleApiAvailability @@ -22,14 +28,20 @@ import com.google.android.gms.location.LocationSettingsResponse import com.google.android.gms.location.LocationSettingsResult import com.google.android.gms.location.SettingsClient import com.google.android.gms.tasks.Task +import io.ionic.libs.iongeolocationlib.controller.helper.IONGLOCBuildConfig +import io.ionic.libs.iongeolocationlib.controller.helper.IONGLOCFallbackHelper +import io.ionic.libs.iongeolocationlib.controller.helper.IONGLOCGoogleServicesHelper import io.ionic.libs.iongeolocationlib.model.IONGLOCException import io.ionic.libs.iongeolocationlib.model.IONGLOCLocationOptions import io.ionic.libs.iongeolocationlib.model.IONGLOCLocationResult import io.mockk.coEvery import io.mockk.every +import io.mockk.just import io.mockk.mockk import io.mockk.mockkObject import io.mockk.mockkStatic +import io.mockk.runs +import io.mockk.slot import io.mockk.spyk import io.mockk.unmockkObject import io.mockk.unmockkStatic @@ -56,9 +68,11 @@ class IONGLOCControllerTest { private val activityResultLauncher = mockk>() private val googleApiAvailability = mockk() private val locationSettingsClient = mockk() - private val helper = spyk( - IONGLOCServiceHelper(fusedLocationProviderClient, activityResultLauncher) + private val locationManager = mockk() + private val googleServicesHelper = spyk( + IONGLOCGoogleServicesHelper(fusedLocationProviderClient, activityResultLauncher) ) + private val fallbackHelper = spyk(IONGLOCFallbackHelper(locationManager)) private val mockAndroidLocation = mockkLocation() private val locationSettingsTask = mockk>(relaxed = true) @@ -67,6 +81,7 @@ class IONGLOCControllerTest { private lateinit var sut: IONGLOCController private lateinit var locationCallback: LocationCallback + private lateinit var locationListenerCompat: LocationListenerCompat @Before fun setUp() { @@ -82,16 +97,20 @@ class IONGLOCControllerTest { every { Log.d(any(), any(), any()) } returns 0 mockkStatic(Looper::class) every { Looper.getMainLooper() } returns mockk() + mockkStatic(LocationManagerCompat::class) sut = IONGLOCController( fusedLocationClient = fusedLocationProviderClient, + locationManager = locationManager, activityLauncher = activityResultLauncher, - helper = helper + googleServicesHelper = googleServicesHelper, + fallbackHelper = fallbackHelper ) } @After fun tearDown() { + unmockkStatic(LocationManagerCompat::class) unmockkStatic(Looper::class) unmockkStatic(Log::class) unmockkObject(IONGLOCBuildConfig) @@ -218,13 +237,13 @@ class IONGLOCControllerTest { sut.addWatch(mockk(), locationOptions, "1").test { advanceUntilIdle() // to wait until locationCallback is instantiated - emitLocations(listOf(mockAndroidLocation)) + emitLocationsGMS(listOf(mockAndroidLocation)) var result = awaitItem() assertTrue(result.isSuccess) assertEquals(listOf(locationResult), result.getOrNull()) - emitLocations( + emitLocationsGMS( listOf( mockkLocation { every { time } returns 1234L }, mockkLocation { every { time } returns 12345L }, @@ -268,7 +287,7 @@ class IONGLOCControllerTest { sut.addWatch(mockk(), locationOptions, "1").test { advanceUntilIdle() // to wait until locationCallback is instantiated - emitLocations(listOf(mockAndroidLocation)) + emitLocationsGMS(listOf(mockAndroidLocation)) val result = awaitItem() assertTrue(result.isSuccess) @@ -354,13 +373,102 @@ class IONGLOCControllerTest { sut.addWatch(mockk(), locationOptions, watchId).test { advanceUntilIdle() // to wait until locationCallback is instantiated - emitLocations(listOf(mockAndroidLocation)) + emitLocationsGMS(listOf(mockAndroidLocation)) ensureAllEventsConsumed() } } // endregion clearWatch tests + // region fallback tests + @Test + fun `given location settings check fails but useLocationManagerFallback=true, when getCurrentLocation is called, result is returned`() = + runTest { + givenSuccessConditions() // to instantiate mocks + val error = RuntimeException() + coEvery { locationSettingsTask.await() } throws error + + val result = sut.getCurrentPosition(mockk(), locationOptionsWithFallback) + + assertTrue(result.isSuccess) + assertEquals(locationResult, result.getOrNull()) + } + + @Test + fun `given location settings check fails with resolvableError but useLocationManagerFallback=true, when getCurrentLocation is called, result is returned`() = + runTest { + givenSuccessConditions() // to instantiate mocks + givenResolvableApiException(Activity.RESULT_OK) + + val result = sut.getCurrentPosition(mockk(), locationOptionsWithFallback) + + assertTrue(result.isSuccess) + assertEquals(locationResult, result.getOrNull()) + } + + @Test + fun `given location settings check fails with resolvableError, location is off, and useLocationManagerFallback=true, when getCurrentLocation is called, result is returned`() = + runTest { + givenSuccessConditions() // to instantiate mocks + givenResolvableApiException(Activity.RESULT_OK) + every { LocationManagerCompat.isLocationEnabled(any()) } returns false + + val result = sut.getCurrentPosition(mockk(), locationOptionsWithFallback) + + assertTrue(result.isSuccess) + assertEquals(locationResult, result.getOrNull()) + verify { activityResultLauncher.launch(any()) } + } + + @Test + fun `given play services not available but useLocationManagerFallback=true, when addWatch is called, locations returned in flow`() = + runTest { + givenSuccessConditions() + givenPlayServicesNotAvailableWithResolvableError() + + sut.addWatch(mockk(), locationOptionsWithFallback, "1").test { + advanceUntilIdle() // to wait until locationCallback is instantiated + emitLocationsFallback(listOf(mockAndroidLocation)) + var result = awaitItem() + assertTrue(result.isSuccess) + assertEquals(listOf(locationResult), result.getOrNull()) + + + emitLocationsFallback( + listOf( + mockkLocation { every { time } returns 1234L }, + mockkLocation { every { time } returns 12345L }, + ) + ) + result = awaitItem() + assertEquals( + listOf( + locationResult.copy(timestamp = 1234L), + locationResult.copy(timestamp = 12345L), + ), + result.getOrNull() + ) + } + } + + @Test + fun `given watch was added via fallback, when clearWatch is called, true is returned`() = runTest { + val watchId = "id" + givenSuccessConditions() + givenPlayServicesNotAvailableWithUnResolvableError() + sut.addWatch(mockk(), locationOptionsWithFallback, watchId).test { + advanceUntilIdle() // to wait until locationCallback is instantiated + + val result = sut.clearWatch(watchId) + + assertTrue(result) + expectNoEvents() + } + verify { LocationManagerCompat.removeUpdates(any(), locationListenerCompat) } + } + // endregion fallback tests + + // region utils private fun givenSuccessConditions() { every { googleApiAvailability.isGooglePlayServicesAvailable(any()) } returns ConnectionResult.SUCCESS every { locationSettingsClient.checkLocationSettings(any()) } returns locationSettingsTask @@ -375,7 +483,6 @@ class IONGLOCControllerTest { fusedLocationProviderClient.getCurrentLocation(any(), any()) } returns currentLocationTask coEvery { currentLocationTask.await() } returns mockAndroidLocation - every { fusedLocationProviderClient.requestLocationUpdates( any(), @@ -386,8 +493,35 @@ class IONGLOCControllerTest { locationCallback = args[1] as LocationCallback voidTask } - every { fusedLocationProviderClient.removeLocationUpdates(any()) } returns voidTask + + val consumerSlot = slot>() + every { + LocationManagerCompat.getCurrentLocation( + any(), + any(), + any(), + any(), + capture(consumerSlot) + ) + } answers { + consumerSlot.captured.accept(mockAndroidLocation) + } + every { + LocationManagerCompat.requestLocationUpdates( + any(), + any(), + any(), + any(), + any() + ) + } answers { + locationListenerCompat = args[3] as LocationListenerCompat + } + every { + LocationManagerCompat.removeUpdates(any(), any()) + } just runs + every { LocationManagerCompat.isLocationEnabled(any()) } returns true } private fun givenPlayServicesNotAvailableWithResolvableError() { @@ -428,7 +562,7 @@ class IONGLOCControllerTest { overrideDefaultMocks() } - private fun emitLocations(locationList: List) { + private fun emitLocationsGMS(locationList: List) { locationCallback.onLocationResult( mockk(relaxed = true) { every { locations } returns locationList.toMutableList() @@ -436,6 +570,15 @@ class IONGLOCControllerTest { ) } + private fun emitLocationsFallback(locationList: List) { + if (locationList.size == 1) { + locationListenerCompat.onLocationChanged(locationList.first()) + } else { + locationListenerCompat.onLocationChanged(locationList) + } + } + // endregion utils + companion object { private const val DELAY = 3_000L @@ -443,9 +586,13 @@ class IONGLOCControllerTest { timeout = 5000, maximumAge = 3000, enableHighAccuracy = true, - minUpdateInterval = 2000L + minUpdateInterval = 2000L, + useLocationManagerFallback = false ) + private val locationOptionsWithFallback = + locationOptions.copy(useLocationManagerFallback = true) + private val locationResult = IONGLOCLocationResult( latitude = 1.0, longitude = 2.0, From fbef9af832b52c2bea80738c17f13bec02f4a5a8 Mon Sep 17 00:00:00 2001 From: OS-pedrogustavobilro Date: Mon, 29 Sep 2025 12:47:34 +0100 Subject: [PATCH 02/15] refactor: Rename new fallback attribute References: https://outsystemsrd.atlassian.net/browse/RMET-2991 --- .../controller/IONGLOCController.kt | 18 +++++++++--------- .../model/IONGLOCLocationOptions.kt | 2 +- .../controller/IONGLOCControllerTest.kt | 12 ++++++------ 3 files changed, 16 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 af02aa3..a71eae6 100644 --- a/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCController.kt +++ b/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCController.kt @@ -68,12 +68,12 @@ class IONGLOCController internal constructor( try { val checkResult: Result = checkLocationPreconditions(activity, options, isSingleLocationRequest = true) - return if (checkResult.isFailure && !options.useLocationManagerFallback) { + return if (checkResult.isFailure && !options.enableLocationManagerFallback) { Result.failure( checkResult.exceptionOrNull() ?: NullPointerException() ) } else { - val location: Location = if (!options.useLocationManagerFallback) { + val location: Location = if (!options.enableLocationManagerFallback) { googleServicesHelper.getCurrentLocation(options) } else { fallbackHelper.getCurrentLocation() @@ -130,19 +130,19 @@ class IONGLOCController internal constructor( if (checkWatchInBlackList(watchId)) { return } - val locations = locations.map { currentLocation -> + val locationResultList = locations.map { currentLocation -> currentLocation.toOSLocationResult() } - trySend(Result.success(locations)) + trySend(Result.success(locationResultList)) } val checkResult: Result = checkLocationPreconditions(activity, options, isSingleLocationRequest = true) - if (checkResult.isFailure && !options.useLocationManagerFallback) { + if (checkResult.isFailure && !options.enableLocationManagerFallback) { trySend( Result.failure(checkResult.exceptionOrNull() ?: NullPointerException()) ) } else { - watchLocationHandlers[watchId] = if (!options.useLocationManagerFallback) { + watchLocationHandlers[watchId] = if (!options.enableLocationManagerFallback) { LocationHandler.Callback(object : LocationCallback() { override fun onLocationResult(location: LocationResult) { onNewLocations(location.locations) @@ -204,9 +204,9 @@ class IONGLOCController internal constructor( } // if meant to use fallback, then resolvable errors from Play Services Location don't need to be addressed val playServicesResult = googleServicesHelper.checkGooglePlayServicesAvailable( - activity, shouldTryResolve = !options.useLocationManagerFallback + activity, shouldTryResolve = !options.enableLocationManagerFallback ) - if (playServicesResult.isFailure && !options.useLocationManagerFallback) { + if (playServicesResult.isFailure && !options.enableLocationManagerFallback) { return Result.failure(playServicesResult.exceptionOrNull() ?: NullPointerException()) } @@ -215,7 +215,7 @@ class IONGLOCController internal constructor( activity, locationManager, options.copy(timeout = if (isSingleLocationRequest) 0 else options.timeout), - shouldTryResolve = !options.useLocationManagerFallback + shouldTryResolve = !options.enableLocationManagerFallback ) return when (locationSettingsResult) { 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 4f0453b..9c418e6 100644 --- a/src/main/kotlin/io/ionic/libs/iongeolocationlib/model/IONGLOCLocationOptions.kt +++ b/src/main/kotlin/io/ionic/libs/iongeolocationlib/model/IONGLOCLocationOptions.kt @@ -7,6 +7,6 @@ data class IONGLOCLocationOptions( val timeout: Long, val maximumAge: Long, val enableHighAccuracy: Boolean, - val useLocationManagerFallback: Boolean, + val enableLocationManagerFallback: Boolean, 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 5f591b0..cc316cd 100644 --- a/src/test/java/io/ionic/libs/iongeolocationlib/controller/IONGLOCControllerTest.kt +++ b/src/test/java/io/ionic/libs/iongeolocationlib/controller/IONGLOCControllerTest.kt @@ -382,7 +382,7 @@ class IONGLOCControllerTest { // region fallback tests @Test - fun `given location settings check fails but useLocationManagerFallback=true, when getCurrentLocation is called, result is returned`() = + fun `given location settings check fails but enableLocationManagerFallback=true, when getCurrentLocation is called, result is returned`() = runTest { givenSuccessConditions() // to instantiate mocks val error = RuntimeException() @@ -395,7 +395,7 @@ class IONGLOCControllerTest { } @Test - fun `given location settings check fails with resolvableError but useLocationManagerFallback=true, when getCurrentLocation is called, result is returned`() = + fun `given location settings check fails with resolvableError but enableLocationManagerFallback=true, when getCurrentLocation is called, result is returned`() = runTest { givenSuccessConditions() // to instantiate mocks givenResolvableApiException(Activity.RESULT_OK) @@ -407,7 +407,7 @@ class IONGLOCControllerTest { } @Test - fun `given location settings check fails with resolvableError, location is off, and useLocationManagerFallback=true, when getCurrentLocation is called, result is returned`() = + fun `given location settings check fails with resolvableError, location is off, and enableLocationManagerFallback=true, when getCurrentLocation is called, result is returned`() = runTest { givenSuccessConditions() // to instantiate mocks givenResolvableApiException(Activity.RESULT_OK) @@ -421,7 +421,7 @@ class IONGLOCControllerTest { } @Test - fun `given play services not available but useLocationManagerFallback=true, when addWatch is called, locations returned in flow`() = + fun `given play services not available but enableLocationManagerFallback=true, when addWatch is called, locations returned in flow`() = runTest { givenSuccessConditions() givenPlayServicesNotAvailableWithResolvableError() @@ -587,11 +587,11 @@ class IONGLOCControllerTest { maximumAge = 3000, enableHighAccuracy = true, minUpdateInterval = 2000L, - useLocationManagerFallback = false + enableLocationManagerFallback = false ) private val locationOptionsWithFallback = - locationOptions.copy(useLocationManagerFallback = true) + locationOptions.copy(enableLocationManagerFallback = true) private val locationResult = IONGLOCLocationResult( latitude = 1.0, From bc3088d4f548ee23dd4341014c7065433dd27a98 Mon Sep 17 00:00:00 2001 From: OS-pedrogustavobilro Date: Mon, 29 Sep 2025 12:55:46 +0100 Subject: [PATCH 03/15] refactor: Extract code to separate methods --- .../controller/IONGLOCController.kt | 90 ++++++++++++------- 1 file changed, 57 insertions(+), 33 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 a71eae6..e799fe9 100644 --- a/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCController.kt +++ b/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCController.kt @@ -135,6 +135,7 @@ class IONGLOCController internal constructor( } trySend(Result.success(locationResultList)) } + val checkResult: Result = checkLocationPreconditions(activity, options, isSingleLocationRequest = true) if (checkResult.isFailure && !options.enableLocationManagerFallback) { @@ -142,29 +143,7 @@ class IONGLOCController internal constructor( Result.failure(checkResult.exceptionOrNull() ?: NullPointerException()) ) } else { - watchLocationHandlers[watchId] = if (!options.enableLocationManagerFallback) { - LocationHandler.Callback(object : LocationCallback() { - override fun onLocationResult(location: LocationResult) { - onNewLocations(location.locations) - } - }).also { - googleServicesHelper.requestLocationUpdates(options, it.callback) - } - } else { - LocationHandler.Listener(object : LocationListenerCompat { - override fun onLocationChanged(location: Location) { - onNewLocations(listOf(location)) - } - - override fun onLocationChanged(locations: List) { - locations.filterNotNull().takeIf { it.isNotEmpty() }?.let { - onNewLocations(it) - } - } - }).also { - fallbackHelper.requestLocationUpdates(options, it.listener) - } - } + requestLocationUpdates(watchId, options) { onNewLocations(it) } } } catch (exception: Exception) { Log.d(LOG_TAG, "Error requesting location updates: ${exception.message}") @@ -218,18 +197,42 @@ class IONGLOCController internal constructor( shouldTryResolve = !options.enableLocationManagerFallback ) - return when (locationSettingsResult) { - IONGLOCGoogleServicesHelper.LocationSettingsResult.Success -> - Result.success(Unit) - - IONGLOCGoogleServicesHelper.LocationSettingsResult.Resolving -> - resolveLocationSettingsResultFlow.first() + return locationSettingsResult.toKotlinResult() + } - is IONGLOCGoogleServicesHelper.LocationSettingsResult.ResolveSkipped -> - Result.failure(locationSettingsResult.resolvableError) + /** + * Request location updates using the appropriate helper class + * @param watchId a unique id to associate with the location update request (so that it may be cleared later) + * @param options location request options to use + * @param onNewLocations lambda to notify of new location requests + */ + private fun requestLocationUpdates( + watchId: String, + options: IONGLOCLocationOptions, + onNewLocations: (List) -> Unit + ) { + watchLocationHandlers[watchId] = if (!options.enableLocationManagerFallback) { + LocationHandler.Callback(object : LocationCallback() { + override fun onLocationResult(location: LocationResult) { + onNewLocations(location.locations) + } + }).also { + googleServicesHelper.requestLocationUpdates(options, it.callback) + } + } else { + LocationHandler.Listener(object : LocationListenerCompat { + override fun onLocationChanged(location: Location) { + onNewLocations(listOf(location)) + } - is IONGLOCGoogleServicesHelper.LocationSettingsResult.UnresolvableError -> - Result.failure(locationSettingsResult.error) + override fun onLocationChanged(locations: List) { + locations.filterNotNull().takeIf { it.isNotEmpty() }?.let { + onNewLocations(it) + } + } + }).also { + fallbackHelper.requestLocationUpdates(options, it.listener) + } } } @@ -291,6 +294,27 @@ class IONGLOCController internal constructor( timestamp = this.time ) + /** + * Extension function to convert the [IONGLOCGoogleServicesHelper.LocationSettingsResult]. + * Depending on the result value, it may suspend to await a flow + * @return a regular Kotlin [Result], which may be either Success or Error. + */ + private suspend fun IONGLOCGoogleServicesHelper.LocationSettingsResult.toKotlinResult(): Result { + return when (this) { + IONGLOCGoogleServicesHelper.LocationSettingsResult.Success -> + Result.success(Unit) + + IONGLOCGoogleServicesHelper.LocationSettingsResult.Resolving -> + resolveLocationSettingsResultFlow.first() + + is IONGLOCGoogleServicesHelper.LocationSettingsResult.ResolveSkipped -> + Result.failure(resolvableError) + + is IONGLOCGoogleServicesHelper.LocationSettingsResult.UnresolvableError -> + Result.failure(error) + } + } + sealed interface LocationHandler { data class Callback(val callback: LocationCallback) : LocationHandler data class Listener(val listener: LocationListenerCompat) : LocationHandler From 1229c03750b7125931043a38fc347d89c62586b4 Mon Sep 17 00:00:00 2001 From: OS-pedrogustavobilro Date: Mon, 29 Sep 2025 15:56:12 +0100 Subject: [PATCH 04/15] fix: Improve `getCurrentLocation` fallback and fix fallback condition References: https://outsystemsrd.atlassian.net/browse/RMET-2991 --- .../controller/IONGLOCController.kt | 23 ++-- .../helper/IONGLOCFallbackHelper.kt | 68 +++++++---- .../controller/IONGLOCControllerTest.kt | 108 ++++++++++++++---- 3 files changed, 146 insertions(+), 53 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 e799fe9..5b08a69 100644 --- a/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCController.kt +++ b/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCController.kt @@ -73,11 +73,12 @@ class IONGLOCController internal constructor( checkResult.exceptionOrNull() ?: NullPointerException() ) } else { - val location: Location = if (!options.enableLocationManagerFallback) { - googleServicesHelper.getCurrentLocation(options) - } else { - fallbackHelper.getCurrentLocation() - } + val location: Location = + if (checkResult.isFailure && options.enableLocationManagerFallback) { + fallbackHelper.getCurrentLocation(options) + } else { + googleServicesHelper.getCurrentLocation(options) + } return Result.success(location.toOSLocationResult()) } } catch (exception: Exception) { @@ -143,7 +144,11 @@ class IONGLOCController internal constructor( Result.failure(checkResult.exceptionOrNull() ?: NullPointerException()) ) } else { - requestLocationUpdates(watchId, options) { onNewLocations(it) } + requestLocationUpdates( + watchId, + options, + useFallback = checkResult.isFailure && options.enableLocationManagerFallback + ) { onNewLocations(it) } } } catch (exception: Exception) { Log.d(LOG_TAG, "Error requesting location updates: ${exception.message}") @@ -185,7 +190,7 @@ class IONGLOCController internal constructor( val playServicesResult = googleServicesHelper.checkGooglePlayServicesAvailable( activity, shouldTryResolve = !options.enableLocationManagerFallback ) - if (playServicesResult.isFailure && !options.enableLocationManagerFallback) { + if (playServicesResult.isFailure) { return Result.failure(playServicesResult.exceptionOrNull() ?: NullPointerException()) } @@ -204,14 +209,16 @@ class IONGLOCController internal constructor( * Request location updates using the appropriate helper class * @param watchId a unique id to associate with the location update request (so that it may be cleared later) * @param options location request options to use + * @param useFallback whether or not the fallback should be used * @param onNewLocations lambda to notify of new location requests */ private fun requestLocationUpdates( watchId: String, options: IONGLOCLocationOptions, + useFallback: Boolean, onNewLocations: (List) -> Unit ) { - watchLocationHandlers[watchId] = if (!options.enableLocationManagerFallback) { + watchLocationHandlers[watchId] = if (!useFallback) { LocationHandler.Callback(object : LocationCallback() { override fun onLocationResult(location: LocationResult) { onNewLocations(location.locations) 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 ef69fe5..614b2e3 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 @@ -3,17 +3,16 @@ package io.ionic.libs.iongeolocationlib.controller.helper import android.annotation.SuppressLint import android.location.Location import android.location.LocationManager -import android.os.CancellationSignal import android.os.Looper import androidx.core.location.LocationListenerCompat import androidx.core.location.LocationManagerCompat import androidx.core.location.LocationRequestCompat import io.ionic.libs.iongeolocationlib.model.IONGLOCException import io.ionic.libs.iongeolocationlib.model.IONGLOCLocationOptions +import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.suspendCancellableCoroutine -import java.util.concurrent.Executors +import kotlinx.coroutines.withTimeout import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException /** * Helper class that wraps the functionality of Android's [LocationManager]. @@ -24,30 +23,58 @@ internal class IONGLOCFallbackHelper( ) { /** * Obtains a fresh device location. - * This fallback method does not receive options because Android API does not allow to configure specific options. + * @param options location request options to use * @return Location object representing the location */ @SuppressLint("MissingPermission") - internal suspend fun getCurrentLocation(): Location = - suspendCancellableCoroutine { continuation -> - val cancellationSignal: CancellationSignal? = null - LocationManagerCompat.getCurrentLocation( - locationManager, - LocationManager.GPS_PROVIDER, - cancellationSignal, - Executors.newSingleThreadExecutor() - ) { location -> - if (location != null) { + internal suspend fun getCurrentLocation(options: IONGLOCLocationOptions): Location = try { + withTimeout(options.timeout) { + suspendCancellableCoroutine { continuation -> + val cachedLocation = + locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER) + if (cachedLocation != null && (System.currentTimeMillis() - cachedLocation.time) < options.maximumAge) { + continuation.resume(cachedLocation) + return@suspendCancellableCoroutine + } + + // cached location inexistent or too old - must make a fresh location request + val locationRequest = LocationRequestCompat.Builder(0).apply { + setQuality(if (options.enableHighAccuracy) LocationRequestCompat.QUALITY_HIGH_ACCURACY else LocationRequestCompat.QUALITY_BALANCED_POWER_ACCURACY) + }.build() + var locationListener: LocationListenerCompat? = null + locationListener = LocationListenerCompat { location -> + locationListener?.let { + // remove listener to only allow one location update + removeLocationUpdates(it) + locationListener = null + } continuation.resume(location) - } else { - continuation.resumeWithException( - IONGLOCException.IONGLOCLocationRetrievalTimeoutException( - message = "Location request timed out" - ) + } + locationListener?.let { + LocationManagerCompat.requestLocationUpdates( + locationManager, + LocationManager.GPS_PROVIDER, + locationRequest, + it, + Looper.getMainLooper() ) } + + // If coroutine is cancelled (due to timeout or external cancel), remove listener + continuation.invokeOnCancellation { + locationListener?.let { + removeLocationUpdates(it) + locationListener = null + } + } } } + } catch (e: TimeoutCancellationException) { + throw IONGLOCException.IONGLOCLocationRetrievalTimeoutException( + message = "Location request timed out", + cause = e + ) + } /** * Requests updates of device location. @@ -85,9 +112,10 @@ internal class IONGLOCFallbackHelper( * * @param locationListener the location listener to be removed */ + @SuppressLint("MissingPermission") internal fun removeLocationUpdates( locationListener: LocationListenerCompat ) { LocationManagerCompat.removeUpdates(locationManager, locationListener) } -} \ 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 cc316cd..aceae63 100644 --- a/src/test/java/io/ionic/libs/iongeolocationlib/controller/IONGLOCControllerTest.kt +++ b/src/test/java/io/ionic/libs/iongeolocationlib/controller/IONGLOCControllerTest.kt @@ -35,6 +35,7 @@ import io.ionic.libs.iongeolocationlib.model.IONGLOCException import io.ionic.libs.iongeolocationlib.model.IONGLOCLocationOptions import io.ionic.libs.iongeolocationlib.model.IONGLOCLocationResult import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.just import io.mockk.mockk @@ -48,10 +49,13 @@ import io.mockk.unmockkStatic import io.mockk.verify import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async 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 import org.junit.Assert.assertEquals @@ -382,42 +386,94 @@ class IONGLOCControllerTest { // region fallback tests @Test - fun `given location settings check fails but enableLocationManagerFallback=true, when getCurrentLocation is called, result is returned`() = + fun `given location settings check fails but enableLocationManagerFallback=true and there is cached data, when getCurrentLocation is called, result is returned`() = runTest { givenSuccessConditions() // to instantiate mocks - val error = RuntimeException() - coEvery { locationSettingsTask.await() } throws error + coEvery { locationSettingsTask.await() } throws RuntimeException() + val currentTime = System.currentTimeMillis() + every { locationManager.getLastKnownLocation(any()) } returns mockkLocation { + every { time } returns currentTime + } val result = sut.getCurrentPosition(mockk(), locationOptionsWithFallback) assertTrue(result.isSuccess) - assertEquals(locationResult, result.getOrNull()) + assertEquals(locationResult.copy(timestamp = currentTime), result.getOrNull()) + coVerify(inverse = true) { + // only getLastKnownLocation, no location update requested + LocationManagerCompat.requestLocationUpdates( + any(), + any(), + any(), + any(), + any() + ) + } } @Test - fun `given location settings check fails with resolvableError but enableLocationManagerFallback=true, when getCurrentLocation is called, result is returned`() = + fun `given location settings check fails with resolvableError but enableLocationManagerFallback=true but cached data is older, when getCurrentLocation is called, result is returned`() = runTest { givenSuccessConditions() // to instantiate mocks givenResolvableApiException(Activity.RESULT_OK) + every { locationManager.getLastKnownLocation(any()) } returns mockkLocation { + every { time } returns System.currentTimeMillis() - (60_000L * 60_000L) + } - val result = sut.getCurrentPosition(mockk(), locationOptionsWithFallback) + val deferred = + async { sut.getCurrentPosition(mockk(), locationOptionsWithFallback) } + runCurrent() // to wait until locationListenerCompat is instantiated, can't use advanceUntilIdle because that would trigger the timeout + locationListenerCompat.onLocationChanged(mockAndroidLocation) + val result = deferred.await() assertTrue(result.isSuccess) assertEquals(locationResult, result.getOrNull()) + coVerify { + // to confirm that listener has been removed by the end of getCurrentPosition + LocationManagerCompat.removeUpdates(locationManager, locationListenerCompat) + } } @Test - fun `given location settings check fails with resolvableError, location is off, and enableLocationManagerFallback=true, when getCurrentLocation is called, result is returned`() = + fun `given all preconditions pass and enableLocationManagerFallback=true, when getCurrentLocation is called, the fallback is not called`() = + runTest { + givenSuccessConditions() // to instantiate mocks + + sut.getCurrentPosition(mockk(), locationOptionsWithFallback) + + coVerify(inverse = true) { + fallbackHelper.getCurrentLocation(any()) + } + } + + @Test + fun `given location settings check fails with resolvableError, location is off, and enableLocationManagerFallback=true, when getCurrentLocation is called, the fallback is not called`() = runTest { givenSuccessConditions() // to instantiate mocks givenResolvableApiException(Activity.RESULT_OK) every { LocationManagerCompat.isLocationEnabled(any()) } returns false - val result = sut.getCurrentPosition(mockk(), locationOptionsWithFallback) + sut.getCurrentPosition(mockk(), locationOptionsWithFallback) - assertTrue(result.isSuccess) - assertEquals(locationResult, result.getOrNull()) - verify { activityResultLauncher.launch(any()) } + coVerify(inverse = true) { + fallbackHelper.getCurrentLocation(any()) + } + } + + @Test + fun `given fallback is being used but requestLocationUpdates does not notify listener, when getCurrentLocation is called, IONGLOCLocationRetrievalTimeoutException is returned`() = + runTest { + givenSuccessConditions() // to instantiate mocks + coEvery { locationSettingsTask.await() } throws RuntimeException() + every { LocationManagerCompat.isLocationEnabled(any()) } returns false + + val deferred = + async { sut.getCurrentPosition(mockk(), locationOptionsWithFallback) } + advanceTimeBy(locationOptionsWithFallback.timeout * 2) + val result = deferred.await() + + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is IONGLOCException.IONGLOCLocationRetrievalTimeoutException) } @Test @@ -427,7 +483,7 @@ class IONGLOCControllerTest { givenPlayServicesNotAvailableWithResolvableError() sut.addWatch(mockk(), locationOptionsWithFallback, "1").test { - advanceUntilIdle() // to wait until locationCallback is instantiated + advanceUntilIdle() // to wait until locationListenerCompat is instantiated emitLocationsFallback(listOf(mockAndroidLocation)) var result = awaitItem() assertTrue(result.isSuccess) @@ -452,20 +508,21 @@ class IONGLOCControllerTest { } @Test - fun `given watch was added via fallback, when clearWatch is called, true is returned`() = runTest { - val watchId = "id" - givenSuccessConditions() - givenPlayServicesNotAvailableWithUnResolvableError() - sut.addWatch(mockk(), locationOptionsWithFallback, watchId).test { - advanceUntilIdle() // to wait until locationCallback is instantiated + fun `given watch was added via fallback, when clearWatch is called, true is returned`() = + runTest { + val watchId = "id" + givenSuccessConditions() + givenPlayServicesNotAvailableWithUnResolvableError() + sut.addWatch(mockk(), locationOptionsWithFallback, watchId).test { + advanceUntilIdle() // to wait until locationListenerCompat is instantiated - val result = sut.clearWatch(watchId) + val result = sut.clearWatch(watchId) - assertTrue(result) - expectNoEvents() + assertTrue(result) + expectNoEvents() + } + verify { LocationManagerCompat.removeUpdates(any(), locationListenerCompat) } } - verify { LocationManagerCompat.removeUpdates(any(), locationListenerCompat) } - } // endregion fallback tests // region utils @@ -522,6 +579,7 @@ class IONGLOCControllerTest { LocationManagerCompat.removeUpdates(any(), any()) } just runs every { LocationManagerCompat.isLocationEnabled(any()) } returns true + every { locationManager.getLastKnownLocation(any()) } returns null } private fun givenPlayServicesNotAvailableWithResolvableError() { @@ -583,8 +641,8 @@ class IONGLOCControllerTest { private const val DELAY = 3_000L private val locationOptions = IONGLOCLocationOptions( - timeout = 5000, - maximumAge = 3000, + timeout = 60_000, + maximumAge = 30_000, enableHighAccuracy = true, minUpdateInterval = 2000L, enableLocationManagerFallback = false From 50d158b381b1de7f2e3830fd6ffba6553efb99e9 Mon Sep 17 00:00:00 2001 From: OS-pedrogustavobilro Date: Mon, 29 Sep 2025 16:30:11 +0100 Subject: [PATCH 05/15] chore: update Gradle, AGP, and Android SDK --- build.gradle | 6 +++--- gradle/wrapper/gradle-wrapper.properties | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/build.gradle b/build.gradle index 9a17cdf..90419ef 100644 --- a/build.gradle +++ b/build.gradle @@ -12,7 +12,7 @@ buildscript { if (System.getenv("SHOULD_PUBLISH") == "true") { classpath("io.github.gradle-nexus:publish-plugin:1.1.0") } - classpath 'com.android.tools.build:gradle:8.2.2' + classpath 'com.android.tools.build:gradle:8.12.3' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "org.jacoco:org.jacoco.core:$jacocoVersion" } @@ -41,11 +41,11 @@ apply plugin: "jacoco" android { namespace "io.ionic.libs.iongeolocationlib" - compileSdk 35 + compileSdk 36 defaultConfig { minSdk 23 - targetSdk 35 + targetSdk 36 versionCode 1 versionName "1.0" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 84a27f8..2165c5a 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Fri Apr 08 08:58:08 WEST 2022 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip distributionPath=wrapper/dists zipStorePath=wrapper/dists zipStoreBase=GRADLE_USER_HOME \ No newline at end of file From 89885ddb47d7a18e54470f63e4887ab6c3b2adf5 Mon Sep 17 00:00:00 2001 From: OS-pedrogustavobilro Date: Mon, 29 Sep 2025 18:40:36 +0100 Subject: [PATCH 06/15] fix: Fallback Quality based on network connectivity --- src/main/AndroidManifest.xml | 4 +- .../controller/IONGLOCController.kt | 7 ++- .../helper/IONGLOCFallbackHelper.kt | 54 +++++++++++++++++-- .../controller/IONGLOCControllerTest.kt | 27 ++++++++-- 4 files changed, 82 insertions(+), 10 deletions(-) diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml index de749ac..f0f34af 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -1,2 +1,4 @@ - \ No newline at end of file + + + \ 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 5b08a69..916cd80 100644 --- a/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCController.kt +++ b/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCController.kt @@ -4,6 +4,7 @@ import android.app.Activity import android.content.Context import android.location.Location import android.location.LocationManager +import android.net.ConnectivityManager import android.os.Build import android.util.Log import androidx.activity.result.ActivityResultLauncher @@ -33,12 +34,15 @@ import kotlinx.coroutines.flow.first class IONGLOCController internal constructor( fusedLocationClient: FusedLocationProviderClient, private val locationManager: LocationManager, + connectivityManager: ConnectivityManager, activityLauncher: ActivityResultLauncher, private val googleServicesHelper: IONGLOCGoogleServicesHelper = IONGLOCGoogleServicesHelper( fusedLocationClient, activityLauncher ), - private val fallbackHelper: IONGLOCFallbackHelper = IONGLOCFallbackHelper(locationManager) + private val fallbackHelper: IONGLOCFallbackHelper = IONGLOCFallbackHelper( + locationManager, connectivityManager + ) ) { constructor( @@ -47,6 +51,7 @@ class IONGLOCController internal constructor( ) : this( fusedLocationClient = LocationServices.getFusedLocationProviderClient(context), locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager, + connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager, activityLauncher = activityLauncher ) 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 614b2e3..c5283d1 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 @@ -3,10 +3,14 @@ package io.ionic.libs.iongeolocationlib.controller.helper import android.annotation.SuppressLint import android.location.Location import android.location.LocationManager +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.os.Build import android.os.Looper import androidx.core.location.LocationListenerCompat import androidx.core.location.LocationManagerCompat import androidx.core.location.LocationRequestCompat +import androidx.core.net.ConnectivityManagerCompat import io.ionic.libs.iongeolocationlib.model.IONGLOCException import io.ionic.libs.iongeolocationlib.model.IONGLOCLocationOptions import kotlinx.coroutines.TimeoutCancellationException @@ -19,7 +23,8 @@ import kotlin.coroutines.resume * Meant to be used only as a fallback in case we cannot used the Fused Location Provider from Play Services. */ internal class IONGLOCFallbackHelper( - private val locationManager: LocationManager + private val locationManager: LocationManager, + private val connectivityManager: ConnectivityManager ) { /** * Obtains a fresh device location. @@ -39,7 +44,7 @@ internal class IONGLOCFallbackHelper( // cached location inexistent or too old - must make a fresh location request val locationRequest = LocationRequestCompat.Builder(0).apply { - setQuality(if (options.enableHighAccuracy) LocationRequestCompat.QUALITY_HIGH_ACCURACY else LocationRequestCompat.QUALITY_BALANCED_POWER_ACCURACY) + setQuality(getQualityToUse(options)) }.build() var locationListener: LocationListenerCompat? = null locationListener = LocationListenerCompat { location -> @@ -53,7 +58,7 @@ internal class IONGLOCFallbackHelper( locationListener?.let { LocationManagerCompat.requestLocationUpdates( locationManager, - LocationManager.GPS_PROVIDER, + getProviderToUse(), locationRequest, it, Looper.getMainLooper() @@ -90,7 +95,7 @@ internal class IONGLOCFallbackHelper( ) { val locationRequest = LocationRequestCompat.Builder(options.timeout).apply { // note: setMaxUpdateAgeMillis unavailable in this API, so options.maximumAge is not used - setQuality(if (options.enableHighAccuracy) LocationRequestCompat.QUALITY_HIGH_ACCURACY else LocationRequestCompat.QUALITY_BALANCED_POWER_ACCURACY) + setQuality(getQualityToUse(options)) if (options.minUpdateInterval != null) { setMinUpdateIntervalMillis(options.minUpdateInterval) } @@ -98,7 +103,7 @@ internal class IONGLOCFallbackHelper( LocationManagerCompat.requestLocationUpdates( locationManager, - LocationManager.GPS_PROVIDER, + getProviderToUse(), locationRequest, locationListener, Looper.getMainLooper() @@ -118,4 +123,43 @@ internal class IONGLOCFallbackHelper( ) { LocationManagerCompat.removeUpdates(locationManager, locationListener) } + + /** + * Get the desired location request quality to use based on the provided options and providers. + * If there's no network provider, the request will go one quality level down, to avoid reducing timeouts from using only GPS. + * @param options location request options to use + * @return an integer indicating the desired quality for location request + */ + private fun getQualityToUse(options: IONGLOCLocationOptions): Int { + val networkEnabled = hasNetworkEnabledForLocationPurposes() + return when { + options.enableHighAccuracy && networkEnabled -> LocationRequestCompat.QUALITY_HIGH_ACCURACY + options.enableHighAccuracy || networkEnabled -> LocationRequestCompat.QUALITY_BALANCED_POWER_ACCURACY + else -> LocationRequestCompat.QUALITY_LOW_POWER + } + } + + /** + * @return the location provider to use + */ + private fun getProviderToUse() = + if (hasNetworkEnabledForLocationPurposes() && IONGLOCBuildConfig.getAndroidSdkVersionCode() >= Build.VERSION_CODES.S) { + LocationManager.FUSED_PROVIDER + } else { + LocationManager.GPS_PROVIDER + } + + /** + * @return true if there's any active network capability that could be used to improve location, false otherwise. + */ + private fun hasNetworkEnabledForLocationPurposes() = + connectivityManager.activeNetwork?.let { network -> + connectivityManager.getNetworkCapabilities(network)?.let { capabilities -> + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) || + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) || + (IONGLOCBuildConfig.getAndroidSdkVersionCode() >= Build.VERSION_CODES.O && + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI_AWARE)) + } + } ?: false } 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 aceae63..98d9dd6 100644 --- a/src/test/java/io/ionic/libs/iongeolocationlib/controller/IONGLOCControllerTest.kt +++ b/src/test/java/io/ionic/libs/iongeolocationlib/controller/IONGLOCControllerTest.kt @@ -4,6 +4,7 @@ import android.app.Activity import android.app.PendingIntent import android.location.Location import android.location.LocationManager +import android.net.ConnectivityManager import android.os.Build import android.os.CancellationSignal import android.os.Looper @@ -73,10 +74,11 @@ class IONGLOCControllerTest { private val googleApiAvailability = mockk() private val locationSettingsClient = mockk() private val locationManager = mockk() + private val connectivityManager = mockk() private val googleServicesHelper = spyk( IONGLOCGoogleServicesHelper(fusedLocationProviderClient, activityResultLauncher) ) - private val fallbackHelper = spyk(IONGLOCFallbackHelper(locationManager)) + private val fallbackHelper = spyk(IONGLOCFallbackHelper(locationManager, connectivityManager)) private val mockAndroidLocation = mockkLocation() private val locationSettingsTask = mockk>(relaxed = true) @@ -106,6 +108,7 @@ class IONGLOCControllerTest { sut = IONGLOCController( fusedLocationClient = fusedLocationProviderClient, locationManager = locationManager, + connectivityManager = connectivityManager, activityLauncher = activityResultLauncher, googleServicesHelper = googleServicesHelper, fallbackHelper = fallbackHelper @@ -432,6 +435,23 @@ class IONGLOCControllerTest { // to confirm that listener has been removed by the end of getCurrentPosition LocationManagerCompat.removeUpdates(locationManager, locationListenerCompat) } + // to confirm that the correct quality was passed, based on the fact that + // 1. there is no network provider and 2. options#enableHighAccuracy=true + val slot = slot() + coVerify { + // only getLastKnownLocation, no location update requested + LocationManagerCompat.requestLocationUpdates( + any(), + any(), + capture(slot), + any(), + any() + ) + } + assertEquals( + LocationRequestCompat.QUALITY_BALANCED_POWER_ACCURACY, + slot.captured.quality + ) } @Test @@ -552,6 +572,9 @@ class IONGLOCControllerTest { } every { fusedLocationProviderClient.removeLocationUpdates(any()) } returns voidTask + every { connectivityManager.activeNetwork } returns null + every { LocationManagerCompat.isLocationEnabled(any()) } returns true + every { locationManager.getLastKnownLocation(any()) } returns null val consumerSlot = slot>() every { LocationManagerCompat.getCurrentLocation( @@ -578,8 +601,6 @@ class IONGLOCControllerTest { every { LocationManagerCompat.removeUpdates(any(), any()) } just runs - every { LocationManagerCompat.isLocationEnabled(any()) } returns true - every { locationManager.getLastKnownLocation(any()) } returns null } private fun givenPlayServicesNotAvailableWithResolvableError() { From 9e1a98c5bd9cdda5cb736b36bd38cf946f80bbbe Mon Sep 17 00:00:00 2001 From: OS-pedrogustavobilro Date: Tue, 30 Sep 2025 11:37:45 +0100 Subject: [PATCH 07/15] fix: Use maximumAge in addWatch fallback --- .../helper/IONGLOCFallbackHelper.kt | 26 ++++++++++++++----- .../controller/IONGLOCControllerTest.kt | 23 ++++++++++++++++ 2 files changed, 43 insertions(+), 6 deletions(-) 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 c5283d1..850a264 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 @@ -10,7 +10,6 @@ import android.os.Looper import androidx.core.location.LocationListenerCompat import androidx.core.location.LocationManagerCompat import androidx.core.location.LocationRequestCompat -import androidx.core.net.ConnectivityManagerCompat import io.ionic.libs.iongeolocationlib.model.IONGLOCException import io.ionic.libs.iongeolocationlib.model.IONGLOCLocationOptions import kotlinx.coroutines.TimeoutCancellationException @@ -35,10 +34,8 @@ internal class IONGLOCFallbackHelper( internal suspend fun getCurrentLocation(options: IONGLOCLocationOptions): Location = try { withTimeout(options.timeout) { suspendCancellableCoroutine { continuation -> - val cachedLocation = - locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER) - if (cachedLocation != null && (System.currentTimeMillis() - cachedLocation.time) < options.maximumAge) { - continuation.resume(cachedLocation) + getValidCachedLocation(options)?.let { validCacheLocation -> + continuation.resume(validCacheLocation) return@suspendCancellableCoroutine } @@ -93,8 +90,12 @@ internal class IONGLOCFallbackHelper( options: IONGLOCLocationOptions, locationListener: LocationListenerCompat ) { + // note: setMaxUpdateAgeMillis unavailable in this API, which is why we explicitly try to retrieve it before starting the location request + getValidCachedLocation(options)?.let { validCacheLocation -> + locationListener.onLocationChanged(validCacheLocation) + } + val locationRequest = LocationRequestCompat.Builder(options.timeout).apply { - // note: setMaxUpdateAgeMillis unavailable in this API, so options.maximumAge is not used setQuality(getQualityToUse(options)) if (options.minUpdateInterval != null) { setMinUpdateIntervalMillis(options.minUpdateInterval) @@ -124,6 +125,19 @@ internal class IONGLOCFallbackHelper( LocationManagerCompat.removeUpdates(locationManager, locationListener) } + /** + * Get the last cached location if valid (newer that options#maximumAge) + * @param options location request options to use + * @return the cached [Location] or null if it didn't exist or was too old. + */ + @SuppressLint("MissingPermission") + private fun getValidCachedLocation(options: IONGLOCLocationOptions): Location? { + val cachedLocation = locationManager.getLastKnownLocation(getProviderToUse()) + return cachedLocation?.takeIf { + (System.currentTimeMillis() - it.time) < options.maximumAge + } + } + /** * Get the desired location request quality to use based on the provided options and providers. * If there's no network provider, the request will go one quality level down, to avoid reducing timeouts from using only GPS. 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 98d9dd6..55f8433 100644 --- a/src/test/java/io/ionic/libs/iongeolocationlib/controller/IONGLOCControllerTest.kt +++ b/src/test/java/io/ionic/libs/iongeolocationlib/controller/IONGLOCControllerTest.kt @@ -527,6 +527,29 @@ class IONGLOCControllerTest { } } + @Test + fun `given play services not available but enableLocationManagerFallback=true and there is cached location, when addWatch is called, cached location returned in flow`() = + runTest { + givenSuccessConditions() + givenPlayServicesNotAvailableWithUnResolvableError() + val currentTime = System.currentTimeMillis() + every { locationManager.getLastKnownLocation(any()) } returns mockkLocation { + every { time } returns currentTime + } + + sut.addWatch(mockk(), locationOptionsWithFallback, "1").test { + advanceUntilIdle() // to wait until locationListenerCompat is instantiated + + val result = awaitItem() + assertTrue(result.isSuccess) + assertEquals( + listOf(locationResult.copy(timestamp = currentTime)), + result.getOrNull() + ) + expectNoEvents() + } + } + @Test fun `given watch was added via fallback, when clearWatch is called, true is returned`() = runTest { From c01c5a693d4fb325ea314e4a2a703b956e8ae6c4 Mon Sep 17 00:00:00 2001 From: OS-pedrogustavobilro Date: Tue, 30 Sep 2025 12:55:44 +0100 Subject: [PATCH 08/15] fix: Improve use of providers in fallback References: https://outsystemsrd.atlassian.net/browse/RMET-2991 --- .../helper/IONGLOCFallbackHelper.kt | 25 +++++++++++-------- .../controller/IONGLOCControllerTest.kt | 1 + 2 files changed, 16 insertions(+), 10 deletions(-) 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 850a264..93f2351 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 @@ -132,7 +132,11 @@ internal class IONGLOCFallbackHelper( */ @SuppressLint("MissingPermission") private fun getValidCachedLocation(options: IONGLOCLocationOptions): Location? { - val cachedLocation = locationManager.getLastKnownLocation(getProviderToUse()) + // get from whichever of the providers has the latest location + val cachedLocation = listOfNotNull( + locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER), + locationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER) + ).maxByOrNull { it.time } return cachedLocation?.takeIf { (System.currentTimeMillis() - it.time) < options.maximumAge } @@ -167,13 +171,14 @@ internal class IONGLOCFallbackHelper( * @return true if there's any active network capability that could be used to improve location, false otherwise. */ private fun hasNetworkEnabledForLocationPurposes() = - connectivityManager.activeNetwork?.let { network -> - connectivityManager.getNetworkCapabilities(network)?.let { capabilities -> - capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || - capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) || - capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) || - (IONGLOCBuildConfig.getAndroidSdkVersionCode() >= Build.VERSION_CODES.O && - capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI_AWARE)) - } - } ?: false + LocationManagerCompat.hasProvider(locationManager, LocationManager.NETWORK_PROVIDER) && + connectivityManager.activeNetwork?.let { network -> + connectivityManager.getNetworkCapabilities(network)?.let { capabilities -> + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) || + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) || + (IONGLOCBuildConfig.getAndroidSdkVersionCode() >= Build.VERSION_CODES.O && + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI_AWARE)) + } + } ?: false } 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 55f8433..ece8e71 100644 --- a/src/test/java/io/ionic/libs/iongeolocationlib/controller/IONGLOCControllerTest.kt +++ b/src/test/java/io/ionic/libs/iongeolocationlib/controller/IONGLOCControllerTest.kt @@ -596,6 +596,7 @@ class IONGLOCControllerTest { every { fusedLocationProviderClient.removeLocationUpdates(any()) } returns voidTask every { connectivityManager.activeNetwork } returns null + every { LocationManagerCompat.hasProvider(any(), any()) } returns true every { LocationManagerCompat.isLocationEnabled(any()) } returns true every { locationManager.getLastKnownLocation(any()) } returns null val consumerSlot = slot>() From bb7804dc798d3f8e81beb90315d54939765ef3b6 Mon Sep 17 00:00:00 2001 From: OS-pedrogustavobilro Date: Tue, 30 Sep 2025 13:24:06 +0100 Subject: [PATCH 09/15] refactor: Extract methods and classes to separate files References: https://outsystemsrd.atlassian.net/browse/RMET-2991 --- .../controller/IONGLOCController.kt | 44 +++---------- .../controller/helper/IONGLOCExtensions.kt | 62 +++++++++++++++++++ .../helper/IONGLOCFallbackHelper.kt | 23 ++----- .../helper/IONGLOCGoogleServicesHelper.kt | 49 +-------------- .../model/internal/LocationHandler.kt | 19 ++++++ .../model/internal/LocationSettingsResult.kt | 31 ++++++++++ 6 files changed, 127 insertions(+), 101 deletions(-) create mode 100644 src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/helper/IONGLOCExtensions.kt create mode 100644 src/main/kotlin/io/ionic/libs/iongeolocationlib/model/internal/LocationHandler.kt create mode 100644 src/main/kotlin/io/ionic/libs/iongeolocationlib/model/internal/LocationSettingsResult.kt 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 916cd80..7d06ecc 100644 --- a/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCController.kt +++ b/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCController.kt @@ -5,7 +5,6 @@ import android.content.Context import android.location.Location import android.location.LocationManager import android.net.ConnectivityManager -import android.os.Build import android.util.Log import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.IntentSenderRequest @@ -15,12 +14,14 @@ import com.google.android.gms.location.FusedLocationProviderClient import com.google.android.gms.location.LocationCallback import com.google.android.gms.location.LocationResult import com.google.android.gms.location.LocationServices -import io.ionic.libs.iongeolocationlib.controller.helper.IONGLOCBuildConfig import io.ionic.libs.iongeolocationlib.controller.helper.IONGLOCFallbackHelper import io.ionic.libs.iongeolocationlib.controller.helper.IONGLOCGoogleServicesHelper +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.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -292,46 +293,19 @@ class IONGLOCController internal constructor( } /** - * Extension function to convert Location object into OSLocationResult object - * @return OSLocationResult object - */ - private fun Location.toOSLocationResult(): IONGLOCLocationResult = IONGLOCLocationResult( - latitude = this.latitude, - longitude = this.longitude, - altitude = this.altitude, - accuracy = this.accuracy, - altitudeAccuracy = if (IONGLOCBuildConfig.getAndroidSdkVersionCode() >= Build.VERSION_CODES.O) this.verticalAccuracyMeters else null, - heading = this.bearing, - speed = this.speed, - timestamp = this.time - ) - - /** - * Extension function to convert the [IONGLOCGoogleServicesHelper.LocationSettingsResult]. + * Extension function to convert the [LocationSettingsResult]. * Depending on the result value, it may suspend to await a flow * @return a regular Kotlin [Result], which may be either Success or Error. */ - private suspend fun IONGLOCGoogleServicesHelper.LocationSettingsResult.toKotlinResult(): Result { + private suspend fun LocationSettingsResult.toKotlinResult(): Result { return when (this) { - IONGLOCGoogleServicesHelper.LocationSettingsResult.Success -> - Result.success(Unit) - - IONGLOCGoogleServicesHelper.LocationSettingsResult.Resolving -> - resolveLocationSettingsResultFlow.first() - - is IONGLOCGoogleServicesHelper.LocationSettingsResult.ResolveSkipped -> - Result.failure(resolvableError) - - is IONGLOCGoogleServicesHelper.LocationSettingsResult.UnresolvableError -> - Result.failure(error) + LocationSettingsResult.Success -> Result.success(Unit) + LocationSettingsResult.Resolving -> resolveLocationSettingsResultFlow.first() + is LocationSettingsResult.ResolveSkipped -> Result.failure(resolvableError) + is LocationSettingsResult.UnresolvableError -> Result.failure(error) } } - sealed interface LocationHandler { - data class Callback(val callback: LocationCallback) : LocationHandler - data class Listener(val listener: LocationListenerCompat) : LocationHandler - } - companion object { private const val LOG_TAG = "IONGeolocationController" } 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 new file mode 100644 index 0000000..e6197bd --- /dev/null +++ b/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/helper/IONGLOCExtensions.kt @@ -0,0 +1,62 @@ +package io.ionic.libs.iongeolocationlib.controller.helper + +import android.location.Location +import android.location.LocationManager +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.os.Build +import androidx.core.location.LocationManagerCompat +import io.ionic.libs.iongeolocationlib.model.IONGLOCException +import io.ionic.libs.iongeolocationlib.model.IONGLOCLocationResult + +/** + * @return true if there's any active network capability that could be used to improve location, false otherwise. + */ +internal fun hasNetworkEnabledForLocationPurposes( + locationManager: LocationManager, + connectivityManager: ConnectivityManager +) = LocationManagerCompat.hasProvider(locationManager, LocationManager.NETWORK_PROVIDER) && + connectivityManager.activeNetwork?.let { network -> + connectivityManager.getNetworkCapabilities(network)?.let { capabilities -> + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) || + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) || + (IONGLOCBuildConfig.getAndroidSdkVersionCode() >= Build.VERSION_CODES.O && + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI_AWARE)) + } + } ?: false + +/** + * Returns a Result object containing an IONGLOCException.IONGLOCGoogleServicesException exception with the given + * resolvable and message values + * @param resolvable whether or not the exception is resolvable + * @param message message to include in the exception + * @return Result object with the exception to return + * + */ +internal fun sendResultWithGoogleServicesException( + resolvable: Boolean, + message: String +): Result { + return Result.failure( + IONGLOCException.IONGLOCGoogleServicesException( + resolvable = resolvable, + message = message + ) + ) +} + +/** + * Extension function to convert Location object into OSLocationResult object + * @return OSLocationResult object + */ +internal fun Location.toOSLocationResult(): IONGLOCLocationResult = IONGLOCLocationResult( + latitude = this.latitude, + longitude = this.longitude, + altitude = this.altitude, + accuracy = this.accuracy, + altitudeAccuracy = if (IONGLOCBuildConfig.getAndroidSdkVersionCode() >= Build.VERSION_CODES.O) this.verticalAccuracyMeters else null, + heading = this.bearing, + speed = this.speed, + timestamp = this.time +) \ 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 93f2351..5d6f66f 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 @@ -4,7 +4,6 @@ import android.annotation.SuppressLint import android.location.Location import android.location.LocationManager import android.net.ConnectivityManager -import android.net.NetworkCapabilities import android.os.Build import android.os.Looper import androidx.core.location.LocationListenerCompat @@ -149,7 +148,8 @@ internal class IONGLOCFallbackHelper( * @return an integer indicating the desired quality for location request */ private fun getQualityToUse(options: IONGLOCLocationOptions): Int { - val networkEnabled = hasNetworkEnabledForLocationPurposes() + val networkEnabled = + hasNetworkEnabledForLocationPurposes(locationManager, connectivityManager) return when { options.enableHighAccuracy && networkEnabled -> LocationRequestCompat.QUALITY_HIGH_ACCURACY options.enableHighAccuracy || networkEnabled -> LocationRequestCompat.QUALITY_BALANCED_POWER_ACCURACY @@ -161,24 +161,11 @@ internal class IONGLOCFallbackHelper( * @return the location provider to use */ private fun getProviderToUse() = - if (hasNetworkEnabledForLocationPurposes() && IONGLOCBuildConfig.getAndroidSdkVersionCode() >= Build.VERSION_CODES.S) { + if (hasNetworkEnabledForLocationPurposes(locationManager, connectivityManager) + && IONGLOCBuildConfig.getAndroidSdkVersionCode() >= Build.VERSION_CODES.S + ) { LocationManager.FUSED_PROVIDER } else { LocationManager.GPS_PROVIDER } - - /** - * @return true if there's any active network capability that could be used to improve location, false otherwise. - */ - private fun hasNetworkEnabledForLocationPurposes() = - LocationManagerCompat.hasProvider(locationManager, LocationManager.NETWORK_PROVIDER) && - connectivityManager.activeNetwork?.let { network -> - connectivityManager.getNetworkCapabilities(network)?.let { capabilities -> - capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || - capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) || - capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) || - (IONGLOCBuildConfig.getAndroidSdkVersionCode() >= Build.VERSION_CODES.O && - capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI_AWARE)) - } - } ?: false } 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 34c9e6d..77dc226 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 @@ -20,6 +20,7 @@ import com.google.android.gms.location.LocationSettingsRequest import com.google.android.gms.location.Priority import io.ionic.libs.iongeolocationlib.model.IONGLOCException import io.ionic.libs.iongeolocationlib.model.IONGLOCLocationOptions +import io.ionic.libs.iongeolocationlib.model.internal.LocationSettingsResult import kotlinx.coroutines.tasks.await /** @@ -114,26 +115,6 @@ internal class IONGLOCGoogleServicesHelper( } } - /** - * Returns a Result object containing an IONGLOCException.IONGLOCGoogleServicesException exception with the given - * resolvable and message values - * @param resolvable whether or not the exception is resolvable - * @param message message to include in the exception - * @return Result object with the exception to return - * - */ - private fun sendResultWithGoogleServicesException( - resolvable: Boolean, - message: String - ): Result { - return Result.failure( - IONGLOCException.IONGLOCGoogleServicesException( - resolvable = resolvable, - message = message - ) - ) - } - /** * Obtains a fresh device location. * @param options location request options to use @@ -193,32 +174,4 @@ internal class IONGLOCGoogleServicesHelper( ) { fusedLocationClient.removeLocationUpdates(locationCallback) } - - /** - * Result returned from checking Location Settings - */ - internal sealed class LocationSettingsResult { - /** - * Location settings checked successfully - Able to request location via Google Play Services - */ - data object Success : LocationSettingsResult() - - /** - * Received an error from location settings that may be resolved by the user. - * Check `resolveLocationSettingsResultFlow` in `IONGLOCController` to receive this result - */ - data object Resolving : LocationSettingsResult() - - /** - * Received a resolvable error from location settings, but resolving was skipped. - * Check the docs on `checkLocationSettings` for more information - */ - data class ResolveSkipped(val resolvableError: ResolvableApiException) : - LocationSettingsResult() - - /** - * An unresolvable error occurred - Cannot request location via Google Play Services - */ - data class UnresolvableError(val error: Exception) : LocationSettingsResult() - } } \ No newline at end of file diff --git a/src/main/kotlin/io/ionic/libs/iongeolocationlib/model/internal/LocationHandler.kt b/src/main/kotlin/io/ionic/libs/iongeolocationlib/model/internal/LocationHandler.kt new file mode 100644 index 0000000..8df646e --- /dev/null +++ b/src/main/kotlin/io/ionic/libs/iongeolocationlib/model/internal/LocationHandler.kt @@ -0,0 +1,19 @@ +package io.ionic.libs.iongeolocationlib.model.internal + +import androidx.core.location.LocationListenerCompat +import com.google.android.gms.location.LocationCallback + +/** + * Handler for receiving location updates, the implementation depends on if Play Services or Fallback is used + */ +sealed class LocationHandler { + /** + * Location updates returned via Google Play Service's [LocationCallback] + */ + data class Callback(val callback: LocationCallback) : LocationHandler() + + /** + * Location updates returned via fallback [android.location.LocationManager] with [LocationListenerCompat] + */ + data class Listener(val listener: LocationListenerCompat) : LocationHandler() +} \ No newline at end of file diff --git a/src/main/kotlin/io/ionic/libs/iongeolocationlib/model/internal/LocationSettingsResult.kt b/src/main/kotlin/io/ionic/libs/iongeolocationlib/model/internal/LocationSettingsResult.kt new file mode 100644 index 0000000..db2318d --- /dev/null +++ b/src/main/kotlin/io/ionic/libs/iongeolocationlib/model/internal/LocationSettingsResult.kt @@ -0,0 +1,31 @@ +package io.ionic.libs.iongeolocationlib.model.internal + +import com.google.android.gms.common.api.ResolvableApiException + +/** + * Result returned from checking Location Settings + */ +internal sealed class LocationSettingsResult { + /** + * Location settings checked successfully - Able to request location via Google Play Services + */ + data object Success : LocationSettingsResult() + + /** + * Received an error from location settings that may be resolved by the user. + * Check `resolveLocationSettingsResultFlow` in `IONGLOCController` to receive this result + */ + data object Resolving : LocationSettingsResult() + + /** + * Received a resolvable error from location settings, but resolving was skipped. + * Check the docs on `checkLocationSettings` for more information + */ + data class ResolveSkipped(val resolvableError: ResolvableApiException) : + LocationSettingsResult() + + /** + * An unresolvable error occurred - Cannot request location via Google Play Services + */ + data class UnresolvableError(val error: Exception) : LocationSettingsResult() +} \ No newline at end of file From 2783abf6b9d736880e321308c27258d5a71b115a Mon Sep 17 00:00:00 2001 From: OS-pedrogustavobilro Date: Tue, 30 Sep 2025 15:00:30 +0100 Subject: [PATCH 10/15] chore: Return specific error on no network+location --- .../controller/IONGLOCController.kt | 17 +++++++--- .../helper/IONGLOCGoogleServicesHelper.kt | 32 +++++++++++++++---- .../model/IONGLOCException.kt | 3 ++ .../controller/IONGLOCControllerTest.kt | 23 ++++++++++++- 4 files changed, 63 insertions(+), 12 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 7d06ecc..39be159 100644 --- a/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCController.kt +++ b/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCController.kt @@ -38,6 +38,8 @@ class IONGLOCController internal constructor( connectivityManager: ConnectivityManager, activityLauncher: ActivityResultLauncher, private val googleServicesHelper: IONGLOCGoogleServicesHelper = IONGLOCGoogleServicesHelper( + locationManager, + connectivityManager, fusedLocationClient, activityLauncher ), @@ -74,7 +76,7 @@ class IONGLOCController internal constructor( try { val checkResult: Result = checkLocationPreconditions(activity, options, isSingleLocationRequest = true) - return if (checkResult.isFailure && !options.enableLocationManagerFallback) { + return if (checkResult.shouldNotProceed(options)) { Result.failure( checkResult.exceptionOrNull() ?: NullPointerException() ) @@ -144,8 +146,8 @@ class IONGLOCController internal constructor( } val checkResult: Result = - checkLocationPreconditions(activity, options, isSingleLocationRequest = true) - if (checkResult.isFailure && !options.enableLocationManagerFallback) { + checkLocationPreconditions(activity, options, isSingleLocationRequest = false) + if (checkResult.shouldNotProceed(options)) { trySend( Result.failure(checkResult.exceptionOrNull() ?: NullPointerException()) ) @@ -203,7 +205,6 @@ class IONGLOCController internal constructor( resolveLocationSettingsResultFlow = MutableSharedFlow() val locationSettingsResult = googleServicesHelper.checkLocationSettings( activity, - locationManager, options.copy(timeout = if (isSingleLocationRequest) 0 else options.timeout), shouldTryResolve = !options.enableLocationManagerFallback ) @@ -306,6 +307,14 @@ class IONGLOCController internal constructor( } } + /** + * @return true if the the settings result is such that the location request must fail + * (even if enableLocationManagerFallback=true), or false otherwise + */ + private fun Result.shouldNotProceed(options: IONGLOCLocationOptions): Boolean = + isFailure && (!options.enableLocationManagerFallback || + exceptionOrNull() is IONGLOCException.IONGLOCLocationAndNetworkDisabledException) + companion object { private const val LOG_TAG = "IONGeolocationController" } 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 77dc226..d675bd0 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 @@ -4,12 +4,14 @@ import android.annotation.SuppressLint import android.app.Activity import android.location.Location import android.location.LocationManager +import android.net.ConnectivityManager import android.os.Looper import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.IntentSenderRequest import androidx.core.location.LocationManagerCompat import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.GoogleApiAvailability +import com.google.android.gms.common.api.ApiException import com.google.android.gms.common.api.ResolvableApiException import com.google.android.gms.location.CurrentLocationRequest import com.google.android.gms.location.FusedLocationProviderClient @@ -27,6 +29,8 @@ import kotlinx.coroutines.tasks.await * Helper class that wraps the functionality of [FusedLocationProviderClient] */ internal class IONGLOCGoogleServicesHelper( + private val locationManager: LocationManager, + private val connectivityManager: ConnectivityManager, private val fusedLocationClient: FusedLocationProviderClient, private val activityLauncher: ActivityResultLauncher ) { @@ -43,7 +47,6 @@ internal class IONGLOCGoogleServicesHelper( */ internal suspend fun checkLocationSettings( activity: Activity, - locationManager: LocationManager, options: IONGLOCLocationOptions, shouldTryResolve: Boolean ): LocationSettingsResult { @@ -73,12 +76,7 @@ internal class IONGLOCGoogleServicesHelper( return LocationSettingsResult.ResolveSkipped(e) } } catch (e: Exception) { - return LocationSettingsResult.UnresolvableError( - IONGLOCException.IONGLOCSettingsException( - message = "There is an error with the location settings.", - cause = e - ) - ) + return LocationSettingsResult.UnresolvableError(e.mapLocationSettingsError()) } } @@ -174,4 +172,24 @@ internal class IONGLOCGoogleServicesHelper( ) { fusedLocationClient.removeLocationUpdates(locationCallback) } + + /** + * Map the Location Settings Exception to an exception from this native library. + * @return a [IONGLOCException] + */ + private fun Exception.mapLocationSettingsError(): IONGLOCException = if (this is ApiException && + message?.contains("SETTINGS_CHANGE_UNAVAILABLE") == true + && !LocationManagerCompat.isLocationEnabled(locationManager) + && !hasNetworkEnabledForLocationPurposes(locationManager, connectivityManager) + ) { + IONGLOCException.IONGLOCLocationAndNetworkDisabledException( + message = "Unable to retrieve location because device has both Network and Location turned off.", + cause = this + ) + } else { + IONGLOCException.IONGLOCSettingsException( + message = "There is an error with the location settings.", + cause = this + ) + } } \ No newline at end of file diff --git a/src/main/kotlin/io/ionic/libs/iongeolocationlib/model/IONGLOCException.kt b/src/main/kotlin/io/ionic/libs/iongeolocationlib/model/IONGLOCException.kt index 30a06c9..bc6ba8e 100644 --- a/src/main/kotlin/io/ionic/libs/iongeolocationlib/model/IONGLOCException.kt +++ b/src/main/kotlin/io/ionic/libs/iongeolocationlib/model/IONGLOCException.kt @@ -20,4 +20,7 @@ sealed class IONGLOCException(message: String, cause: Throwable?) : Exception(me class IONGLOCLocationRetrievalTimeoutException( message: String, cause: Throwable? = null ) : IONGLOCException(message, cause) + class IONGLOCLocationAndNetworkDisabledException( + message: String, cause: Throwable? = null + ) : IONGLOCException(message, cause) } \ 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 ece8e71..a2154e2 100644 --- a/src/test/java/io/ionic/libs/iongeolocationlib/controller/IONGLOCControllerTest.kt +++ b/src/test/java/io/ionic/libs/iongeolocationlib/controller/IONGLOCControllerTest.kt @@ -18,6 +18,7 @@ import androidx.core.util.Consumer import app.cash.turbine.test import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.GoogleApiAvailability +import com.google.android.gms.common.api.ApiException import com.google.android.gms.common.api.ResolvableApiException import com.google.android.gms.common.api.Status import com.google.android.gms.location.CurrentLocationRequest @@ -76,7 +77,12 @@ class IONGLOCControllerTest { private val locationManager = mockk() private val connectivityManager = mockk() private val googleServicesHelper = spyk( - IONGLOCGoogleServicesHelper(fusedLocationProviderClient, activityResultLauncher) + IONGLOCGoogleServicesHelper( + locationManager, + connectivityManager, + fusedLocationProviderClient, + activityResultLauncher + ) ) private val fallbackHelper = spyk(IONGLOCFallbackHelper(locationManager, connectivityManager)) @@ -496,6 +502,21 @@ class IONGLOCControllerTest { assertTrue(result.exceptionOrNull() is IONGLOCException.IONGLOCLocationRetrievalTimeoutException) } + @Test + fun `given SETTINGS_CHANGE_UNAVAILABLE error and network+location disabled and enableLocationManagerFallback=true, when getCurrentLocation is called, IONGLOCLocationAndNetworkDisabledException is returned`() = + runTest { + givenSuccessConditions() // to instantiate mocks + coEvery { locationSettingsTask.await() } throws mockk { + every { message } returns "8502: SETTINGS_CHANGE_UNAVAILABLE" + } + every { LocationManagerCompat.isLocationEnabled(any()) } returns false + + val result = sut.getCurrentPosition(mockk(), locationOptionsWithFallback) + + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is IONGLOCException.IONGLOCLocationAndNetworkDisabledException) + } + @Test fun `given play services not available but enableLocationManagerFallback=true, when addWatch is called, locations returned in flow`() = runTest { From f161a5341f76243b98466ff6d4746336f1c6e1ab Mon Sep 17 00:00:00 2001 From: OS-pedrogustavobilro Date: Tue, 30 Sep 2025 15:14:05 +0100 Subject: [PATCH 11/15] docs: Document the IONGLOCLocationOptions properties --- README.md | 4 ++++ .../model/IONGLOCLocationOptions.kt | 21 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/README.md b/README.md index c08c409..4dc3a77 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,10 @@ Common issues and solutions: - Ensure clear sky view - Wait for better GPS signal +3. Error received when in airplane mode + - 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). + ## Contributing 1. Fork the repository 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 9c418e6..e5eb3e5 100644 --- a/src/main/kotlin/io/ionic/libs/iongeolocationlib/model/IONGLOCLocationOptions.kt +++ b/src/main/kotlin/io/ionic/libs/iongeolocationlib/model/IONGLOCLocationOptions.kt @@ -2,6 +2,27 @@ 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 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. + * Note that high accuracy requests may increase power/battery consumption. + * @property enableLocationManagerFallback Whether to fall back to the Android framework's + * [android.location.LocationManager] APIs in case [com.google.android.gms.location.FusedLocationProviderClient] + * location settings checks fail. + * This can happen for multiple reasons, e.g. Google Play Services location APIs are unavailable + * or device has no Network connection (e.g. on Airplane mode). + * If set to `false`, failures will propagate as exceptions instead of falling back. + * Note that [android.location.LocationManager] may not be as effective as Google Play Services implementation. + * 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 minUpdateInterval Optional minimum interval in **milliseconds** between consecutive + * location updates when using `addWatch`. */ data class IONGLOCLocationOptions( val timeout: Long, From 7ebcfab93ebe3d5fd6949fe0027a642343360b69 Mon Sep 17 00:00:00 2001 From: OS-pedrogustavobilro Date: Tue, 30 Sep 2025 15:16:21 +0100 Subject: [PATCH 12/15] 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 --- CHANGELOG.md | 9 ++++++++- README.md | 2 +- pom.xml | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77ad461..f196b01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,14 @@ 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). -## [Unreleased] +## [2.0.0] + +### 2025-09-30 + +- Feature: Allow using a fallback if Google Play Services fails. + +BREAKING CHANGE: The constructor for the controller and some of its methods have changed signature. +You will need to change how your application calls the library if you update to this version. ### 2025-06-26 diff --git a/README.md b/README.md index 4dc3a77..0b61919 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:1.0.0") + implementation("io.ionic.libs:iongeolocation-android:2.0.0") } ``` diff --git a/pom.xml b/pom.xml index 65e819a..8179603 100644 --- a/pom.xml +++ b/pom.xml @@ -6,5 +6,5 @@ 4.0.0 io.ionic.libs iongeolocation-android - 1.0.0 + 2.0.0 \ No newline at end of file From 6fdcfbcb3a0b9a9e6f50db3aa052afcff3fb7d9f Mon Sep 17 00:00:00 2001 From: OS-pedrogustavobilro Date: Tue, 30 Sep 2025 15:29:36 +0100 Subject: [PATCH 13/15] chore: remove outdate doc References: https://outsystemsrd.atlassian.net/browse/RMET-2991 --- .../controller/helper/IONGLOCGoogleServicesHelper.kt | 1 - 1 file changed, 1 deletion(-) 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 d675bd0..9dbcb87 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 @@ -37,7 +37,6 @@ internal class IONGLOCGoogleServicesHelper( /** * Checks if location is on, as well as other conditions for retrieving device location * @param activity the Android activity from which the location request is being triggered - * @param locationManager the [LocationManager] to get additional location settings from * @param options location request options to use * @param shouldTryResolve true if should try to resolve errors; false otherwise. * Dictates whether [LocationSettingsResult.Resolving] or [LocationSettingsResult.ResolveSkipped] is returned. From eeb3023632d24bee0aa2ace4a19ead26cf9f9564 Mon Sep 17 00:00:00 2001 From: OS-pedrogustavobilro Date: Tue, 30 Sep 2025 15:30:10 +0100 Subject: [PATCH 14/15] chore: remove irrelevant portion of PR template References: https://outsystemsrd.atlassian.net/browse/RMET-2991 --- pull_request_template.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/pull_request_template.md b/pull_request_template.md index 8127c95..826cef6 100644 --- a/pull_request_template.md +++ b/pull_request_template.md @@ -12,11 +12,6 @@ - [ ] Refactor (cosmetic changes) - [ ] Breaking change (change that would cause existing functionality to not work as expected) -## Platforms affected -- [ ] Android -- [ ] iOS -- [ ] JavaScript - ## Tests From 43aba1794f5fe7d7b0808144294524145daf45fa Mon Sep 17 00:00:00 2001 From: OS-pedrogustavobilro Date: Wed, 1 Oct 2025 09:37:18 +0100 Subject: [PATCH 15/15] refactor: minor changes from PR comments References: https://outsystemsrd.atlassian.net/browse/RMET-2991 --- .../controller/IONGLOCController.kt | 33 +++++++++++-------- 1 file changed, 19 insertions(+), 14 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 39be159..0df2be5 100644 --- a/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCController.kt +++ b/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCController.kt @@ -73,10 +73,10 @@ class IONGLOCController internal constructor( activity: Activity, options: IONGLOCLocationOptions ): Result { - try { + return try { val checkResult: Result = checkLocationPreconditions(activity, options, isSingleLocationRequest = true) - return if (checkResult.shouldNotProceed(options)) { + if (checkResult.shouldNotProceed(options)) { Result.failure( checkResult.exceptionOrNull() ?: NullPointerException() ) @@ -87,11 +87,11 @@ class IONGLOCController internal constructor( } else { googleServicesHelper.getCurrentLocation(options) } - return Result.success(location.toOSLocationResult()) + Result.success(location.toOSLocationResult()) } } catch (exception: Exception) { Log.d(LOG_TAG, "Error fetching location: ${exception.message}") - return Result.failure(exception) + Result.failure(exception) } } @@ -258,20 +258,25 @@ class IONGLOCController internal constructor( */ private fun clearWatch(id: String, addToBlackList: Boolean): Boolean { val watchHandler = watchLocationHandlers.remove(key = id) - return if (watchHandler != null) { - if (watchHandler is LocationHandler.Callback) { + return when (watchHandler) { + is LocationHandler.Callback -> { googleServicesHelper.removeLocationUpdates(watchHandler.callback) - } else if (watchHandler is LocationHandler.Listener) { + true + } + + is LocationHandler.Listener -> { fallbackHelper.removeLocationUpdates(watchHandler.listener) + true } - true - } else { - if (addToBlackList) { - // It is possible that clearWatch is being called before requestLocationUpdates is triggered (e.g. very low timeout on JavaScript side.) - // add to a blacklist in order to remove the location callback in the future - watchIdsBlacklist.add(id) + + else -> { + if (addToBlackList) { + // It is possible that clearWatch is being called before requestLocationUpdates is triggered (e.g. very low timeout on JavaScript side.) + // add to a blacklist in order to remove the location callback in the future + watchIdsBlacklist.add(id) + } + false } - false } }