mirror of
https://github.com/tauri-apps/plugins-workspace.git
synced 2026-10-18 23:07:27 +02:00
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:
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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"geolocation": "patch"
|
||||
"geolocation-js": "patch"
|
||||
---
|
||||
|
||||
Fix `watchPosition` never resolving on Android.
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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'])
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
Reference in new issue
Block a user