feat(geolocation): support for non-GMS devices (#3377)

* chore(geolocation): update gradle

* feat(geolocation): add support for android devices without GMS

* fix(geolocation): watchPosition does not resolve on android

* feat(example): add watch support for geolocation

* chore: add changeset

* Update .changes/geolocation-non-gms.md

Co-authored-by: Fabian-Lars <30730186+FabianLars@users.noreply.github.com>

* fix(geolocation): only use Android Location API on API Level >= 31

* fix resolve

* fix(geolocation): use LocationManagerCompat for non-GMS devices

- support API 24+ instead of only API 31+
- pick the best enabled provider (fused, gps, network) instead of GPS only
- honor enableHighAccuracy in getCurrentPosition
- clear the previous listener before starting a new watch
- report an error when the provider is disabled during a watch
- share the provider/priority logic between both code paths
- drop the geolocation-js bump, no JS changes

* fix(examples): clear existing geolocation watch before starting a new one

* chore(geolocation): add change file for watchPosition resolve fix

* fix(geolocation): honor enableHighAccuracy in getCurrentPosition on Android 12+ without GMS

* fix(geolocation): fall back to the next provider when one returns no fix on Android without GMS

* fix(geolocation): honor timeout in getCurrentPosition on Android

* fix(geolocation): keep multiple watchPosition watches active on Android

* chore(geolocation): bump geolocation-js alongside geolocation in change files

* refactor(geolocation): report provider errors from the callers of androidProviders

* e2e tests

---------

Co-authored-by: Tony <68118705+Legend-Master@users.noreply.github.com>
Co-authored-by: Fabian-Lars <30730186+FabianLars@users.noreply.github.com>
Co-authored-by: Lucas Nogueira <lucas@tauri.app>
This commit is contained in:
authored and GitHub committed 2026-10-13 17:37:39 -03:00
1 parent 57297f3fb1
commit 36d8e4098e
15 files changed
+573 -64

No files matched your search

@@ -8,50 +8,140 @@ import android.annotation.SuppressLint
import android.content.Context
import android.location.Location
import android.location.LocationManager
import android.location.LocationRequest
import android.os.Build
import android.os.CancellationSignal
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import androidx.core.content.ContextCompat
import androidx.core.location.LocationListenerCompat
import androidx.core.location.LocationManagerCompat
import androidx.core.location.LocationRequestCompat
import androidx.core.os.CancellationSignal as CompatCancellationSignal
import app.tauri.Logger
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
import com.google.android.gms.location.CurrentLocationRequest
import com.google.android.gms.location.FusedLocationProviderClient
import com.google.android.gms.location.LocationCallback
import com.google.android.gms.location.LocationRequest
import com.google.android.gms.location.LocationRequest as GmsLocationRequest
import com.google.android.gms.location.LocationResult
import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.Priority
public class Geolocation(private val context: Context) {
private val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
private var fusedLocationClient: FusedLocationProviderClient? = null
private var locationCallback: LocationCallback? = null
// Active watches, keyed by watch id.
private val locationCallbacks = HashMap<Long, LocationCallback>() // For gms
private val locationListeners = HashMap<Long, LocationListenerCompat>() // For android
fun isLocationServicesEnabled(): Boolean {
val lm = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
return LocationManagerCompat.isLocationEnabled(lm)
return LocationManagerCompat.isLocationEnabled(locationManager)
}
@SuppressWarnings("MissingPermission")
fun sendLocation(enableHighAccuracy: Boolean, successCallback: (location: Location) -> Unit, errorCallback: (error: String) -> Unit) {
val resultCode = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context);
if (resultCode == ConnectionResult.SUCCESS) {
val lm = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
private fun isGmsAvailable(): Boolean {
return GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context) == ConnectionResult.SUCCESS
}
if (this.isLocationServicesEnabled()) {
var networkEnabled = false
private fun gmsPriority(enableHighAccuracy: Boolean): Int {
if (enableHighAccuracy) {
return Priority.PRIORITY_HIGH_ACCURACY
}
try {
networkEnabled = lm.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
} catch (_: Exception) {
Logger.error("isProviderEnabled failed")
var networkEnabled = false
try {
networkEnabled = locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
} catch (_: Exception) {
Logger.error("isProviderEnabled failed")
}
return if (networkEnabled) Priority.PRIORITY_BALANCED_POWER_ACCURACY else Priority.PRIORITY_LOW_POWER
}
// Lists the enabled LocationManager providers for devices without Google Play Services, best first.
private fun androidProviders(enableHighAccuracy: Boolean): List<String> {
val fused = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) listOf(LocationManager.FUSED_PROVIDER) else emptyList()
val candidates = if (enableHighAccuracy) {
fused + LocationManager.GPS_PROVIDER + LocationManager.NETWORK_PROVIDER
} else {
fused + LocationManager.NETWORK_PROVIDER + LocationManager.GPS_PROVIDER
}
val enabled = locationManager.getProviders(true)
return candidates.filter { it in enabled }
}
// Asks each provider in turn for a fresh fix, so one that is enabled but can't produce a fix
// (e.g. network without Wi-Fi or cell signal) falls back to the next.
// The timeout covers all attempts; 0 or less means no timeout.
@SuppressLint("MissingPermission")
private fun androidCurrentLocation(providers: List<String>, enableHighAccuracy: Boolean, timeout: Long, successCallback: (location: Location) -> Unit, errorCallback: (error: String) -> Unit) {
// Everything below runs on the main thread, so no synchronization is needed.
val handler = Handler(Looper.getMainLooper())
var done = false
var cancelAttempt: (() -> Unit)? = null
val onTimeout = Runnable {
if (!done) {
done = true
cancelAttempt?.invoke()
errorCallback("Timeout expired.")
}
}
if (timeout > 0) {
handler.postDelayed(onTimeout, timeout)
}
fun attempt(remaining: List<String>) {
val provider = remaining.firstOrNull()
if (provider == null) {
done = true
handler.removeCallbacks(onTimeout)
errorCallback("Location unavailable.")
return
}
val onResult = { location: Location? ->
if (!done) {
if (location == null) {
attempt(remaining.drop(1))
} else {
done = true
handler.removeCallbacks(onTimeout)
successCallback(location)
}
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
// On API 31+ the provider is usually fused, so the request quality is what honors enableHighAccuracy.
val request = LocationRequest.Builder(0)
.setQuality(if (enableHighAccuracy) LocationRequest.QUALITY_HIGH_ACCURACY else LocationRequest.QUALITY_BALANCED_POWER_ACCURACY)
.build()
val signal = CancellationSignal()
cancelAttempt = { signal.cancel() }
locationManager.getCurrentLocation(provider, request, signal, context.mainExecutor) { onResult(it) }
} else {
val signal = CompatCancellationSignal()
cancelAttempt = { signal.cancel() }
LocationManagerCompat.getCurrentLocation(locationManager, provider, signal, ContextCompat.getMainExecutor(context)) { onResult(it) }
}
}
val lowPrio = if (networkEnabled) Priority.PRIORITY_BALANCED_POWER_ACCURACY else Priority.PRIORITY_LOW_POWER
val prio = if (enableHighAccuracy) Priority.PRIORITY_HIGH_ACCURACY else lowPrio
attempt(providers)
}
@SuppressLint("MissingPermission")
fun sendLocation(enableHighAccuracy: Boolean, timeout: Long, successCallback: (location: Location) -> Unit, errorCallback: (error: String) -> Unit) {
if (isGmsAvailable()) {
if (this.isLocationServicesEnabled()) {
val currentLocationRequest = CurrentLocationRequest.Builder()
.setPriority(gmsPriority(enableHighAccuracy))
if (timeout > 0) {
currentLocationRequest.setDurationMillis(timeout)
}
LocationServices
.getFusedLocationProviderClient(context)
.getCurrentLocation(prio, null)
.getCurrentLocation(currentLocationRequest.build(), null)
.addOnFailureListener { e -> e.message?.let { errorCallback(it) } }
.addOnSuccessListener { location ->
if (location == null) {
@@ -63,39 +153,29 @@ public class Geolocation(private val context: Context) {
} else {
errorCallback("Location disabled.")
}
} else if (!this.isLocationServicesEnabled()) {
errorCallback("Location disabled.")
} else {
errorCallback("Google Play Services unavailable.")
// Reports "Location unavailable." itself when there is no provider.
androidCurrentLocation(androidProviders(enableHighAccuracy), enableHighAccuracy, timeout, successCallback, errorCallback)
}
}
@SuppressLint("MissingPermission")
fun requestLocationUpdates(enableHighAccuracy: Boolean, timeout: Long, successCallback: (location: Location) -> Unit, errorCallback: (error: String) -> Unit) {
val resultCode = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context);
if (resultCode == ConnectionResult.SUCCESS) {
clearLocationUpdates()
fun requestLocationUpdates(id: Long, enableHighAccuracy: Boolean, timeout: Long, successCallback: (location: Location) -> Unit, errorCallback: (error: String) -> Unit) {
clearLocationUpdates(id)
if (isGmsAvailable()) {
fusedLocationClient = LocationServices.getFusedLocationProviderClient(context)
val lm = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
if (this.isLocationServicesEnabled()) {
var networkEnabled = false
try {
networkEnabled = lm.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
} catch (_: Exception) {
Logger.error("isProviderEnabled failed")
}
val lowPrio = if (networkEnabled) Priority.PRIORITY_BALANCED_POWER_ACCURACY else Priority.PRIORITY_LOW_POWER
val prio = if (enableHighAccuracy) Priority.PRIORITY_HIGH_ACCURACY else lowPrio
val locationRequest = LocationRequest.Builder(timeout)
val locationRequest = GmsLocationRequest.Builder(timeout)
.setMaxUpdateDelayMillis(timeout)
.setMinUpdateIntervalMillis(timeout)
.setPriority(prio)
.setPriority(gmsPriority(enableHighAccuracy))
.build()
locationCallback =
val locationCallback =
object : LocationCallback() {
override fun onLocationResult(locationResult: LocationResult) {
val lastLocation = locationResult.lastLocation
@@ -107,29 +187,53 @@ public class Geolocation(private val context: Context) {
}
}
fusedLocationClient?.requestLocationUpdates(locationRequest, locationCallback!!, null)
locationCallbacks[id] = locationCallback
fusedLocationClient?.requestLocationUpdates(locationRequest, locationCallback, null)
} else {
errorCallback("Location disabled.")
}
} else if (!this.isLocationServicesEnabled()) {
errorCallback("Location disabled.")
} else {
errorCallback("Google Play Services not available.")
val provider = androidProviders(enableHighAccuracy).firstOrNull()
if (provider == null) {
errorCallback("Location unavailable.")
return
}
val request = LocationRequestCompat.Builder(timeout)
.setQuality(if (enableHighAccuracy) LocationRequestCompat.QUALITY_HIGH_ACCURACY else LocationRequestCompat.QUALITY_LOW_POWER)
.build()
val listener = object : LocationListenerCompat {
override fun onLocationChanged(location: Location) {
successCallback(location)
}
override fun onProviderDisabled(provider: String) {
errorCallback("Location disabled.")
}
}
locationListeners[id] = listener
LocationManagerCompat.requestLocationUpdates(locationManager, provider, request, ContextCompat.getMainExecutor(context), listener)
}
}
fun clearLocationUpdates() {
if (locationCallback != null) {
fusedLocationClient?.removeLocationUpdates(locationCallback!!)
locationCallback = null
fun clearLocationUpdates(id: Long) {
locationCallbacks.remove(id)?.let { fusedLocationClient?.removeLocationUpdates(it) }
locationListeners.remove(id)?.let { LocationManagerCompat.removeUpdates(locationManager, it) }
}
fun clearAllLocationUpdates() {
for (id in locationCallbacks.keys + locationListeners.keys) {
clearLocationUpdates(id)
}
}
@SuppressLint("MissingPermission")
fun getLastLocation(maximumAge: Long): Location? {
var lastLoc: Location? = null
val lm = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
for (provider in lm.allProviders) {
val tmpLoc = lm.getLastKnownLocation(provider)
for (provider in locationManager.allProviders) {
val tmpLoc = locationManager.getLastKnownLocation(provider)
if (tmpLoc != null) {
val locationAge = SystemClock.elapsedRealtimeNanos() - tmpLoc.elapsedRealtimeNanos
val maxAgeNano = maximumAge * 1000000L
@@ -71,7 +71,7 @@ class GeolocationPlugin(private val activity: Activity): Plugin(activity) {
override fun onPause() {
super.onPause()
// Clear all location updates on pause to avoid possible background location calls
implementation.clearLocationUpdates()
implementation.clearAllLocationUpdates()
}
override fun onResume() {
@@ -108,7 +108,7 @@ class GeolocationPlugin(private val activity: Activity): Plugin(activity) {
if (location != null) {
invoke.resolve(convertLocation(location))
} else {
implementation.sendLocation(args.enableHighAccuracy,
implementation.sendLocation(args.enableHighAccuracy, args.timeout,
{ loc -> invoke.resolve(convertLocation(loc)) },
{ error -> invoke.reject(error) })
}
@@ -126,10 +126,12 @@ class GeolocationPlugin(private val activity: Activity): Plugin(activity) {
fun watchPosition(invoke: Invoke) {
val args = invoke.parseArgs(WatchArgs::class.java)
startWatch(invoke, args)
invoke.resolve()
}
private fun startWatch(invoke: Invoke, args: WatchArgs) {
implementation.requestLocationUpdates(
args.channel.id,
args.options.enableHighAccuracy,
args.options.timeout,
{ location -> args.channel.send(convertLocation(location)) },
@@ -143,10 +145,7 @@ class GeolocationPlugin(private val activity: Activity): Plugin(activity) {
val args = invoke.parseArgs(ClearWatchArgs::class.java)
watchers.remove(args.channelId)
if (watchers.isEmpty()) {
implementation.clearLocationUpdates()
}
implementation.clearLocationUpdates(args.channelId)
invoke.resolve()
}
-1
View File
@@ -108,7 +108,6 @@ export type PositionOptions = {
enableHighAccuracy: boolean
/**
* The maximum wait time in milliseconds for location updates.
* On Android the timeout gets ignored for getCurrentPosition.
* Ignored on iOS
*/
timeout: number
-1
View File
@@ -36,7 +36,6 @@ pub struct PositionOptions {
pub enable_high_accuracy: bool,
/// The maximum wait time in milliseconds for location updates.
/// Default: 10000
/// On Android the timeout gets ignored for getCurrentPosition.
/// Ignored on iOS.
// TODO: Handle Infinity and default to it.
// TODO: Should be u64+ but specta doesn't like that?