diff --git a/.changes/geolocation-android-current-position-timeout.md b/.changes/geolocation-android-current-position-timeout.md
new file mode 100644
index 000000000..b8618b068
--- /dev/null
+++ b/.changes/geolocation-android-current-position-timeout.md
@@ -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.
diff --git a/.changes/geolocation-android-multiple-watches.md b/.changes/geolocation-android-multiple-watches.md
new file mode 100644
index 000000000..79ffe2430
--- /dev/null
+++ b/.changes/geolocation-android-multiple-watches.md
@@ -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.
diff --git a/.changes/geolocation-non-gms.md b/.changes/geolocation-non-gms.md
new file mode 100644
index 000000000..45d01edaa
--- /dev/null
+++ b/.changes/geolocation-non-gms.md
@@ -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.
diff --git a/.changes/geolocation-watch-resolve.md b/.changes/geolocation-watch-resolve.md
new file mode 100644
index 000000000..e0d4adda2
--- /dev/null
+++ b/.changes/geolocation-watch-resolve.md
@@ -0,0 +1,6 @@
+---
+"geolocation": "patch"
+"geolocation-js": "patch"
+---
+
+Fix `watchPosition` never resolving on Android.
diff --git a/examples/api/src-tauri/Info.plist b/examples/api/src-tauri/Info.plist
index 4dcd6aca6..87e6ff795 100644
--- a/examples/api/src-tauri/Info.plist
+++ b/examples/api/src-tauri/Info.plist
@@ -10,5 +10,7 @@
Authenticate with biometrics
NFCReaderUsageDescription
Read and write to NFC tags for testing
+ NSLocationWhenInUseUsageDescription
+ Read the device location for testing
diff --git a/examples/api/src-tauri/capabilities/mobile.json b/examples/api/src-tauri/capabilities/mobile.json
index 7a4cc593c..4db0f3fca 100644
--- a/examples/api/src-tauri/capabilities/mobile.json
+++ b/examples/api/src-tauri/capabilities/mobile.json
@@ -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",
diff --git a/examples/api/src-tauri/gen/apple/api_iOS/Info.plist b/examples/api/src-tauri/gen/apple/api_iOS/Info.plist
index 7d35e7021..fffebddb7 100644
--- a/examples/api/src-tauri/gen/apple/api_iOS/Info.plist
+++ b/examples/api/src-tauri/gen/apple/api_iOS/Info.plist
@@ -48,5 +48,7 @@
Authenticate with biometrics
NFCReaderUsageDescription
Read and write to NFC tags for testing
+ NSLocationWhenInUseUsageDescription
+ Read the device location for testing
\ No newline at end of file
diff --git a/examples/api/src/views/Geolocation.svelte b/examples/api/src/views/Geolocation.svelte
index cd181dfbe..07c2ae6b4 100644
--- a/examples/api/src/views/Geolocation.svelte
+++ b/examples/api/src/views/Geolocation.svelte
@@ -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
+ }
+
+
+
+
+
+
+{#if watchId}
+ Watch ID: {watchId}
+{/if}
+
+{#if pos}
+
{JSON.stringify(pos, null, 2)}
+{/if}
diff --git a/packages/api-e2e/README.md b/packages/api-e2e/README.md
index 947dd00e2..44463ccc3 100644
--- a/packages/api-e2e/README.md
+++ b/packages/api-e2e/README.md
@@ -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
diff --git a/packages/api-e2e/test/specs/geolocation.spec.ts b/packages/api-e2e/test/specs/geolocation.spec.ts
new file mode 100644
index 000000000..791efb628
--- /dev/null
+++ b/packages/api-e2e/test/specs/geolocation.spec.ts
@@ -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 ` 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(point: Point, fn: () => Promise): Promise {
+ 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 {
+ 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 {
+ 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'])
+ }
+ })
+})
diff --git a/packages/api-e2e/wdio.mobile.ts b/packages/api-e2e/wdio.mobile.ts
index e2b500458..192a0af5c 100644
--- a/packages/api-e2e/wdio.mobile.ts
+++ b/packages/api-e2e/wdio.mobile.ts
@@ -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
diff --git a/plugins/geolocation/android/src/main/java/Geolocation.kt b/plugins/geolocation/android/src/main/java/Geolocation.kt
index f93d23071..88ef6229a 100644
--- a/plugins/geolocation/android/src/main/java/Geolocation.kt
+++ b/plugins/geolocation/android/src/main/java/Geolocation.kt
@@ -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() // For gms
+ private val locationListeners = HashMap() // 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 {
+ 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, 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) {
+ 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
diff --git a/plugins/geolocation/android/src/main/java/GeolocationPlugin.kt b/plugins/geolocation/android/src/main/java/GeolocationPlugin.kt
index cf81f5e3b..8348f7152 100644
--- a/plugins/geolocation/android/src/main/java/GeolocationPlugin.kt
+++ b/plugins/geolocation/android/src/main/java/GeolocationPlugin.kt
@@ -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()
}
diff --git a/plugins/geolocation/guest-js/index.ts b/plugins/geolocation/guest-js/index.ts
index 2c05bdae2..9e06a7cbc 100644
--- a/plugins/geolocation/guest-js/index.ts
+++ b/plugins/geolocation/guest-js/index.ts
@@ -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
diff --git a/plugins/geolocation/src/models.rs b/plugins/geolocation/src/models.rs
index 9dd7d3ab3..1e80fed28 100644
--- a/plugins/geolocation/src/models.rs
+++ b/plugins/geolocation/src/models.rs
@@ -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?