appium-android-driver
Version:
Android UiAutomator and Chrome support for Appium
259 lines (243 loc) • 8.07 kB
text/typescript
import _ from 'lodash';
import {fs, tempDir} from '@appium/support';
import path from 'node:path';
import B from 'bluebird';
import type {Location} from '@appium/types';
import {SETTINGS_HELPER_ID} from 'io.appium.settings';
import {getThirdPartyPackages} from './app-management';
import type {AndroidDriver} from '../driver';
// The value close to zero, but not zero, is needed
// to trick JSON generation and send a float value instead of an integer,
// This allows strictly-typed clients, like Java, to properly
// parse it. Otherwise float 0.0 is always represented as integer 0 in JS.
// The value must not be greater than DBL_EPSILON (https://opensource.apple.com/source/Libc/Libc-498/include/float.h)
const GEO_EPSILON = Number.MIN_VALUE;
const MOCK_APP_IDS_STORE = '/data/local/tmp/mock_apps.json';
/**
* Sets the device geolocation.
*
* @param location The geolocation object containing latitude, longitude, and altitude.
* @returns Promise that resolves to the current geolocation after setting it.
*/
export async function setGeoLocation(
this: AndroidDriver,
location: Location,
): Promise<Location> {
await this.settingsApp.setGeoLocation(location, this.isEmulator());
try {
return await this.getGeoLocation();
} catch (e) {
this.log.warn(
`Could not get the current geolocation info: ${(e as Error).message}`,
);
this.log.warn(`Returning the default zero'ed values`);
return {
latitude: GEO_EPSILON,
longitude: GEO_EPSILON,
altitude: GEO_EPSILON,
};
}
}
/**
* Set the device geolocation.
*
* @param latitude Valid latitude value.
* @param longitude Valid longitude value.
* @param altitude Valid altitude value.
* @param satellites Number of satellites being tracked (1-12). Available for emulators.
* @param speed Valid speed value.
* https://developer.android.com/reference/android/location/Location#setSpeed(float)
* @param bearing Valid bearing value. Available for real devices.
* https://developer.android.com/reference/android/location/Location#setBearing(float)
* @param accuracy Valid accuracy value. Available for real devices.
* https://developer.android.com/reference/android/location/Location#setAccuracy(float),
* https://developer.android.com/reference/android/location/Criteria
*/
export async function mobileSetGeolocation(
this: AndroidDriver,
latitude: number,
longitude: number,
altitude?: number,
satellites?: number,
speed?: number,
bearing?: number,
accuracy?: number,
): Promise<void> {
await this.settingsApp.setGeoLocation({
latitude,
longitude,
altitude,
satellites,
speed,
bearing,
accuracy,
}, this.isEmulator());
}
/**
* Sends an async request to refresh the GPS cache.
*
* This feature only works if the device under test has Google Play Services
* installed. In case the vanilla LocationManager is used the device API level
* must be at version 30 (Android R) or higher.
*
* @param timeoutMs The maximum number of milliseconds
* to block until GPS cache is refreshed. Providing zero or a negative
* value to it skips waiting completely.
* 20000ms by default.
* @returns Promise that resolves when the GPS cache refresh is initiated.
*/
export async function mobileRefreshGpsCache(
this: AndroidDriver,
timeoutMs?: number,
): Promise<void> {
await this.settingsApp.refreshGeoLocationCache(timeoutMs);
}
/**
* Gets the current device geolocation.
*
* @returns Promise that resolves to the current geolocation object.
*/
export async function getGeoLocation(
this: AndroidDriver,
): Promise<Location> {
const {latitude, longitude, altitude} = await this.settingsApp.getGeoLocation();
return {
latitude: parseFloat(String(latitude)) || GEO_EPSILON,
longitude: parseFloat(String(longitude)) || GEO_EPSILON,
altitude: parseFloat(String(altitude)) || GEO_EPSILON,
};
}
/**
* Gets the current device geolocation.
*
* @returns Promise that resolves to the current geolocation object.
*/
export async function mobileGetGeolocation(
this: AndroidDriver,
): Promise<Location> {
return await this.getGeoLocation();
}
/**
* Checks if location services are enabled.
*
* @returns Promise that resolves to `true` if location services are enabled, `false` otherwise.
*/
export async function isLocationServicesEnabled(
this: AndroidDriver,
): Promise<boolean> {
return (await this.adb.getLocationProviders()).includes('gps');
}
/**
* Toggles the location services state.
*
* @returns Promise that resolves when the location services state is toggled.
*/
export async function toggleLocationServices(
this: AndroidDriver,
): Promise<void> {
this.log.info('Toggling location services');
const isGpsEnabled = await this.isLocationServicesEnabled();
this.log.debug(
`Current GPS state: ${isGpsEnabled}. ` +
`The service is going to be ${isGpsEnabled ? 'disabled' : 'enabled'}`,
);
await this.adb.toggleGPSLocationProvider(!isGpsEnabled);
}
/**
* Resets the geolocation to the default state.
*
* @returns Promise that resolves when the geolocation is reset.
* @throws {Error} If called on an emulator (geolocation reset does not work on emulators).
*/
export async function mobileResetGeolocation(
this: AndroidDriver,
): Promise<void> {
if (this.isEmulator()) {
throw new Error('Geolocation reset does not work on emulators');
}
await resetMockLocation.bind(this);
}
// #region Internal helpers
/**
* Sets the mock location permission for a specific app.
*
* @param appId The application package identifier.
* @returns Promise that resolves when the mock location permission is set.
*/
export async function setMockLocationApp(
this: AndroidDriver,
appId: string,
): Promise<void> {
try {
await this.adb.shell(['appops', 'set', appId, 'android:mock_location', 'allow']);
} catch (err) {
this.log.warn(`Unable to set mock location for app '${appId}': ${(err as Error).message}`);
return;
}
try {
let pkgIds: string[] = [];
if (await this.adb.fileExists(MOCK_APP_IDS_STORE)) {
try {
pkgIds = JSON.parse(await this.adb.shell(['cat', MOCK_APP_IDS_STORE]));
} catch {}
}
if (pkgIds.includes(appId)) {
return;
}
pkgIds.push(appId);
const tmpRoot = await tempDir.openDir();
const srcPath = path.posix.join(tmpRoot, path.posix.basename(MOCK_APP_IDS_STORE));
try {
await fs.writeFile(srcPath, JSON.stringify(pkgIds), 'utf8');
await this.adb.push(srcPath, MOCK_APP_IDS_STORE);
} finally {
await fs.rimraf(tmpRoot);
}
} catch (e) {
this.log.warn(`Unable to persist mock location app id '${appId}': ${(e as Error).message}`);
}
}
/**
* Resets the mock location permissions for all apps.
*
* @returns Promise that resolves when the mock location permissions are reset.
*/
async function resetMockLocation(
this: AndroidDriver,
): Promise<void> {
try {
const thirdPartyPkgIdsPromise = getThirdPartyPackages.bind(this)();
let pkgIds: string[] = [];
if (await this.adb.fileExists(MOCK_APP_IDS_STORE)) {
try {
pkgIds = JSON.parse(await this.adb.shell(['cat', MOCK_APP_IDS_STORE]));
} catch {}
}
const thirdPartyPkgIds = await thirdPartyPkgIdsPromise;
// Only include currently installed packages
const resultPkgs = _.intersection(pkgIds, thirdPartyPkgIds);
if (_.size(resultPkgs) <= 1) {
await this.adb.shell([
'appops',
'set',
resultPkgs[0] ?? SETTINGS_HELPER_ID,
'android:mock_location',
'deny',
]);
return;
}
this.log.debug(`Resetting mock_location permission for the following apps: ${resultPkgs}`);
await B.all(
resultPkgs.map((pkgId) =>
(async () => {
try {
await this.adb.shell(['appops', 'set', pkgId, 'android:mock_location', 'deny']);
} catch {}
})(),
),
);
} catch (err) {
this.log.warn(`Unable to reset mock location: ${(err as Error).message}`);
}
}
// #endregion Internal helpers