From 36d8e4098e0cbcb9005405249c0d55e26f8ed89c Mon Sep 17 00:00:00 2001 From: cfp <44261356+cfpwastaken@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:37:39 +0200 Subject: [PATCH] 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 --- ...cation-android-current-position-timeout.md | 6 + .../geolocation-android-multiple-watches.md | 6 + .changes/geolocation-non-gms.md | 6 + .changes/geolocation-watch-resolve.md | 6 + examples/api/src-tauri/Info.plist | 2 + .../api/src-tauri/capabilities/mobile.json | 1 + .../src-tauri/gen/apple/api_iOS/Info.plist | 2 + examples/api/src/views/Geolocation.svelte | 72 ++++- packages/api-e2e/README.md | 10 +- .../api-e2e/test/specs/geolocation.spec.ts | 295 ++++++++++++++++++ packages/api-e2e/wdio.mobile.ts | 16 +- .../android/src/main/java/Geolocation.kt | 202 +++++++++--- .../src/main/java/GeolocationPlugin.kt | 11 +- plugins/geolocation/guest-js/index.ts | 1 - plugins/geolocation/src/models.rs | 1 - 15 files changed, 573 insertions(+), 64 deletions(-) create mode 100644 .changes/geolocation-android-current-position-timeout.md create mode 100644 .changes/geolocation-android-multiple-watches.md create mode 100644 .changes/geolocation-non-gms.md create mode 100644 .changes/geolocation-watch-resolve.md create mode 100644 packages/api-e2e/test/specs/geolocation.spec.ts 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?