Files
tauri-plugins-workspace/packages/api-e2e/test/specs/geolocation.spec.ts
T
36d8e4098e 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>
2026-10-13 17:37:39 -03:00

296 lines
9.6 KiB
TypeScript

// 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'])
}
})
})