appium-adb
Version:
Android Debug Bridge interface
1,460 lines (1,381 loc) • 47.9 kB
text/typescript
import path from 'node:path';
import {log} from '../logger';
import B from 'bluebird';
import {system, fs, util, tempDir, timing} from '@appium/support';
import {DEFAULT_ADB_EXEC_TIMEOUT, getSdkRootFromEnv} from '../helpers';
import {exec, SubProcess} from 'teen_process';
import type {ExecError, TeenProcessExecResult} from 'teen_process';
import {retry, retryInterval, waitForCondition} from 'asyncbox';
import _ from 'lodash';
import * as semver from 'semver';
import type {ADB} from '../adb';
import type {
ConnectedDevicesOptions,
Device,
AvdLaunchOptions,
Version,
RootResult,
ShellExecOptions,
SpecialAdbExecOptions,
TFullOutputOption,
ExecResult,
} from './types';
const DEFAULT_ADB_REBOOT_RETRIES = 90;
const LINKER_WARNING_REGEXP = /^WARNING: linker.+$/m;
const ADB_RETRY_ERROR_PATTERNS = [
/protocol fault \(no status\)/i,
/error: device ('.+' )?not found/i,
/error: device still connecting/i,
] as const;
const BINARY_VERSION_PATTERN = /^Version ([\d.]+)-(\d+)/m;
const BRIDGE_VERSION_PATTERN = /^Android Debug Bridge version ([\d.]+)/m;
const CERTS_ROOT = '/system/etc/security/cacerts';
const SDK_BINARY_ROOTS: (string | string[])[] = [
'platform-tools',
'emulator',
['cmdline-tools', 'latest', 'bin'],
'tools',
['tools', 'bin'],
'.', // Allow custom sdkRoot to specify full folder path
];
const MIN_DELAY_ADB_API_LEVEL = 28;
const REQUIRED_SERVICES = ['activity', 'package', 'window'] as const;
const MAX_SHELL_BUFFER_LENGTH = 1000;
// Private methods (defined early as they're used by public methods)
/**
* Retrieve full binary name for the current operating system.
*
* @param binaryName - The name of the binary
* @returns The binary name with appropriate extension for the current OS
*/
function _getBinaryNameForOS(binaryName: string): string {
if (!system.isWindows()) {
return binaryName;
}
if (['android', 'apksigner', 'apkanalyzer'].includes(binaryName)) {
return `${binaryName}.bat`;
}
if (!path.extname(binaryName)) {
return `${binaryName}.exe`;
}
return binaryName;
}
/**
* Returns the Android binaries locations
*
* @param sdkRoot - The Android SDK root directory path
* @param fullBinaryName - The full name of the binary (with extension)
* @returns Array of possible binary location paths
*/
function getSdkBinaryLocationCandidates(sdkRoot: string, fullBinaryName: string): string[] {
return SDK_BINARY_ROOTS.map((x) =>
path.resolve(sdkRoot, ...(_.isArray(x) ? x : [x]), fullBinaryName),
);
}
/**
* Get the path to the openssl binary for the current operating system
*
* @returns The full path to the openssl binary
* @throws {Error} If openssl is not found in PATH
*/
async function getOpenSslForOs(): Promise<string> {
const binaryName = `openssl${system.isWindows() ? '.exe' : ''}`;
try {
return await fs.which(binaryName);
} catch {
throw new Error('The openssl tool must be installed on the system and available on the path');
}
}
// Public methods
/**
* Retrieve full path to the given binary.
*
* @param binaryName - The name of the binary
* @returns The full path to the binary
*/
export async function getSdkBinaryPath(this: ADB, binaryName: string): Promise<string> {
return await this.getBinaryFromSdkRoot(binaryName);
}
export const getBinaryNameForOS = _.memoize(_getBinaryNameForOS);
/**
* Retrieve full path to the given binary and caches it into `binaries`
* property of the current ADB instance.
*
* @param binaryName - The name of the binary
* @returns The full path to the binary
* @throws {Error} If SDK root is not set or binary cannot be found
*/
export async function getBinaryFromSdkRoot(this: ADB, binaryName: string): Promise<string> {
if (this.binaries?.[binaryName]) {
return this.binaries[binaryName];
}
const fullBinaryName = this.getBinaryNameForOS(binaryName);
if (!this.sdkRoot) {
throw new Error('SDK root is not set');
}
const binaryLocs = getSdkBinaryLocationCandidates(this.sdkRoot, fullBinaryName);
// get subpaths for currently installed build tool directories
let buildToolsDirs = await getBuildToolsDirs(this.sdkRoot);
if (this.buildToolsVersion) {
buildToolsDirs = buildToolsDirs.filter((x) => path.basename(x) === this.buildToolsVersion);
if (_.isEmpty(buildToolsDirs)) {
log.info(`Found no build tools whose version matches to '${this.buildToolsVersion}'`);
} else {
log.info(`Using build tools at '${buildToolsDirs}'`);
}
}
binaryLocs.push(
..._.flatten(
buildToolsDirs.map((dir) => [
path.resolve(dir, fullBinaryName),
path.resolve(dir, 'lib', fullBinaryName),
]),
),
);
let binaryLoc: string | null = null;
for (const loc of binaryLocs) {
if (await fs.exists(loc)) {
binaryLoc = loc;
break;
}
}
if (_.isNull(binaryLoc)) {
throw new Error(
`Could not find '${fullBinaryName}' in ${JSON.stringify(binaryLocs)}. ` +
`Do you have Android Build Tools ${this.buildToolsVersion ? `v ${this.buildToolsVersion} ` : ''}` +
`installed at '${this.sdkRoot}'?`,
);
}
log.info(`Using '${fullBinaryName}' from '${binaryLoc}'`);
if (!this.binaries) {
this.binaries = {};
}
this.binaries[binaryName] = binaryLoc;
return binaryLoc;
}
/**
* Retrieve full path to the given binary.
* This method does not have cache.
*
* @param binaryName - The name of the binary
* @returns The full path to the binary
* @throws {Error} If binary cannot be found in the Android SDK
*/
export async function getAndroidBinaryPath(binaryName: string): Promise<string> {
const fullBinaryName = getBinaryNameForOS(binaryName);
const sdkRoot = getSdkRootFromEnv();
const binaryLocs = getSdkBinaryLocationCandidates(sdkRoot ?? '', fullBinaryName);
for (const loc of binaryLocs) {
if (await fs.exists(loc)) {
return loc;
}
}
throw new Error(
`Could not find '${fullBinaryName}' in ${JSON.stringify(binaryLocs)}. ` +
`Do you have Android Build Tools installed at '${sdkRoot}'?`,
);
}
/**
* Retrieve full path to a binary file using the standard system lookup tool.
*
* @param binaryName - The name of the binary
* @returns The full path to the binary
* @throws {Error} If binary cannot be found in PATH
*/
export async function getBinaryFromPath(this: ADB, binaryName: string): Promise<string> {
if (this.binaries?.[binaryName]) {
return this.binaries[binaryName];
}
const fullBinaryName = this.getBinaryNameForOS(binaryName);
try {
const binaryLoc = await fs.which(fullBinaryName);
log.info(`Using '${fullBinaryName}' from '${binaryLoc}'`);
if (!this.binaries) {
this.binaries = {};
}
this.binaries[binaryName] = binaryLoc;
return binaryLoc;
} catch {
throw new Error(
`Could not find '${fullBinaryName}' in PATH. Please set the ANDROID_HOME ` +
`or ANDROID_SDK_ROOT environment variables to the correct Android SDK root directory path.`,
);
}
}
/**
* Retrieve the list of devices visible to adb.
*
* @param opts - Options for device retrieval
* @returns Array of connected devices
* @throws {Error} If adb devices command fails or returns unexpected output
*/
export async function getConnectedDevices(
this: ADB,
opts: ConnectedDevicesOptions = {},
): Promise<Device[]> {
log.debug('Getting connected devices');
const args = [...this.executable.defaultArgs, 'devices'];
if (opts.verbose) {
args.push('-l');
}
let stdout: string;
try {
({stdout} = await exec(this.executable.path, args));
} catch (e: unknown) {
const error = e as Error;
throw new Error(`Error while getting connected devices. Original error: ${error.message}`);
}
const listHeader = 'List of devices';
// expecting adb devices to return output as
// List of devices attached
// emulator-5554 device
const startingIndex = stdout.indexOf(listHeader);
if (startingIndex < 0) {
throw new Error(`Unexpected output while trying to get devices: ${stdout}`);
}
// slicing output we care about
stdout = stdout.slice(startingIndex);
const excludedLines = [listHeader, 'adb server', '* daemon'];
if (!this.allowOfflineDevices) {
excludedLines.push('offline');
}
const devices = stdout
.split('\n')
.map(_.trim)
.filter((line) => line && !excludedLines.some((x) => line.includes(x)))
.map((line) => {
// state is "device", afaic
const [udid, state, ...description] = line.split(/\s+/);
const device: Device & Record<string, string> = {udid, state} as Device &
Record<string, string>;
if (opts.verbose) {
for (const entry of description) {
if (entry.includes(':')) {
// each entry looks like key:value
const [key, value] = entry.split(':');
device[key] = value;
}
}
}
return device;
});
if (_.isEmpty(devices)) {
log.debug('No connected devices have been detected');
} else {
log.debug(`Connected devices: ${JSON.stringify(devices)}`);
}
return devices;
}
/**
* Retrieve the list of devices visible to adb within the given timeout.
*
* @param timeoutMs - Maximum time to wait for devices (default: 20000ms)
* @returns Array of connected devices
* @throws {Error} If no devices are found within the timeout period
*/
export async function getDevicesWithRetry(this: ADB, timeoutMs: number = 20000): Promise<Device[]> {
log.debug('Trying to find connected Android devices');
try {
let devices: Device[] = [];
await waitForCondition(
async () => {
try {
devices = await this.getConnectedDevices();
if (devices.length) {
return true;
}
log.debug('Could not find online devices');
} catch (err: unknown) {
const error = err as Error;
log.debug(error.stack);
log.warn(
`Got an unexpected error while fetching connected devices list: ${error.message}`,
);
}
try {
await this.reconnect();
} catch {
await this.restartAdb();
}
return false;
},
{
waitMs: timeoutMs,
intervalMs: 200,
},
);
return devices;
} catch (e: unknown) {
const error = e as Error;
if (/Condition unmet/.test(error.message)) {
throw new Error(`Could not find a connected Android device in ${timeoutMs}ms`);
} else {
throw e;
}
}
}
/**
* Kick current connection from host/device side and make it reconnect
*
* @param target - The target to reconnect (default: 'offline')
* @throws {Error} If reconnect command fails
*/
export async function reconnect(this: ADB, target: string | null = 'offline'): Promise<void> {
log.debug(`Reconnecting adb (target ${target})`);
const args = ['reconnect'];
if (target) {
args.push(target);
}
try {
await this.adbExec(args);
} catch (e: unknown) {
const error = e as ExecError;
throw new Error(`Cannot reconnect adb. Original error: ${error.stderr || error.message}`);
}
}
/**
* Restart adb server, unless _this.suppressKillServer_ property is true.
*/
export async function restartAdb(this: ADB): Promise<void> {
if (this.suppressKillServer) {
log.debug(`Not restarting abd since 'suppressKillServer' is on`);
return;
}
log.debug('Restarting adb');
try {
await this.killServer();
await this.adbExec(['start-server']);
} catch {
log.error(`Error killing ADB server, going to see if it's online anyway`);
}
}
/**
* Kill adb server.
*/
export async function killServer(this: ADB): Promise<void> {
log.debug(`Killing adb server on port '${this.adbPort}'`);
await this.adbExec(['kill-server'], {
exclusive: true,
});
}
/**
* Reset Telnet authentication token.
* @see {@link http://tools.android.com/recent/emulator2516releasenotes} for more details.
*
* @returns True if token was reset successfully, false otherwise
*/
export const resetTelnetAuthToken = _.memoize(
async function resetTelnetAuthToken(): Promise<boolean> {
// The methods is used to remove telnet auth token
//
const homeFolderPath = process.env[process.platform === 'win32' ? 'USERPROFILE' : 'HOME'];
if (!homeFolderPath) {
log.warn(
`Cannot find the path to user home folder. Ignoring resetting of emulator's telnet authentication token`,
);
return false;
}
const dstPath = path.resolve(homeFolderPath, '.emulator_console_auth_token');
log.debug(
`Overriding ${dstPath} with an empty string to avoid telnet authentication for emulator commands`,
);
try {
await fs.writeFile(dstPath, '');
} catch (e: unknown) {
const error = e as Error;
log.warn(
`Error ${error.message} while resetting the content of ${dstPath}. Ignoring resetting of emulator's telnet authentication token`,
);
return false;
}
return true;
},
);
/**
* Execute the given emulator command using _adb emu_ tool.
*
* @param cmd - Array of command arguments
* @throws {Error} If emulator is not connected or command execution fails
*/
export async function adbExecEmu(this: ADB, cmd: string[]): Promise<void> {
await this.verifyEmulatorConnected();
await this.resetTelnetAuthToken();
await this.adbExec(['emu', ...cmd]);
}
let isExecLocked = false;
export const EXEC_OUTPUT_FORMAT = {
STDOUT: 'stdout',
FULL: 'full',
} as const;
/**
* Execute the given adb command.
*
* @param cmd - Command string or array of command arguments
* @param opts - Execution options
* @returns Command output (string or ExecResult depending on outputFormat)
* @throws {Error} If command execution fails or timeout is exceeded
*/
export async function adbExec<
TExecOpts extends ShellExecOptions & SpecialAdbExecOptions = ShellExecOptions &
SpecialAdbExecOptions,
>(
this: ADB,
cmd: string | string[],
opts?: TExecOpts,
): Promise<TExecOpts extends TFullOutputOption ? ExecResult : string> {
if (!cmd) {
throw new Error('You need to pass in a command to adbExec()');
}
const optsCopy = _.cloneDeep(opts ?? {}) as TExecOpts;
// setting default timeout for each command to prevent infinite wait.
optsCopy.timeout = optsCopy.timeout || this.adbExecTimeout || DEFAULT_ADB_EXEC_TIMEOUT;
optsCopy.timeoutCapName = optsCopy.timeoutCapName || 'adbExecTimeout'; // For error message
const {outputFormat = this.EXEC_OUTPUT_FORMAT.STDOUT} = optsCopy;
cmd = _.isArray(cmd) ? cmd : [cmd];
let adbRetried = false;
const execFunc = async (): Promise<string | ExecResult> => {
try {
const args = [...this.executable.defaultArgs, ...cmd];
log.debug(
`Running '${this.executable.path} ` +
(args.find((arg) => /\s+/.test(arg)) ? util.quote(args) : args.join(' ')) +
`'`,
);
const {stdout: rawStdout, stderr} = (await exec(
this.executable.path,
args,
optsCopy,
)) as TeenProcessExecResult<string>;
// sometimes ADB prints out weird stdout warnings that we don't want
// to include in any of the response data, so let's strip it out
const stdout = rawStdout.replace(LINKER_WARNING_REGEXP, '').trim();
return outputFormat === this.EXEC_OUTPUT_FORMAT.FULL ? {stdout, stderr} : stdout;
} catch (e: unknown) {
const error = e as ExecError;
const errText = `${error.message}, ${error.stdout}, ${error.stderr}`;
if (ADB_RETRY_ERROR_PATTERNS.some((p) => p.test(errText))) {
log.info(`Error sending command, reconnecting device and retrying: ${cmd}`);
await this.getDevicesWithRetry();
// try again one time
if (!adbRetried) {
adbRetried = true;
return await execFunc();
}
}
if (error.code === 0 && error.stdout) {
return error.stdout.replace(LINKER_WARNING_REGEXP, '').trim();
}
if (_.isNull(error.code)) {
error.message =
`Error executing adbExec. Original error: '${error.message}'. ` +
`Try to increase the ${optsCopy.timeout}ms adb execution timeout ` +
`represented by '${optsCopy.timeoutCapName}' capability`;
} else {
error.message =
`Error executing adbExec. Original error: '${error.message}'; ` +
`Command output: ${error.stderr || error.stdout || '<empty>'}`;
}
throw error;
}
};
if (isExecLocked) {
log.debug('Waiting until the other exclusive ADB command is completed');
await waitForCondition(() => !isExecLocked, {
waitMs: Number.MAX_SAFE_INTEGER,
intervalMs: 10,
});
log.debug('Continuing with the current ADB command');
}
if (optsCopy.exclusive) {
isExecLocked = true;
}
try {
return (await execFunc()) as TExecOpts extends TFullOutputOption ? ExecResult : string;
} finally {
if (optsCopy.exclusive) {
isExecLocked = false;
}
}
}
/**
* Execute the given command using _adb shell_ prefix.
*
* @param cmd - Command string or array of command arguments
* @param opts - Execution options
* @returns Command output (string or ExecResult depending on outputFormat)
* @throws {Error} If command execution fails
*/
export async function shell<TShellExecOpts extends ShellExecOptions = ShellExecOptions>(
this: ADB,
cmd: string | string[],
opts?: TShellExecOpts,
): Promise<TShellExecOpts extends TFullOutputOption ? ExecResult : string> {
const {privileged} = opts ?? ({} as TShellExecOpts);
const cmdArr = _.isArray(cmd) ? cmd : [cmd];
const fullCmd: string[] = ['shell'];
if (privileged) {
log.info(`'adb shell ${util.quote(cmdArr)}' requires root access`);
if (await this.isRoot()) {
log.info('The device already had root access');
fullCmd.push(...cmdArr);
} else {
fullCmd.push('su', 'root', util.quote(cmdArr));
}
} else {
fullCmd.push(...cmdArr);
}
return await this.adbExec(fullCmd, opts);
}
/**
* Create a new ADB subprocess with the given arguments
*
* @param args - Array of command arguments (default: empty array)
* @returns A SubProcess instance
*/
export function createSubProcess(this: ADB, args: string[] = []): SubProcess {
// add the default arguments
const finalArgs = [...this.executable.defaultArgs, ...args];
log.debug(`Creating ADB subprocess with args: ${JSON.stringify(finalArgs)}`);
return new SubProcess(this.getAdbPath(), finalArgs);
}
/**
* Retrieve the current adb port.
* @todo can probably deprecate this now that the logic is just to read this.adbPort
* @deprecated Use this.adbPort instead
*
* @returns The ADB server port number
*/
export function getAdbServerPort(this: ADB): number {
return this.adbPort as number;
}
/**
* Retrieve the current emulator port from _adb devices_ output.
*
* @returns The emulator port number
* @throws {Error} If no devices are connected or emulator port cannot be found
*/
export async function getEmulatorPort(this: ADB): Promise<number> {
log.debug('Getting running emulator port');
if (!_.isNil(this.emulatorPort)) {
return this.emulatorPort;
}
try {
const devices = await this.getConnectedDevices();
const port = this.getPortFromEmulatorString(devices[0].udid);
if (port) {
return port;
} else {
throw new Error(`Emulator port not found`);
}
} catch (e: unknown) {
const error = e as Error;
throw new Error(`No devices connected. Original error: ${error.message}`);
}
}
/**
* Retrieve the current emulator port by parsing emulator name string.
*
* @param emStr - The emulator string (e.g., 'emulator-5554')
* @returns The port number if found, false otherwise
*/
export function getPortFromEmulatorString(this: ADB, emStr: string): number | false {
const portPattern = /emulator-(\d+)/;
const match = portPattern.exec(emStr);
return match ? parseInt(match[1], 10) : false;
}
/**
* Retrieve the list of currently connected emulators.
*
* @param opts - Options for device retrieval
* @returns Array of connected emulator devices
* @throws {Error} If error occurs while getting emulators
*/
export async function getConnectedEmulators(
this: ADB,
opts: ConnectedDevicesOptions = {},
): Promise<Device[]> {
log.debug('Getting connected emulators');
try {
const devices = await this.getConnectedDevices(opts);
const emulators: Device[] = [];
for (const device of devices) {
const port = this.getPortFromEmulatorString(device.udid);
if (port) {
device.port = port;
emulators.push(device);
}
}
log.debug(`${util.pluralize('emulator', emulators.length, true)} connected`);
return emulators;
} catch (e: unknown) {
const error = e as Error;
throw new Error(`Error getting emulators. Original error: ${error.message}`);
}
}
/**
* Set _emulatorPort_ property of the current class.
*
* @param emPort - The emulator port number
*/
export function setEmulatorPort(this: ADB, emPort: number): void {
this.emulatorPort = emPort;
}
/**
* Set the identifier of the current device (_this.curDeviceId_).
*
* @param deviceId - The device identifier
*/
export function setDeviceId(this: ADB, deviceId: string): void {
log.debug(`Setting device id to ${deviceId}`);
this.curDeviceId = deviceId;
const argsHasDevice = this.executable.defaultArgs.indexOf('-s');
if (argsHasDevice !== -1) {
// remove the old device id from the arguments
this.executable.defaultArgs.splice(argsHasDevice, 2);
}
this.executable.defaultArgs.push('-s', deviceId);
}
/**
* Set the current device object.
*
* @param deviceObj - The device object containing udid and other properties
*/
export function setDevice(this: ADB, deviceObj: Device): void {
const deviceId = deviceObj.udid;
const emPort = this.getPortFromEmulatorString(deviceId);
if (_.isNumber(emPort)) {
this.setEmulatorPort(emPort);
}
this.setDeviceId(deviceId);
}
/**
* Get the object for the currently running emulator.
* !!! This method has a side effect - it implicitly changes the
* `deviceId` (only if AVD with a matching name is found)
* and `emulatorPort` instance properties.
*
* @param avdName - The name of the AVD to find
* @returns The device object if found, null otherwise
* @throws {Error} If error occurs while getting AVD
*/
export async function getRunningAVD(this: ADB, avdName: string): Promise<Device | null> {
log.debug(`Trying to find '${avdName}' emulator`);
try {
const emulators = await this.getConnectedEmulators();
for (const emulator of emulators) {
if (_.isNumber(emulator.port)) {
this.setEmulatorPort(emulator.port);
}
const runningAVDName = await this.execEmuConsoleCommand(['avd', 'name'], {
port: emulator.port,
execTimeout: 5000,
connTimeout: 1000,
});
if (_.toLower(avdName) === _.toLower(runningAVDName.trim())) {
log.debug(`Found emulator '${avdName}' on port ${emulator.port}`);
this.setDeviceId(emulator.udid);
return emulator;
}
}
log.debug(`Emulator '${avdName}' not running`);
return null;
} catch (e: unknown) {
const error = e as Error;
throw new Error(`Error getting AVD. Original error: ${error.message}`);
}
}
/**
* Get the object for the currently running emulator with retry.
*
* @param avdName - The name of the AVD to find
* @param timeoutMs - Maximum time to wait (default: 20000ms)
* @returns The device object if found, null otherwise
* @throws {Error} If error occurs while getting AVD with retry
*/
export async function getRunningAVDWithRetry(
this: ADB,
avdName: string,
timeoutMs: number = 20000,
): Promise<Device | null> {
try {
return (await waitForCondition(
async () => {
try {
return await this.getRunningAVD(avdName.replace('@', ''));
} catch (e: unknown) {
const error = e as Error;
log.debug(error.message);
return false;
}
},
{
waitMs: timeoutMs,
intervalMs: 1000,
},
)) as Device | null;
} catch (e: unknown) {
const error = e as Error;
throw new Error(`Error getting AVD with retry. Original error: ${error.message}`);
}
}
/**
* Shutdown all running emulators by killing their processes.
*
* @throws {Error} If error occurs while killing emulators
*/
export async function killAllEmulators(this: ADB): Promise<void> {
let cmd: string;
let args: string[];
if (system.isWindows()) {
cmd = 'TASKKILL';
args = ['TASKKILL', '/IM', 'emulator.exe'];
} else {
cmd = '/usr/bin/killall';
args = ['-m', 'emulator*'];
}
try {
await exec(cmd, args);
} catch (e: unknown) {
const error = e as Error;
throw new Error(`Error killing emulators. Original error: ${error.message}`);
}
}
/**
* Kill emulator with the given name. No error
* is thrown if given avd does not exist/is not running.
*
* @param avdName - The name of the AVD to kill (null to kill current AVD)
* @param timeout - Maximum time to wait for emulator to be killed (default: 60000ms)
* @returns True if emulator was killed, false if it was not running
* @throws {Error} If emulator is still running after timeout
*/
export async function killEmulator(
this: ADB,
avdName: string | null = null,
timeout: number = 60000,
): Promise<boolean> {
if (util.hasValue(avdName)) {
log.debug(`Killing avd '${avdName}'`);
const device = await this.getRunningAVD(avdName);
if (!device) {
log.info(`No avd with name '${avdName}' running. Skipping kill step.`);
return false;
}
} else {
// killing the current avd
log.debug(`Killing avd with id '${this.curDeviceId}'`);
if (!(await this.isEmulatorConnected())) {
log.debug(`Emulator with id '${this.curDeviceId}' not connected. Skipping kill step`);
return false;
}
}
await this.adbExec(['emu', 'kill']);
log.debug(
`Waiting up to ${timeout}ms until the emulator '${avdName ? avdName : this.curDeviceId}' is killed`,
);
try {
await waitForCondition(
async () => {
try {
return util.hasValue(avdName)
? !(await this.getRunningAVD(avdName as string))
: !(await this.isEmulatorConnected());
} catch {
return false;
}
},
{
waitMs: timeout,
intervalMs: 2000,
},
);
} catch {
throw new Error(
`The emulator '${avdName ? avdName : this.curDeviceId}' is still running after being killed ${timeout}ms ago`,
);
}
log.info(`Successfully killed the '${avdName ? avdName : this.curDeviceId}' emulator`);
return true;
}
/**
* Start an emulator with given parameters and wait until it is fully started.
*
* @param avdName - The name of the AVD to launch
* @param opts - Launch options
* @returns The SubProcess instance for the launched emulator
* @throws {Error} If emulator fails to launch or boot
*/
export async function launchAVD(
this: ADB,
avdName: string,
opts: AvdLaunchOptions = {},
): Promise<SubProcess> {
const {
args = [],
env = {},
language,
country,
launchTimeout = 60000,
readyTimeout = 60000,
retryTimes = 1,
} = opts;
log.debug(
`Launching Emulator with AVD ${avdName}, launchTimeout ` +
`${launchTimeout}ms and readyTimeout ${readyTimeout}ms`,
);
const emulatorBinaryPath = await this.getSdkBinaryPath('emulator');
let processedAvdName = avdName;
if (processedAvdName.startsWith('@')) {
processedAvdName = processedAvdName.slice(1);
}
await this.checkAvdExist(processedAvdName);
const launchArgs: string[] = ['-avd', processedAvdName];
launchArgs.push(...toAvdLocaleArgs(language ?? null, country ?? null));
let isDelayAdbFeatureEnabled = false;
if (this.allowDelayAdb) {
const {revision} = await this.getEmuVersionInfo();
if (revision && util.compareVersions(revision, '>=', '29.0.7')) {
// https://androidstudio.googleblog.com/2019/05/emulator-2907-canary.html
try {
const {target} = await this.getEmuImageProperties(processedAvdName);
const apiMatch = /\d+/.exec(target);
// https://issuetracker.google.com/issues/142533355
if (apiMatch && parseInt(apiMatch[0], 10) >= MIN_DELAY_ADB_API_LEVEL) {
launchArgs.push('-delay-adb');
isDelayAdbFeatureEnabled = true;
} else {
throw new Error(`The actual image API version is below ${MIN_DELAY_ADB_API_LEVEL}`);
}
} catch (e: unknown) {
const error = e as Error;
log.info(
`The -delay-adb emulator startup detection feature will not be enabled. ` +
`Original error: ${error.message}`,
);
}
}
} else {
log.info('The -delay-adb emulator startup detection feature has been explicitly disabled');
}
if (!_.isEmpty(args)) {
launchArgs.push(...(_.isArray(args) ? args : util.shellParse(`${args}`)));
}
log.debug(`Running '${emulatorBinaryPath}' with args: ${util.quote(launchArgs)}`);
if (!_.isEmpty(env)) {
log.debug(`Customized emulator environment: ${JSON.stringify(env)}`);
}
const proc = new SubProcess(emulatorBinaryPath, launchArgs, {
env: {...process.env, ...env},
});
await proc.start(0);
for (const streamName of ['stderr', 'stdout']) {
proc.on(`line-${streamName}`, (line: string) => log.debug(`[AVD OUTPUT] ${line}`));
}
proc.on('die', (code: number | null, signal: string | null) => {
log.warn(
`Emulator avd ${processedAvdName} exited with code ${code}${signal ? `, signal ${signal}` : ''}`,
);
});
await retry(
retryTimes,
async () => await this.getRunningAVDWithRetry(processedAvdName, launchTimeout),
);
// At this point we have deviceId already assigned
const timer = new timing.Timer().start();
if (isDelayAdbFeatureEnabled) {
try {
await this.adbExec(['wait-for-device'], {timeout: readyTimeout});
} catch (e: unknown) {
const error = e as ExecError;
throw new Error(
`'${processedAvdName}' Emulator has failed to boot: ${error.stderr || error.message}`,
);
}
}
await this.waitForEmulatorReady(Math.trunc(readyTimeout - timer.getDuration().asMilliSeconds));
return proc;
}
/**
* Get the adb version. The result of this method is cached.
*
* @returns Version information object
* @throws {Error} If error occurs while getting adb version
*/
export const getVersion = _.memoize(async function getVersion(this: ADB): Promise<Version> {
let stdout: string;
try {
stdout = await this.adbExec('version');
} catch (e: unknown) {
const error = e as ExecError;
throw new Error(`Error getting adb version: ${error.stderr || error.message}`);
}
const result: Partial<Version> = {};
const binaryVersionMatch = BINARY_VERSION_PATTERN.exec(stdout);
if (binaryVersionMatch) {
result.binary = {
version: semver.coerce(binaryVersionMatch[1])?.version || binaryVersionMatch[1],
build: parseInt(binaryVersionMatch[2], 10),
};
}
const bridgeVersionMatch = BRIDGE_VERSION_PATTERN.exec(stdout);
if (bridgeVersionMatch) {
result.bridge = {
version: semver.coerce(bridgeVersionMatch[1])?.version || bridgeVersionMatch[1],
};
}
return result as Version;
});
/**
* Check if the current emulator is ready to accept further commands (booting completed).
*
* @param timeoutMs - Maximum time to wait (default: 20000ms)
* @throws {Error} If emulator is not ready within the timeout period
*/
export async function waitForEmulatorReady(this: ADB, timeoutMs: number = 20000): Promise<void> {
log.debug(`Waiting up to ${timeoutMs}ms for the emulator to be ready`);
const requiredServicesRe = REQUIRED_SERVICES.map((name) => new RegExp(`\\b${name}:`));
let services: string | undefined;
const timer = new timing.Timer().start();
let isFirstCheck = true;
let isBootCompleted = false;
try {
await waitForCondition(
async () => {
if (isFirstCheck) {
isFirstCheck = false;
} else {
log.debug(
`${timer.getDuration().asMilliSeconds.toFixed(0)}ms elapsed since ` +
`emulator readiness check has started`,
);
}
try {
if (!isBootCompleted) {
const [bootCompleted, bootAnimState] = await Promise.all([
this.shell(['getprop', 'sys.boot_completed']),
this.shell(['getprop', 'init.svc.bootanim']),
]);
if (bootCompleted.trim() !== '1' || !['stopped', ''].includes(bootAnimState.trim())) {
log.debug(
`Current status: sys.boot_completed=${bootCompleted.trim()}, ` +
`init.svc.bootanim=${bootAnimState.trim()}`,
);
return false;
}
isBootCompleted = true;
}
const servicesOutput = await this.shell(['service', 'list']);
services = servicesOutput;
if (
!servicesOutput ||
!requiredServicesRe.every((pattern) => pattern.test(servicesOutput))
) {
log.debug(`Running services: ${servicesOutput}`);
return false;
}
return true;
} catch (err: unknown) {
const error = err as Error;
log.debug(`Intermediate error: ${error.message}`);
return false;
}
},
{
waitMs: timeoutMs,
intervalMs: 3000,
},
);
} catch {
let suffix = '';
const servicesValue = services;
if (servicesValue) {
const missingServices = _.zip(REQUIRED_SERVICES, requiredServicesRe)
.filter(([, pattern]) => !(pattern as RegExp).test(servicesValue))
.map(([name]) => name);
suffix = ` (${missingServices} service${missingServices.length === 1 ? ' is' : 's are'} not running)`;
}
throw new Error(`Emulator is not ready within ${timeoutMs}ms${suffix}`);
}
const elapsedMs = timer.getDuration().asMilliSeconds;
// Only log if the wait took a noticeable amount of time
if (elapsedMs > 100) {
log.info(`Emulator is ready after ${elapsedMs}ms`);
}
}
/**
* Check if the current device is ready to accept further commands (booting completed).
*
* @param appDeviceReadyTimeout - Timeout in seconds (default: 30)
* @throws {Error} If device is not ready within the timeout period
*/
export async function waitForDevice(this: ADB, appDeviceReadyTimeout: number = 30): Promise<void> {
const timeoutMs = appDeviceReadyTimeout * 1000;
let lastErrorMessage: string | null = null;
try {
await waitForCondition(
async () => {
try {
await this.adbExec('wait-for-device', {timeout: Math.trunc(timeoutMs * 0.99)});
await this.ping();
return true;
} catch (e: unknown) {
const error = e as Error;
lastErrorMessage = error.message;
try {
try {
await this.reconnect();
} catch {
await this.restartAdb();
}
await this.getConnectedDevices();
} catch {
// Ignore errors during reconnection
}
return false;
}
},
{
waitMs: timeoutMs,
intervalMs: 1000,
},
);
} catch {
let suffix = '';
if (lastErrorMessage) {
suffix = ` Original error: ${lastErrorMessage}`;
}
throw new Error(`The device is not ready after ${appDeviceReadyTimeout}s.${suffix}`);
}
}
/**
* Reboot the current device and wait until it is completed.
*
* @param retries - Number of retry attempts (default: 90)
* @throws {Error} If reboot fails or device is not ready after reboot
*/
export async function reboot(
this: ADB,
retries: number = DEFAULT_ADB_REBOOT_RETRIES,
): Promise<void> {
// Get root access so we can run the next shell commands which require root access
const {wasAlreadyRooted} = await this.root();
try {
// Stop and re-start the device
await this.shell(['stop']);
await B.delay(2000); // let the emu finish stopping;
await this.setDeviceProperty('sys.boot_completed', '0', {
privileged: false, // no need to set privileged true because device already rooted
});
await this.shell(['start']);
} catch (e: unknown) {
const error = e as Error;
const {message} = error;
// provide a helpful error message if the reason reboot failed was because ADB couldn't gain root access
if (message.includes('must be root')) {
throw new Error(
`Could not reboot device. Rebooting requires root access and ` +
`attempt to get root access on device failed with error: '${message}'`,
);
}
throw error;
} finally {
// Return root state to what it was before
if (!wasAlreadyRooted) {
await this.unroot();
}
}
const timer = new timing.Timer().start();
await retryInterval(retries, 1000, async () => {
if ((await this.getDeviceProperty('sys.boot_completed')) === '1') {
return;
}
const msg = `Reboot is not completed after ${timer.getDuration().asMilliSeconds.toFixed(0)}ms`;
// we don't want the stack trace
log.debug(msg);
throw new Error(msg);
});
}
/**
* Switch adb server root privileges.
*
* @param isElevated - True to enable root, false to disable
* @returns Result object indicating success and whether device was already rooted
*/
export async function changeUserPrivileges(this: ADB, isElevated: boolean): Promise<RootResult> {
const cmd = isElevated ? 'root' : 'unroot';
const retryIfOffline = async (cmdFunc: () => Promise<any>): Promise<any> => {
try {
return await cmdFunc();
} catch (err: unknown) {
const error = err as ExecError;
// Check the output of the stdErr to see if there's any clues that show that the device went offline
// and if it did go offline, restart ADB
if (
['closed', 'device offline', 'timeout expired'].some((x) =>
(error.stderr || '').toLowerCase().includes(x),
)
) {
log.warn(`Attempt to ${cmd} caused ADB to think the device went offline`);
try {
await this.reconnect();
} catch {
await this.restartAdb();
}
return await cmdFunc();
} else {
throw error;
}
}
};
// If it's already rooted, our job is done. No need to root it again.
const isRoot = await retryIfOffline(async () => await this.isRoot());
if ((isRoot && isElevated) || (!isRoot && !isElevated)) {
return {isSuccessful: true, wasAlreadyRooted: isRoot};
}
let wasAlreadyRooted = isRoot;
try {
const {stdout} = await retryIfOffline(async () => await this.adbExec([cmd]));
log.debug(stdout);
// on real devices in some situations we get an error in the stdout
if (stdout) {
if (stdout.includes('adbd cannot run as root')) {
return {isSuccessful: false, wasAlreadyRooted};
}
// if the device was already rooted, return that in the result
if (stdout.includes('already running as root')) {
wasAlreadyRooted = true;
}
}
return {isSuccessful: true, wasAlreadyRooted};
} catch (err: unknown) {
const error = err as ExecError;
const {stderr = '', message} = error;
log.warn(
`Unable to ${cmd} adb daemon. Original error: '${message}'. Stderr: '${stderr}'. Continuing.`,
);
return {isSuccessful: false, wasAlreadyRooted};
}
}
/**
* Switch adb server to root mode
*
* @returns Result object indicating success and whether device was already rooted
*/
export async function root(this: ADB): Promise<RootResult> {
return await this.changeUserPrivileges(true);
}
/**
* Switch adb server to non-root mode.
*
* @returns Result object indicating success and whether device was already rooted
*/
export async function unroot(this: ADB): Promise<RootResult> {
return await this.changeUserPrivileges(false);
}
/**
* Checks whether the current user is root
*
* @returns True if current user is root, false otherwise
*/
export async function isRoot(this: ADB): Promise<boolean> {
return (await this.shell(['whoami'])).trim() === 'root';
}
/**
* Installs the given certificate on a rooted real device or
* an emulator. The emulator must be executed with `-writable-system`
* command line option and adb daemon should be running in root
* mode for this method to work properly. The method also requires
* openssl tool to be available on the destination system.
* Read https://github.com/appium/appium/issues/10964
* for more details on this topic
*
* @param cert - Certificate as Buffer or base64-encoded string
* @throws {Error} If certificate installation fails
*/
export async function installMitmCertificate(this: ADB, cert: Buffer | string): Promise<void> {
const openSsl = await getOpenSslForOs();
const tmpRoot = await tempDir.openDir();
try {
const srcCert = path.resolve(tmpRoot, 'source.cer');
await fs.writeFile(srcCert, Buffer.isBuffer(cert) ? cert : Buffer.from(cert, 'base64'));
const {stdout} = await exec(openSsl, ['x509', '-noout', '-hash', '-in', srcCert]);
const certHash = stdout.trim();
log.debug(`Got certificate hash: ${certHash}`);
log.debug('Preparing certificate content');
const {stdout: stdoutBuff1} = await exec(openSsl, ['x509', '-in', srcCert], {isBuffer: true});
const {stdout: stdoutBuff2} = await exec(
openSsl,
['x509', '-in', srcCert, '-text', '-fingerprint', '-noout'],
{isBuffer: true},
);
const dstCertContent = Buffer.concat([stdoutBuff1, stdoutBuff2]);
const dstCert = path.resolve(tmpRoot, `${certHash}.0`);
await fs.writeFile(dstCert, dstCertContent);
log.debug('Remounting /system in rw mode');
// Sometimes emulator reboot is still not fully finished on this stage, so retry
await retryInterval(5, 2000, async () => await this.adbExec(['remount']));
log.debug(`Uploading the generated certificate from '${dstCert}' to '${CERTS_ROOT}'`);
await this.push(dstCert, CERTS_ROOT);
log.debug('Remounting /system to confirm changes');
await this.adbExec(['remount']);
} catch (err: unknown) {
const error = err as Error;
throw new Error(
`Cannot inject the custom certificate. ` +
`Is the certificate properly encoded into base64-string? ` +
`Do you have root permissions on the device? ` +
`Original error: ${error.message}`,
);
} finally {
await fs.rimraf(tmpRoot);
}
}
/**
* Verifies if the given root certificate is already installed on the device.
*
* @param cert - Certificate as Buffer or base64-encoded string
* @returns True if certificate is installed, false otherwise
* @throws {Error} If certificate hash cannot be retrieved
*/
export async function isMitmCertificateInstalled(
this: ADB,
cert: Buffer | string,
): Promise<boolean> {
const openSsl = await getOpenSslForOs();
const tmpRoot = await tempDir.openDir();
let certHash: string;
try {
const tmpCert = path.resolve(tmpRoot, 'source.cer');
await fs.writeFile(tmpCert, Buffer.isBuffer(cert) ? cert : Buffer.from(cert, 'base64'));
const {stdout} = await exec(openSsl, ['x509', '-noout', '-hash', '-in', tmpCert]);
certHash = stdout.trim();
} catch (err: unknown) {
const error = err as Error;
throw new Error(
`Cannot retrieve the certificate hash. ` +
`Is the certificate properly encoded into base64-string? ` +
`Original error: ${error.message}`,
);
} finally {
await fs.rimraf(tmpRoot);
}
const dstPath = path.posix.resolve(CERTS_ROOT, `${certHash}.0`);
log.debug(`Checking if the certificate is already installed at '${dstPath}'`);
return await this.fileExists(dstPath);
}
/**
* Creates chunks for the given arguments and executes them in `adb shell`.
* This is faster than calling `adb shell` separately for each arg, however
* there is a limit for a maximum length of a single adb command. that is why
* we need all this complicated logic.
*
* @param argTransformer - Function to transform each argument into command array
* @param args - Array of arguments to process
* @throws {Error} If argument transformer returns invalid result or command execution fails
*/
export async function shellChunks(
this: ADB,
argTransformer: (x: string) => string[],
args: string[],
): Promise<void> {
const commands: string[][] = [];
let cmdChunk: string[] = [];
for (const arg of args) {
const nextCmd = argTransformer(arg);
if (!_.isArray(nextCmd)) {
throw new Error('Argument transformer must result in an array');
}
if (_.last(nextCmd) !== ';') {
nextCmd.push(';');
}
if (nextCmd.join(' ').length + cmdChunk.join(' ').length >= MAX_SHELL_BUFFER_LENGTH) {
commands.push(cmdChunk);
cmdChunk = [];
}
cmdChunk = [...cmdChunk, ...nextCmd];
}
if (!_.isEmpty(cmdChunk)) {
commands.push(cmdChunk);
}
log.debug(`Got the following command chunks to execute: ${JSON.stringify(commands)}`);
let lastError: Error | null = null;
for (const cmd of commands) {
try {
await this.shell(cmd);
} catch (e: unknown) {
lastError = e as Error;
}
}
if (lastError) {
throw lastError;
}
}
/**
* Transforms the given language and country abbreviations
* to AVD arguments array
*
* @param language - Language code (e.g., 'en', 'fr')
* @param country - Country code (e.g., 'US', 'FR')
* @returns Array of AVD locale arguments
*/
export function toAvdLocaleArgs(language: string | null, country: string | null): string[] {
const result: string[] = [];
if (language && _.isString(language)) {
result.push('-prop', `persist.sys.language=${language.toLowerCase()}`);
}
if (country && _.isString(country)) {
result.push('-prop', `persist.sys.country=${country.toUpperCase()}`);
}
let locale: string | undefined;
if (_.isString(language) && _.isString(country) && language && country) {
locale = language.toLowerCase() + '-' + country.toUpperCase();
} else if (language && _.isString(language)) {
locale = language.toLowerCase();
} else if (country && _.isString(country)) {
locale = country;
}
if (locale) {
result.push('-prop', `persist.sys.locale=${locale}`);
}
return result;
}
/**
* Retrieves full paths to all 'build-tools' subfolders under the particular
* SDK root folder
*
* @param sdkRoot - The Android SDK root directory path
* @returns Array of build-tools directory paths (newest first)
*/
export const getBuildToolsDirs = _.memoize(async function getBuildToolsDirs(
sdkRoot: string,
): Promise<string[]> {
let buildToolsDirs = await fs.glob('*/', {
cwd: path.resolve(sdkRoot, 'build-tools'),
absolute: true,
});
try {
buildToolsDirs = buildToolsDirs
.map((dir) => [path.basename(dir), dir] as [string, string])
.sort((a, b) => semver.rcompare(a[0], b[0]))
.map((pair) => pair[1]);
} catch (err: unknown) {
const error = err as Error;
log.warn(
`Cannot sort build-tools folders ${JSON.stringify(buildToolsDirs.map((dir) => path.basename(dir)))} ` +
`by semantic version names.`,
);
log.warn(`Falling back to sorting by modification date. Original error: ${error.message}`);
const pairs = await B.map(
buildToolsDirs,
async (dir) => [(await fs.stat(dir)).mtime.valueOf(), dir] as [number, string],
);
buildToolsDirs = pairs.sort((a, b) => (a[0] < b[0] ? 1 : -1)).map((pair) => pair[1]);
}
log.info(
`Found ${buildToolsDirs.length} 'build-tools' folders under '${sdkRoot}' (newest first):`,
);
for (const dir of buildToolsDirs) {
log.info(` ${dir}`);
}
return buildToolsDirs;
});