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

@@ -0,0 +1,6 @@
---
"geolocation": "patch"
"geolocation-js": "patch"
---
On Android, `getCurrentPosition` now honors the `timeout` option and rejects when no position is found in time. A `timeout` of `0` means no timeout.
@@ -0,0 +1,6 @@
---
"geolocation": "patch"
"geolocation-js": "patch"
---
Fixed `watchPosition` on Android stopping updates for every other active watch, and `clearWatch` leaving location updates running until the last watch was cleared.
+6
View File
@@ -0,0 +1,6 @@
---
"geolocation": "minor"
"geolocation-js": "minor"
---
Add support for the geolocation plugin on non-GMS Android devices (API 24+), using the best available location provider.
+6
View File
@@ -0,0 +1,6 @@
---
"geolocation": "patch"
"geolocation-js": "patch"
---
Fix `watchPosition` never resolving on Android.
+2
View File
@@ -10,5 +10,7 @@
<string>Authenticate with biometrics</string>
<key>NFCReaderUsageDescription</key>
<string>Read and write to NFC tags for testing</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Read the device location for testing</string>
</dict>
</plist>
@@ -18,6 +18,7 @@
"geolocation:allow-request-permissions",
"geolocation:allow-watch-position",
"geolocation:allow-get-current-position",
"geolocation:allow-clear-watch",
"haptics:allow-impact-feedback",
"haptics:allow-notification-feedback",
"haptics:allow-selection-feedback",
@@ -48,5 +48,7 @@
<string>Authenticate with biometrics</string>
<key>NFCReaderUsageDescription</key>
<string>Read and write to NFC tags for testing</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Read the device location for testing</string>
</dict>
</plist>
+70 -2
View File
@@ -2,11 +2,18 @@
import {
checkPermissions,
requestPermissions,
getCurrentPosition
getCurrentPosition,
watchPosition,
clearWatch
} from '@tauri-apps/plugin-geolocation'
export let onMessage
let pos = null
let watchId = null
async function getPosition() {
let permissions = await checkPermissions()
if (
@@ -17,13 +24,74 @@
}
if (permissions.location === 'granted') {
getCurrentPosition().then(onMessage).catch(onMessage)
getCurrentPosition().then((position) => {
pos = position
onMessage(position)
}).catch((err) => {
pos = null
onMessage(err)
})
} else {
onMessage('permission denied')
}
}
async function watchPos() {
let permissions = await checkPermissions()
if (
permissions.location === 'prompt' ||
permissions.location === 'prompt-with-rationale'
) {
permissions = await requestPermissions(['location'])
}
if (permissions.location === 'granted') {
await stopWatching()
watchId = await watchPosition({
enableHighAccuracy: true,
timeout: 5000,
maximumAge: 0
}, (position) => {
pos = position
onMessage(position)
})
onMessage('watchId: ' + watchId)
} else {
onMessage('permission denied')
}
}
async function stopWatching() {
if (watchId === null) {
return
}
try {
await clearWatch(watchId)
} catch (err) {
onMessage(err)
}
watchId = null
pos = null
}
</script>
<button class="btn" id="cli-matches" on:click={getPosition}>
Get Position
</button>
<button class="btn" on:click={watchPos}>
Watch Position
</button>
<button class="btn" on:click={stopWatching}>
Stop Watching
</button>
{#if watchId}
<span>Watch ID: {watchId}</span>
{/if}
{#if pos}
<pre>{JSON.stringify(pos, null, 2)}</pre>
{/if}
+9 -1
View File
@@ -88,13 +88,21 @@ skipped on desktop, and the rest run everywhere with the odd test gated.
| `websocket` | Text/binary echo, ping/pong, handshake headers, listener removal, server and client close, connection and argument errors, against the fixture server. | Same (through `adb reverse` on Android). |
| `window-state` | `filename`, save/restore, and that a re-created window gets its saved size (WM dependent). | Skipped — desktop-only plugin. |
| `biometric` | The `passcodeNotSet` rejection when `allowDeviceCredential` is set and the device has no lock screen (checked over `adb`). On an emulator without a lock screen, a PIN and fingerprint are enrolled (then removed) to cover `biometricCipher`: the encrypt/decrypt round trip, tampered and malformed input, and canceling the prompt, with the sensor touched over `adb emu`. | Android only; skipped on desktop. |
| mobile-only plugins | `barcode-scanner`, `geolocation`, `haptics` and `nfc` need hardware or native UI the driver cannot operate, so only their global API surface is asserted. | Only there. |
| `geolocation` | `getCurrentPosition`, `watchPosition` (several at once) and `clearWatch` against a location simulated from the host, the granted permission state, and the rejections with location services off (Android). |
| mobile-only plugins | `barcode-scanner`, `haptics` and `nfc` need hardware or native UI the driver cannot operate, so only their global API surface is asserted. | Only there. |
The notification permission specs are desktop-only: a mobile app starts out ungranted and
`requestPermission` puts up a system dialog the session would then block on. The rest of the
notification API (action types, channels, the pending/active lists, listeners) only exists on
mobile, and is covered there without the permission.
The geolocation specs move the device with `adb emu geo fix` (Android) and
`xcrun simctl location` (iOS), so on Android they need an emulator rather than a device.
Location access is granted up front, by `appium:autoGrantPermissions` on Android and by
`xcrun simctl privacy … grant location` at the start of the spec on iOS (the simulator
forgets the grant whenever Appium reinstalls the app), so the permission prompt itself is
not covered.
The [`plugins.spec.ts`](test/specs/plugins.spec.ts) spec additionally asserts that every
plugin the platform registers injects its global API with its documented members, and that
the _other_ platform's plugins are absent. That is what catches a plugin whose
@@ -0,0 +1,295 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
import path from 'node:path'
import { execFile, spawnSync } from 'node:child_process'
import { expect } from '@wdio/globals'
import {
tauri,
tauriError,
describePlugin,
eventually,
itOn,
platform
} from '../helpers/index.js'
// The location is simulated from the host: `adb emu geo fix` on the Android
// emulator and `simctl location` on the iOS simulator. Neither prompts for
// permission: Appium grants the manifest's location permissions on Android
// (`appium:autoGrantPermissions`), and on iOS the suite grants it through
// `simctl privacy`. Real Android devices cannot be driven this way, so the
// suite expects an emulator there.
const appId = 'com.tauri.api'
interface Point {
latitude: number
longitude: number
}
const EIFFEL_TOWER: Point = { latitude: 48.8584, longitude: 2.2945 }
const TOKYO_TOWER: Point = { latitude: 35.6586, longitude: 139.7454 }
const SUGARLOAF: Point = { latitude: -22.9486, longitude: -43.1566 }
const OPERA_HOUSE: Point = { latitude: -33.8568, longitude: 151.2153 }
const OPTIONS = { enableHighAccuracy: true, timeout: 1000, maximumAge: 0 }
/** `adb` from the Android SDK (else `PATH`), on `E2E_ANDROID_DEVICE` if set. */
function adbArgs(args: string[]): [string, string[]] {
const sdk = process.env.ANDROID_HOME ?? process.env.ANDROID_SDK_ROOT
const binary = sdk ? path.join(sdk, 'platform-tools', 'adb') : 'adb'
const device = process.env.E2E_ANDROID_DEVICE
? ['-s', process.env.E2E_ANDROID_DEVICE]
: []
return [binary, [...device, ...args]]
}
function adb(args: string[]): void {
const [binary, fullArgs] = adbArgs(args)
const result = spawnSync(binary, fullArgs, {
encoding: 'utf8',
timeout: 20_000
})
if (result.status !== 0) {
throw new Error(`adb ${args.join(' ')} failed: ${result.stderr}`)
}
}
/** The command that moves the simulated device to `point`. */
function locationCommand(point: Point): [string, string[]] {
if (platform === 'android') {
// Longitude first.
return adbArgs([
'emu',
'geo',
'fix',
String(point.longitude),
String(point.latitude)
])
}
return simctlArgs(['location', 'set', `${point.latitude},${point.longitude}`])
}
/** `xcrun simctl <command> <simulator> <args>` on the simulator under test. */
function simctlArgs([command, ...args]: string[]): [string, string[]] {
const simulator = process.env.E2E_IOS_UDID
if (!simulator) {
throw new Error(
'E2E_IOS_UDID is not set — run the suite via wdio.ios.conf.ts'
)
}
return ['xcrun', ['simctl', command, simulator, ...args]]
}
/**
* Grants the app location access on the simulator. locationd forgets an app's
* authorization whenever it is reinstalled, which Appium does at the start of
* every session, so this has to run within the session. It does not terminate
* the running app, and the plugin reads the new status on its next call.
*/
function grantIosLocation(): void {
const [binary, args] = simctlArgs(['privacy', 'grant', 'location', appId])
const result = spawnSync(binary, args, { encoding: 'utf8', timeout: 20_000 })
if (result.status !== 0) {
throw new Error(`simctl privacy grant failed: ${result.stderr}`)
}
}
/**
* Moves the simulated device to `point`, repeating every second until the
* returned function is called. The emulator only reports a `geo fix` to
* requests that are already listening, so a single fix sent before the app
* asks would be missed.
*/
function holdLocation(point: Point): () => void {
const [binary, args] = locationCommand(point)
// The first one synchronously, so the device is there before the caller
// asks for a position.
spawnSync(binary, args, { timeout: 20_000 })
const timer = setInterval(() => execFile(binary, args, () => {}), 1000)
return () => clearInterval(timer)
}
/** Runs `fn` while the device sits at `point`. */
async function at<T>(point: Point, fn: () => Promise<T>): Promise<T> {
const release = holdLocation(point)
try {
return await fn()
} finally {
release()
}
}
function expectAt(position: Point | undefined, point: Point): void {
expect(position).toBeDefined()
expect(position!.latitude).toBeCloseTo(point.latitude, 3)
expect(position!.longitude).toBeCloseTo(point.longitude, 3)
}
/** Starts a watch that records into `window.__geolocationE2E[key]`. */
function startWatch(key: string): Promise<number> {
return tauri(
async (api, key, options) => {
const w = window as unknown as {
__geolocationE2E?: Record<
string,
{
positions: { latitude: number; longitude: number }[]
errors: string[]
}
>
}
w.__geolocationE2E ??= {}
const log = { positions: [], errors: [] } as {
positions: { latitude: number; longitude: number }[]
errors: string[]
}
// eslint-disable-next-line security/detect-object-injection
w.__geolocationE2E[key] = log
return api.geolocation.watchPosition(options, (position, error) => {
if (position) {
log.positions.push({
latitude: position.coords.latitude,
longitude: position.coords.longitude
})
} else {
log.errors.push(String(error))
}
})
},
key,
OPTIONS
)
}
/** What the watch started with `key` has recorded so far. */
function watchLog(
key: string
): Promise<{ positions: Point[]; errors: string[] }> {
return tauri((_api, key) => {
const w = window as unknown as {
__geolocationE2E: Record<
string,
{
positions: { latitude: number; longitude: number }[]
errors: string[]
}
>
}
// eslint-disable-next-line security/detect-object-injection
return w.__geolocationE2E[key]
}, key)
}
/** Waits until the watch's latest position is `point`. */
async function watchReaches(key: string, point: Point): Promise<void> {
await eventually(
async () => {
const log = await watchLog(key)
expectAt(log.positions.at(-1), point)
},
{ timeout: 30_000, interval: 500 }
)
}
describePlugin('geolocation', { mobileOnly: true }, () => {
const watches: number[] = []
before(() => {
if (platform === 'ios') {
grantIosLocation()
}
})
afterEach(async () => {
for (const id of watches.splice(0)) {
await tauri((api, id) => api.geolocation.clearWatch(id), id)
}
})
it('reports the location permission as granted', async () => {
const status = await tauri((api) => api.geolocation.checkPermissions())
expect(status.location).toBe('granted')
expect(status.coarseLocation).toBe('granted')
// Already granted, so this answers without a prompt.
const requested = await tauri((api) =>
api.geolocation.requestPermissions(['location'])
)
expect(requested.location).toBe('granted')
})
it('returns the current position', async () => {
const position = await at(EIFFEL_TOWER, () =>
tauri((api, options) => api.geolocation.getCurrentPosition(options), {
...OPTIONS,
timeout: 20_000
})
)
expectAt(position.coords, EIFFEL_TOWER)
expect(typeof position.timestamp).toBe('number')
expect(typeof position.coords.accuracy).toBe('number')
})
it('reports position changes to a watch', async () => {
watches.push(await startWatch('single'))
await at(TOKYO_TOWER, () => watchReaches('single', TOKYO_TOWER))
await at(SUGARLOAF, () => watchReaches('single', SUGARLOAF))
// Core Location reports `kCLErrorLocationUnknown` (error 0) when it cannot
// get a fix right away and keeps trying; the plugin forwards it to the
// watch as is. Only that transient error is tolerated.
const errors = (await watchLog('single')).errors.filter(
(error) => !error.includes('kCLErrorDomain error 0.')
)
expect(errors).toEqual([])
})
it('keeps several watches active at once', async () => {
watches.push(await startWatch('first'))
await at(TOKYO_TOWER, () => watchReaches('first', TOKYO_TOWER))
// Starting another watch must not cancel the first one.
watches.push(await startWatch('second'))
await at(OPERA_HOUSE, async () => {
await watchReaches('second', OPERA_HOUSE)
await watchReaches('first', OPERA_HOUSE)
})
})
it('stops reporting after clearWatch', async () => {
const id = await startWatch('cleared')
await at(EIFFEL_TOWER, () => watchReaches('cleared', EIFFEL_TOWER))
await tauri((api, id) => api.geolocation.clearWatch(id), id)
const before = (await watchLog('cleared')).positions.length
await at(
SUGARLOAF,
() => new Promise((resolve) => setTimeout(resolve, 5000))
)
const after = await watchLog('cleared')
expect(after.positions.length).toBe(before)
expect(
after.positions.some(
(p) => Math.abs(p.latitude - SUGARLOAF.latitude) < 0.001
)
).toBe(false)
})
// Location services can only be toggled from the host on Android.
itOn('android', 'rejects while location services are off', async () => {
adb(['shell', 'cmd', 'location', 'set-location-enabled', 'false'])
try {
await eventually(async () => {
expect(
await tauriError((api) => api.geolocation.checkPermissions())
).toContain('Location services are disabled')
})
expect(
await tauriError(
(api, options) => api.geolocation.getCurrentPosition(options),
OPTIONS
)
).toContain('Location disabled')
} finally {
adb(['shell', 'cmd', 'location', 'set-location-enabled', 'true'])
}
})
})
+12 -4
View File
@@ -37,6 +37,7 @@ let fixtureServer: FixtureServer | undefined
export function mobileConfig(platform: MobilePlatform): WebdriverIO.Config {
const ios = platform === 'ios' ? iosTarget() : undefined
const simulator = ios ? iosSimulator() : undefined
// Path passed to the driver as `appium:app`.
const application =
@@ -70,7 +71,7 @@ export function mobileConfig(platform: MobilePlatform): WebdriverIO.Config {
capabilities: [
platform === 'android'
? androidCapabilities(application)
: iosCapabilities(application)
: iosCapabilities(application, simulator!)
],
services: [
[
@@ -105,7 +106,11 @@ export function mobileConfig(platform: MobilePlatform): WebdriverIO.Config {
specFileRetries: Number(process.env.E2E_SPEC_RETRIES ?? 0),
// Tells the specs (which run in worker processes) what the app runs on;
// `process.platform` there is the host. See `platform` in test/helpers.
runnerEnv: { E2E_PLATFORM: platform },
// `E2E_IOS_UDID` is the simulator the geolocation specs drive with simctl.
runnerEnv: {
E2E_PLATFORM: platform,
...(simulator ? { E2E_IOS_UDID: simulator } : {})
},
onPrepare: async () => {
// The example (and the specs' type-checking) resolve the plugins' JS
@@ -484,7 +489,10 @@ function androidTarget(): string {
// --- iOS ---------------------------------------------------------------------
function iosCapabilities(app: string): WebdriverIO.Capabilities {
function iosCapabilities(
app: string,
simulator: string
): WebdriverIO.Capabilities {
return {
platformName: 'iOS',
'appium:automationName': 'XCUITest',
@@ -499,7 +507,7 @@ function iosCapabilities(app: string): WebdriverIO.Capabilities {
'appium:additionalWebviewBundleIds': [
`process-${path.basename(app, '.app')}`
],
'appium:udid': iosSimulator(),
'appium:udid': simulator,
// No Simulator.app window. Besides not needing one, the driver otherwise
// shuts a simulator that is booted without a visible UI down to relaunch it
// with one, on every session — and that shutdown regularly outlasts the
@@ -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?