UNPKG

appium-adb

Version:

Android Debug Bridge interface

1,460 lines (1,381 loc) 47.9 kB
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; });