appium-android-driver
Version:
Android UiAutomator and Chrome support for Appium
640 lines (600 loc) • 22.5 kB
text/typescript
import {util} from '@appium/support';
import {waitForCondition, longSleep} from 'asyncbox';
import _ from 'lodash';
import {EOL} from 'node:os';
import B from 'bluebird';
import type {AndroidDriver, AndroidDriverOpts} from '../driver';
import type {AppState, TerminateAppOpts} from './types';
import type {UninstallOptions, InstallOptions, StartAppOptions} from 'appium-adb';
const APP_EXTENSIONS = ['.apk', '.apks'] as const;
const PACKAGE_INSTALL_TIMEOUT_MS = 90000;
// These constants are in sync with
// https://developer.apple.com/documentation/xctest/xcuiapplicationstate/xcuiapplicationstaterunningbackground?language=objc
export const APP_STATE = {
NOT_INSTALLED: 0,
NOT_RUNNING: 1,
RUNNING_IN_BACKGROUND: 3,
RUNNING_IN_FOREGROUND: 4,
} as const;
export interface IsAppInstalledOptions {
/**
* The user ID for which to check the package installation.
* The `current` user id is used by default.
*/
user?: string;
}
/**
* Checks whether the specified application is installed on the device.
*
* @param appId The application package identifier to check.
* @param opts Optional parameters for the installation check.
* @returns `true` if the application is installed, `false` otherwise.
*/
export async function isAppInstalled(
this: AndroidDriver,
appId: string,
opts: IsAppInstalledOptions = {},
): Promise<boolean> {
return await this.adb.isAppInstalled(appId, opts);
}
/**
* Checks whether the specified application is installed on the device.
*
* @param appId Application package identifier
* @param user The user ID for which the package is installed.
* The `current` user id is used by default.
* @returns `true` if the application is installed, `false` otherwise.
*/
export async function mobileIsAppInstalled(
this: AndroidDriver,
appId: string,
user?: string | number,
): Promise<boolean> {
const _opts: IsAppInstalledOptions = {};
if (util.hasValue(user)) {
_opts.user = `${user}`;
}
return await this.isAppInstalled(appId, _opts);
}
/**
* Queries the current state of the specified application.
*
* The possible states are:
* - `APP_STATE.NOT_INSTALLED` (0): The application is not installed
* - `APP_STATE.NOT_RUNNING` (1): The application is installed but not running
* - `APP_STATE.RUNNING_IN_BACKGROUND` (3): The application is running in the background
* - `APP_STATE.RUNNING_IN_FOREGROUND` (4): The application is running in the foreground
*
* @param appId Application package identifier
* @returns The current state of the application as a numeric value.
*/
export async function queryAppState(this: AndroidDriver, appId: string): Promise<AppState> {
this.log.info(`Querying the state of '${appId}'`);
if (!(await this.adb.isAppInstalled(appId))) {
return APP_STATE.NOT_INSTALLED;
}
if (!(await this.adb.isAppRunning(appId))) {
return APP_STATE.NOT_RUNNING;
}
const appIdRe = new RegExp(`\\b${_.escapeRegExp(appId)}/`);
for (const line of (await this.adb.dumpWindows()).split('\n')) {
if (appIdRe.test(line) && ['mCurrentFocus', 'mFocusedApp'].some((x) => line.includes(x))) {
return APP_STATE.RUNNING_IN_FOREGROUND;
}
}
return APP_STATE.RUNNING_IN_BACKGROUND;
}
/**
* Activates the specified application, bringing it to the foreground.
*
* This is equivalent to launching the application if it's not already running,
* or bringing it to the foreground if it's running in the background.
*
* @param appId Application package identifier
*/
export async function activateApp(this: AndroidDriver, appId: string): Promise<void> {
return await this.adb.activateApp(appId);
}
/**
* Removes (uninstalls) the specified application from the device.
*
* @param appId The application package identifier to remove.
* @param opts Optional uninstall options. See {@link UninstallOptions} for available options.
* @returns `true` if the application was successfully uninstalled, `false` otherwise.
*/
export async function removeApp(
this: AndroidDriver,
appId: string,
opts: Omit<UninstallOptions, 'appId'> = {},
): Promise<boolean> {
return await this.adb.uninstallApk(appId, opts);
}
/**
* @param appId Application package identifier
* @param timeout The count of milliseconds to wait until the
* app is uninstalled.
* @param keepData Set to true in order to keep the
* application data and cache folders after uninstall.
* @param skipInstallCheck Whether to check if the app is installed prior to
* uninstalling it. By default this is checked.
*/
export async function mobileRemoveApp(
this: AndroidDriver,
appId: string,
timeout?: number,
keepData?: boolean,
skipInstallCheck?: boolean,
): Promise<boolean> {
return await this.removeApp(appId, {
timeout,
keepData,
skipInstallCheck,
});
}
/**
* Terminates the specified application.
*
* This method forcefully stops the application and waits for it to be terminated.
* It checks that all process IDs belonging to the application have been stopped.
*
* @param appId The application package identifier to terminate.
* @param options Optional termination options. See {@link TerminateAppOpts} for available options.
* @returns `true` if the application was successfully terminated, `false` if it was not running.
* @throws {Error} If the application is still running after the timeout period.
*/
export async function terminateApp(
this: AndroidDriver,
appId: string,
options: TerminateAppOpts = {},
): Promise<boolean> {
this.log.info(`Terminating '${appId}'`);
const pids = await this.adb.listAppProcessIds(appId);
if (_.isEmpty(pids)) {
this.log.info(`The app '${appId}' is not running`);
return false;
}
await this.adb.forceStop(appId);
const timeout =
util.hasValue(options.timeout) && !Number.isNaN(options.timeout)
? parseInt(String(options.timeout), 10)
: 500;
if (timeout <= 0) {
this.log.info(
`'${appId}' has been terminated. Skipping checking of the application process state ` +
`since the timeout was set to ${timeout}ms`,
);
return true;
}
let currentPids: number[] = [];
try {
await waitForCondition(async () => {
if (await this.queryAppState(appId) <= APP_STATE.NOT_RUNNING) {
return true;
}
currentPids = await this.adb.listAppProcessIds(appId);
if (_.isEmpty(currentPids) || _.isEmpty(_.intersection(pids, currentPids))) {
this.log.info(
`The application '${appId}' was reported running, ` +
`although all process ids belonging to it have been changed: ` +
`(${JSON.stringify(pids)} -> ${JSON.stringify(currentPids)}). ` +
`Assuming the termination was successful.`
);
return true;
}
return false;
}, {
waitMs: timeout,
intervalMs: 100,
});
} catch {
if (!_.isEmpty(currentPids) && !_.isEmpty(_.difference(pids, currentPids))) {
this.log.warn(
`Some of processes belonging to the '${appId}' applcation are still running ` +
`after ${timeout}ms (${JSON.stringify(pids)} -> ${JSON.stringify(currentPids)})`
);
}
throw this.log.errorWithException(`'${appId}' is still running after ${timeout}ms timeout`);
}
this.log.info(`'${appId}' has been successfully terminated`);
return true;
}
/**
* Terminates the specified application.
*
* This method forcefully stops the application and waits for it to be terminated.
* It checks that all process IDs belonging to the application have been stopped.
*
* @param appId Application package identifier
* @param timeout The count of milliseconds to wait until the app is terminated.
* 500ms by default.
* @returns `true` if the application was successfully terminated, `false` if it was not running.
* @throws {Error} If the application is still running after the timeout period.
*/
export async function mobileTerminateApp(
this: AndroidDriver,
appId: string,
timeout?: number | string,
): Promise<boolean> {
return await this.terminateApp(appId, {
timeout,
});
}
/**
* Installs the specified application on the device.
*
* The application file will be configured and validated before installation.
* Supported file formats are: `.apk`, `.apks`.
*
* @param appPath The path to the application file to install.
* Can be a local file path or a URL.
* @param opts Optional installation options. See {@link InstallOptions} for available options.
*/
export async function installApp(
this: AndroidDriver,
appPath: string,
opts: Omit<InstallOptions, 'appId'>,
): Promise<void> {
const localPath = await this.helpers.configureApp(appPath, [...APP_EXTENSIONS]);
await this.adb.install(localPath, opts);
}
/**
* @param appPath
* @param checkVersion
* @param timeout The count of milliseconds to wait until the app is installed.
* 20000ms by default.
* @param allowTestPackages Set to true in order to allow test packages installation.
* `false` by default.
* @param useSdcard Set to true to install the app on sdcard instead of the device memory.
* `false` by default.
* @param grantPermissions Set to true in order to grant all the
* permissions requested in the application's manifest automatically after the installation is completed
* under Android 6+. `false` by default.
* @param replace Set it to false if you don't want the application to be upgraded/reinstalled
* if it is already present on the device. `true` by default.
* @param noIncremental Forcefully disables incremental installs if set to `true`.
* Read https://developer.android.com/preview/features#incremental for more details.
* `false` by default.
*/
export async function mobileInstallApp(
this: AndroidDriver,
appPath: string,
checkVersion?: boolean,
timeout?: number,
allowTestPackages?: boolean,
useSdcard?: boolean,
grantPermissions?: boolean,
replace?: boolean,
noIncremental?: boolean,
): Promise<void> {
const opts: Omit<InstallOptions, 'appId'> = {
timeout,
allowTestPackages,
useSdcard,
grantPermissions,
replace,
noIncremental,
};
if (checkVersion) {
const localPath = await this.helpers.configureApp(appPath, [...APP_EXTENSIONS]);
await this.adb.installOrUpgrade(localPath, null, {
...opts,
enforceCurrentBuild: false,
});
return;
}
return await this.installApp(appPath, opts);
}
/**
* Clears the application data and cache for the specified application.
*
* This is equivalent to running `adb shell pm clear <appId>`.
* All user data, cache, and settings for the application will be removed.
*
* @param appId Application package identifier
*/
export async function mobileClearApp(this: AndroidDriver, appId: string): Promise<void> {
await this.adb.clear(appId);
}
/**
* Retrieves the name of the currently focused activity.
*
* @returns The fully qualified name of the current activity (e.g., 'com.example.app.MainActivity').
*/
export async function getCurrentActivity(this: AndroidDriver): Promise<string> {
return (await this.adb.getFocusedPackageAndActivity()).appActivity as string;
}
/**
* Retrieves the package name of the currently focused application.
*
* @returns The package identifier of the current application (e.g., 'com.example.app').
*/
export async function getCurrentPackage(this: AndroidDriver): Promise<string> {
return (await this.adb.getFocusedPackageAndActivity()).appPackage as string;
}
/**
* Puts the application in the background for the specified duration.
*
* If a negative value is provided, the app will be sent to background and not restored.
* Otherwise, the app will be restored to the foreground after the specified duration.
*
* @param seconds The number of seconds to keep the app in the background.
* A negative value means to not restore the app after putting it to background.
* @returns `true` if the app was successfully restored, or the result of `startApp` if restoration was attempted.
*/
export async function background(
this: AndroidDriver,
seconds: number,
): Promise<string | true> {
if (seconds < 0) {
// if user passes in a negative seconds value, interpret that as the instruction
// to not bring the app back at all
await this.adb.goToHome();
return true;
}
const {appPackage, appActivity} = await this.adb.getFocusedPackageAndActivity();
await this.adb.goToHome();
// people can wait for a long time, so to be safe let's use the longSleep function and log
// progress periodically.
const sleepMs = seconds * 1000;
const thresholdMs = 30 * 1000; // use the spin-wait for anything over this threshold
// for our spin interval, use 1% of the total wait time, but nothing bigger than 30s
const intervalMs = _.min([30 * 1000, parseInt(String(sleepMs / 100), 10)]) || 1000;
const progressCb = ({elapsedMs, progress}: {elapsedMs: number; progress: number}) => {
const waitSecs = (elapsedMs / 1000).toFixed(0);
const progressPct = (progress * 100).toFixed(2);
this.log.debug(`Waited ${waitSecs}s so far (${progressPct}%)`);
};
await longSleep(sleepMs, {thresholdMs, intervalMs, progressCb});
let args: StartAppOptions;
if (this._cachedActivityArgs?.[`${appPackage}/${appActivity}`]) {
// the activity was started with `startActivity`, so use those args to restart
args = this._cachedActivityArgs[`${appPackage}/${appActivity}`];
} else {
try {
this.log.debug(`Activating app '${appPackage}' in order to restore it`);
await this.adb.activateApp(appPackage as string);
return true;
} catch {}
args =
(appPackage === this.opts.appPackage && appActivity === this.opts.appActivity) ||
(appPackage === this.opts.appWaitPackage &&
(this.opts.appWaitActivity || '').split(',').includes(String(appActivity)))
? {
// the activity is the original session activity, so use the original args
pkg: this.opts.appPackage as string,
activity: this.opts.appActivity ?? undefined,
action: this.opts.intentAction,
category: this.opts.intentCategory,
flags: this.opts.intentFlags,
waitPkg: this.opts.appWaitPackage ?? undefined,
waitActivity: this.opts.appWaitActivity ?? undefined,
waitForLaunch: this.opts.appWaitForLaunch,
waitDuration: this.opts.appWaitDuration,
optionalIntentArguments: this.opts.optionalIntentArguments,
stopApp: false,
user: this.opts.userProfile,
}
: {
// the activity was started some other way, so use defaults
pkg: appPackage as string,
activity: appActivity ?? undefined,
waitPkg: appPackage ?? undefined,
waitActivity: appActivity ?? undefined,
stopApp: false,
};
}
args = _.pickBy(args, (value) => !_.isUndefined(value)) as StartAppOptions;
this.log.debug(`Bringing application back to foreground with arguments: ${JSON.stringify(args)}`);
return await this.adb.startApp(args);
}
/**
* Puts the app to background and waits the given number of seconds then restores the app
* if necessary. The call is blocking.
*
* @param seconds The amount of seconds to wait between putting the app to background and restoring it.
* Any negative value means to not restore the app after putting it to background.
*/
export async function mobileBackgroundApp(
this: AndroidDriver,
seconds: number = -1,
): Promise<void> {
await this.background(seconds);
}
/**
* Resets the Application Under Test (AUT).
*
* The reset behavior depends on the driver options:
* - If `fastReset` is enabled: Stops the app and clears its data
* - If `fullReset` is enabled: Uninstalls and reinstalls the app
* - If neither is enabled: Only stops the app
*
* @param opts Optional driver options. If not provided, uses the current session options.
* @throws {Error} If `appPackage` is not specified or if the app cannot be reset.
*/
export async function resetAUT(
this: AndroidDriver,
opts: AndroidDriverOpts | null = null,
): Promise<void> {
const {
app,
appPackage,
fastReset,
fullReset,
androidInstallTimeout = PACKAGE_INSTALL_TIMEOUT_MS,
autoGrantPermissions,
allowTestPackages,
} = opts ?? this.opts;
if (!appPackage) {
throw new Error("'appPackage' option is required");
}
const isInstalled = await this.adb.isAppInstalled(appPackage);
if (isInstalled) {
try {
await this.adb.forceStop(appPackage);
} catch {}
// fullReset has priority over fastReset
if (!fullReset && fastReset) {
const output = await this.adb.clear(appPackage);
if (_.isString(output) && output.toLowerCase().includes('failed')) {
throw new Error(
`Cannot clear the application data of '${appPackage}'. Original error: ${output}`,
);
}
// executing `shell pm clear` resets previously assigned application permissions as well
if (autoGrantPermissions) {
try {
await this.adb.grantAllPermissions(appPackage);
} catch (error) {
const err = error as Error;
this.log.error(`Unable to grant permissions requested. Original error: ${err.message}`);
}
}
this.log.debug(
`Performed fast reset on the installed '${appPackage}' application (stop and clear)`,
);
return;
}
}
if (!app) {
throw new Error(
`Either provide 'app' option to install '${appPackage}' or ` +
`consider setting 'noReset' to 'true' if '${appPackage}' is supposed to be preinstalled.`,
);
}
this.log.debug(`Running full reset on '${appPackage}' (reinstall)`);
if (isInstalled) {
await this.adb.uninstallApk(appPackage);
}
await this.adb.install(app, {
grantPermissions: autoGrantPermissions,
timeout: androidInstallTimeout,
allowTestPackages,
});
}
/**
* Installs the Application Under Test (AUT) on the device.
*
* If `fullReset` is enabled, this will perform a full reset (uninstall and reinstall).
* Otherwise, it will install or upgrade the app if needed. If `fastReset` is enabled
* and the app was already installed, it will perform a fast reset after installation.
*
* @param opts Optional driver options. If not provided, uses the current session options.
* @throws {Error} If `app` or `appPackage` options are not specified.
*/
export async function installAUT(
this: AndroidDriver,
opts: AndroidDriverOpts | null = null,
): Promise<void> {
const {
app,
appPackage,
fastReset,
fullReset,
androidInstallTimeout = PACKAGE_INSTALL_TIMEOUT_MS,
autoGrantPermissions,
allowTestPackages,
enforceAppInstall,
} = opts ?? this.opts;
if (!app || !appPackage) {
throw new Error("'app' and 'appPackage' options are required");
}
if (fullReset) {
await this.resetAUT(opts);
return;
}
const {appState, wasUninstalled} = await this.adb.installOrUpgrade(app, appPackage, {
grantPermissions: autoGrantPermissions,
timeout: androidInstallTimeout,
allowTestPackages,
enforceCurrentBuild: enforceAppInstall,
});
// There is no need to reset the newly installed app
const isInstalledOverExistingApp =
!wasUninstalled && appState !== this.adb.APP_INSTALL_STATE.NOT_INSTALLED;
if (fastReset && isInstalledOverExistingApp) {
this.log.info(`Performing fast reset on '${appPackage}'`);
await this.resetAUT(opts);
}
}
/**
* Installs multiple additional APK files on the device.
*
* All APKs are installed asynchronously in parallel. This is useful for installing
* dependencies or additional applications required for testing.
*
* @param otherApps An array of paths to APK files to install.
* Each path can be a local file path or a URL.
* @param opts Optional driver options. If not provided, uses the current session options.
*/
export async function installOtherApks(
this: AndroidDriver,
otherApps: string[],
opts: AndroidDriverOpts | null = null,
): Promise<void> {
const {
androidInstallTimeout = PACKAGE_INSTALL_TIMEOUT_MS,
autoGrantPermissions,
allowTestPackages,
} = opts ?? this.opts;
// Install all of the APK's asynchronously
await B.all(
otherApps.map((otherApp) => {
this.log.debug(`Installing app: ${otherApp}`);
return this.adb.installOrUpgrade(otherApp, undefined, {
grantPermissions: autoGrantPermissions,
timeout: androidInstallTimeout,
allowTestPackages,
});
}),
);
}
/**
* Uninstalls the specified packages from the device.
*
* If `appPackages` contains `'*'`, all third-party packages will be uninstalled
* (excluding packages in `filterPackages`).
*
* @param appPackages An array of package names to uninstall, or `['*']` to uninstall all third-party packages.
* @param filterPackages An array of package names to exclude from uninstallation.
* Only used when `appPackages` contains `'*'`.
*/
export async function uninstallOtherPackages(
this: AndroidDriver,
appPackages: string[],
filterPackages: string[] = [],
): Promise<void> {
if (appPackages.includes('*')) {
this.log.debug('Uninstall third party packages');
appPackages = await getThirdPartyPackages.bind(this)(filterPackages);
}
this.log.debug(`Uninstalling packages: ${appPackages}`);
await B.all(appPackages.map((appPackage) => this.adb.uninstallApk(appPackage)));
}
/**
* Retrieves a list of all third-party packages installed on the device.
*
* Third-party packages are those that are not part of the system installation.
* This is equivalent to running `adb shell pm list packages -3`.
*
* @param filterPackages An array of package names to exclude from the results.
* @returns An array of third-party package names, excluding those in `filterPackages`.
* Returns an empty array if the command fails.
*/
export async function getThirdPartyPackages(
this: AndroidDriver,
filterPackages: string[] = [],
): Promise<string[]> {
try {
const packagesString = await this.adb.shell(['pm', 'list', 'packages', '-3']);
const appPackagesArray = packagesString
.trim()
.replace(/package:/g, '')
.split(EOL);
this.log.debug(`'${appPackagesArray}' filtered with '${filterPackages}'`);
return _.difference(appPackagesArray, filterPackages);
} catch (err) {
const error = err as Error;
this.log.warn(`Unable to get packages with 'adb shell pm list packages -3': ${error.message}`);
return [];
}
}