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 c08c409..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")
}
```
@@ -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/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
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
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
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 627c105..0df2be5 100644
--- a/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCController.kt
+++ b/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/IONGLOCController.kt
@@ -4,17 +4,24 @@ import android.app.Activity
import android.content.Context
import android.location.Location
import android.location.LocationManager
-import android.os.Build
+import android.net.ConnectivityManager
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.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
@@ -25,16 +32,34 @@ import kotlinx.coroutines.flow.first
* Entry point in IONGeolocationLib-Android
*
*/
-class IONGLOCController(
+class IONGLOCController internal constructor(
fusedLocationClient: FusedLocationProviderClient,
+ private val locationManager: LocationManager,
+ connectivityManager: ConnectivityManager,
activityLauncher: ActivityResultLauncher,
- private val helper: IONGLOCServiceHelper = IONGLOCServiceHelper(
+ private val googleServicesHelper: IONGLOCGoogleServicesHelper = IONGLOCGoogleServicesHelper(
+ locationManager,
+ connectivityManager,
fusedLocationClient,
activityLauncher
+ ),
+ private val fallbackHelper: IONGLOCFallbackHelper = IONGLOCFallbackHelper(
+ locationManager, connectivityManager
)
) {
+
+ constructor(
+ context: Context,
+ activityLauncher: ActivityResultLauncher
+ ) : this(
+ fusedLocationClient = LocationServices.getFusedLocationProviderClient(context),
+ locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager,
+ connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager,
+ activityLauncher = activityLauncher
+ )
+
private lateinit var resolveLocationSettingsResultFlow: MutableSharedFlow>
- private val locationCallbacks: MutableMap = mutableMapOf()
+ private val watchLocationHandlers: MutableMap = mutableMapOf()
private val watchIdsBlacklist: MutableList = mutableListOf()
/**
@@ -48,20 +73,25 @@ class IONGLOCController(
activity: Activity,
options: IONGLOCLocationOptions
): Result {
- try {
+ return try {
val checkResult: Result =
checkLocationPreconditions(activity, options, isSingleLocationRequest = true)
- return if (checkResult.isFailure) {
+ if (checkResult.shouldNotProceed(options)) {
Result.failure(
checkResult.exceptionOrNull() ?: NullPointerException()
)
} else {
- val location = helper.getCurrentLocation(options)
- return Result.success(location.toOSLocationResult())
+ val location: Location =
+ if (checkResult.isFailure && options.enableLocationManagerFallback) {
+ fallbackHelper.getCurrentLocation(options)
+ } else {
+ googleServicesHelper.getCurrentLocation(options)
+ }
+ Result.success(location.toOSLocationResult())
}
} catch (exception: Exception) {
Log.d(LOG_TAG, "Error fetching location: ${exception.message}")
- return Result.failure(exception)
+ Result.failure(exception)
}
}
@@ -86,10 +116,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,28 +134,29 @@ class IONGLOCController(
options: IONGLOCLocationOptions,
watchId: String
): Flow>> = callbackFlow {
-
try {
+ fun onNewLocations(locations: List) {
+ if (checkWatchInBlackList(watchId)) {
+ return
+ }
+ val locationResultList = locations.map { currentLocation ->
+ currentLocation.toOSLocationResult()
+ }
+ trySend(Result.success(locationResultList))
+ }
+
val checkResult: Result =
- checkLocationPreconditions(activity, options, isSingleLocationRequest = true)
- if (checkResult.isFailure) {
+ checkLocationPreconditions(activity, options, isSingleLocationRequest = false)
+ if (checkResult.shouldNotProceed(options)) {
trySend(
Result.failure(checkResult.exceptionOrNull() ?: NullPointerException())
)
} else {
- locationCallbacks[watchId] = object : LocationCallback() {
- override fun onLocationResult(location: LocationResult) {
- if (checkWatchInBlackList(watchId)) {
- return
- }
- val locations = location.locations.map { currentLocation ->
- currentLocation.toOSLocationResult()
- }
- trySend(Result.success(locations))
- }
- }.also {
- helper.requestLocationUpdates(options, it)
- }
+ requestLocationUpdates(
+ watchId,
+ options,
+ useFallback = checkResult.isFailure && options.enableLocationManagerFallback
+ ) { onNewLocations(it) }
}
} catch (exception: Exception) {
Log.d(LOG_TAG, "Error requesting location updates: ${exception.message}")
@@ -163,23 +194,59 @@ class IONGLOCController(
)
)
}
-
- val playServicesResult = helper.checkGooglePlayServicesAvailable(activity)
+ // 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.enableLocationManagerFallback
+ )
if (playServicesResult.isFailure) {
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
+ options.copy(timeout = if (isSingleLocationRequest) 0 else options.timeout),
+ shouldTryResolve = !options.enableLocationManagerFallback
)
- return if (locationSettingsChecked) {
- Result.success(Unit)
+ return locationSettingsResult.toKotlinResult()
+ }
+
+ /**
+ * 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 (!useFallback) {
+ LocationHandler.Callback(object : LocationCallback() {
+ override fun onLocationResult(location: LocationResult) {
+ onNewLocations(location.locations)
+ }
+ }).also {
+ googleServicesHelper.requestLocationUpdates(options, it.callback)
+ }
} else {
- resolveLocationSettingsResultFlow.first()
+ 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)
+ }
}
}
@@ -190,17 +257,26 @@ 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)
- 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)
+ val watchHandler = watchLocationHandlers.remove(key = id)
+ return when (watchHandler) {
+ is LocationHandler.Callback -> {
+ googleServicesHelper.removeLocationUpdates(watchHandler.callback)
+ true
+ }
+
+ is LocationHandler.Listener -> {
+ fallbackHelper.removeLocationUpdates(watchHandler.listener)
+ 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)
+ }
+ false
}
- false
}
}
@@ -223,19 +299,26 @@ class IONGLOCController(
}
/**
- * Extension function to convert Location object into OSLocationResult object
- * @return OSLocationResult object
+ * 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 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
- )
+ private suspend fun LocationSettingsResult.toKotlinResult(): Result {
+ return when (this) {
+ LocationSettingsResult.Success -> Result.success(Unit)
+ LocationSettingsResult.Resolving -> resolveLocationSettingsResultFlow.first()
+ is LocationSettingsResult.ResolveSkipped -> Result.failure(resolvableError)
+ is LocationSettingsResult.UnresolvableError -> Result.failure(error)
+ }
+ }
+
+ /**
+ * @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/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/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
new file mode 100644
index 0000000..5d6f66f
--- /dev/null
+++ b/src/main/kotlin/io/ionic/libs/iongeolocationlib/controller/helper/IONGLOCFallbackHelper.kt
@@ -0,0 +1,171 @@
+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.os.Build
+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 kotlinx.coroutines.withTimeout
+import kotlin.coroutines.resume
+
+/**
+ * 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,
+ private val connectivityManager: ConnectivityManager
+) {
+ /**
+ * Obtains a fresh device location.
+ * @param options location request options to use
+ * @return Location object representing the location
+ */
+ @SuppressLint("MissingPermission")
+ internal suspend fun getCurrentLocation(options: IONGLOCLocationOptions): Location = try {
+ withTimeout(options.timeout) {
+ suspendCancellableCoroutine { continuation ->
+ getValidCachedLocation(options)?.let { validCacheLocation ->
+ continuation.resume(validCacheLocation)
+ return@suspendCancellableCoroutine
+ }
+
+ // cached location inexistent or too old - must make a fresh location request
+ val locationRequest = LocationRequestCompat.Builder(0).apply {
+ setQuality(getQualityToUse(options))
+ }.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)
+ }
+ locationListener?.let {
+ LocationManagerCompat.requestLocationUpdates(
+ locationManager,
+ getProviderToUse(),
+ 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.
+ *
+ * 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
+ ) {
+ // 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 {
+ setQuality(getQualityToUse(options))
+ if (options.minUpdateInterval != null) {
+ setMinUpdateIntervalMillis(options.minUpdateInterval)
+ }
+ }.build()
+
+ LocationManagerCompat.requestLocationUpdates(
+ locationManager,
+ getProviderToUse(),
+ 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
+ */
+ @SuppressLint("MissingPermission")
+ internal fun removeLocationUpdates(
+ locationListener: LocationListenerCompat
+ ) {
+ 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? {
+ // 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
+ }
+ }
+
+ /**
+ * 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(locationManager, connectivityManager)
+ 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(locationManager, connectivityManager)
+ && IONGLOCBuildConfig.getAndroidSdkVersionCode() >= Build.VERSION_CODES.S
+ ) {
+ LocationManager.FUSED_PROVIDER
+ } else {
+ LocationManager.GPS_PROVIDER
+ }
+}
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..9dbcb87 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,13 +1,17 @@
-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.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
@@ -18,12 +22,15 @@ 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
/**
- * Helper class that wraps the functionality of FusedLocationProviderClient
+ * Helper class that wraps the functionality of [FusedLocationProviderClient]
*/
-class IONGLOCServiceHelper(
+internal class IONGLOCGoogleServicesHelper(
+ private val locationManager: LocationManager,
+ private val connectivityManager: ConnectivityManager,
private val fusedLocationClient: FusedLocationProviderClient,
private val activityLauncher: ActivityResultLauncher
) {
@@ -31,20 +38,20 @@ class IONGLOCServiceHelper(
* 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 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,
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 +60,43 @@ 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(e.mapLocationSettingsError())
}
- 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."
@@ -99,23 +112,6 @@ class IONGLOCServiceHelper(
}
}
- /**
- * 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
@@ -175,4 +171,24 @@ class IONGLOCServiceHelper(
) {
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/main/kotlin/io/ionic/libs/iongeolocationlib/model/IONGLOCLocationOptions.kt b/src/main/kotlin/io/ionic/libs/iongeolocationlib/model/IONGLOCLocationOptions.kt
index ace5ac6..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,10 +2,32 @@ 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,
val maximumAge: Long,
val enableHighAccuracy: Boolean,
- val minUpdateInterval: Long? = null
+ val enableLocationManagerFallback: Boolean,
+ val minUpdateInterval: Long? = null,
)
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
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..a2154e2 100644
--- a/src/test/java/io/ionic/libs/iongeolocationlib/controller/IONGLOCControllerTest.kt
+++ b/src/test/java/io/ionic/libs/iongeolocationlib/controller/IONGLOCControllerTest.kt
@@ -3,14 +3,22 @@ package io.ionic.libs.iongeolocationlib.controller
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
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
+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
@@ -22,24 +30,34 @@ 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.coVerify
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
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
@@ -56,9 +74,17 @@ 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 connectivityManager = mockk()
+ private val googleServicesHelper = spyk(
+ IONGLOCGoogleServicesHelper(
+ locationManager,
+ connectivityManager,
+ fusedLocationProviderClient,
+ activityResultLauncher
+ )
)
+ private val fallbackHelper = spyk(IONGLOCFallbackHelper(locationManager, connectivityManager))
private val mockAndroidLocation = mockkLocation()
private val locationSettingsTask = mockk>(relaxed = true)
@@ -67,6 +93,7 @@ class IONGLOCControllerTest {
private lateinit var sut: IONGLOCController
private lateinit var locationCallback: LocationCallback
+ private lateinit var locationListenerCompat: LocationListenerCompat
@Before
fun setUp() {
@@ -82,16 +109,21 @@ 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,
+ connectivityManager = connectivityManager,
activityLauncher = activityResultLauncher,
- helper = helper
+ googleServicesHelper = googleServicesHelper,
+ fallbackHelper = fallbackHelper
)
}
@After
fun tearDown() {
+ unmockkStatic(LocationManagerCompat::class)
unmockkStatic(Looper::class)
unmockkStatic(Log::class)
unmockkObject(IONGLOCBuildConfig)
@@ -218,13 +250,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 +300,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 +386,210 @@ 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 enableLocationManagerFallback=true and there is cached data, when getCurrentLocation is called, result is returned`() =
+ runTest {
+ givenSuccessConditions() // to instantiate mocks
+ 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.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 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 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)
+ }
+ // 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
+ 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
+
+ sut.getCurrentPosition(mockk(), locationOptionsWithFallback)
+
+ 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
+ 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 {
+ givenSuccessConditions()
+ givenPlayServicesNotAvailableWithResolvableError()
+
+ sut.addWatch(mockk(), locationOptionsWithFallback, "1").test {
+ advanceUntilIdle() // to wait until locationListenerCompat 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 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 {
+ val watchId = "id"
+ givenSuccessConditions()
+ givenPlayServicesNotAvailableWithUnResolvableError()
+ sut.addWatch(mockk(), locationOptionsWithFallback, watchId).test {
+ advanceUntilIdle() // to wait until locationListenerCompat 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 +604,6 @@ class IONGLOCControllerTest {
fusedLocationProviderClient.getCurrentLocation(any(), any())
} returns currentLocationTask
coEvery { currentLocationTask.await() } returns mockAndroidLocation
-
every {
fusedLocationProviderClient.requestLocationUpdates(
any(),
@@ -386,8 +614,38 @@ class IONGLOCControllerTest {
locationCallback = args[1] as LocationCallback
voidTask
}
-
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>()
+ 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
}
private fun givenPlayServicesNotAvailableWithResolvableError() {
@@ -428,7 +686,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,16 +694,29 @@ 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
private val locationOptions = IONGLOCLocationOptions(
- timeout = 5000,
- maximumAge = 3000,
+ timeout = 60_000,
+ maximumAge = 30_000,
enableHighAccuracy = true,
- minUpdateInterval = 2000L
+ minUpdateInterval = 2000L,
+ enableLocationManagerFallback = false
)
+ private val locationOptionsWithFallback =
+ locationOptions.copy(enableLocationManagerFallback = true)
+
private val locationResult = IONGLOCLocationResult(
latitude = 1.0,
longitude = 2.0,