UNPKG

appium-adb

Version:

Android Debug Bridge interface

1,369 lines (1,288 loc) 47.9 kB
import path from 'path'; import { log } from '../logger.js'; 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 { sleep, retry, retryInterval, waitForCondition } from 'asyncbox'; import _ from 'lodash'; import * as semver from 'semver'; 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, ]; 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 = [ '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', 'mount']; const SUBSYSTEM_STATE_OK = 'Subsystem state: true'; const MAX_SHELL_BUFFER_LENGTH = 1000; /** * Retrieve full path to the given binary. * * @this {import('../adb.js').ADB} * @param {string} binaryName - The name of the binary. * @return {Promise<string>} Full path to the given binary including current SDK root. */ export async function getSdkBinaryPath (binaryName) { return await this.getBinaryFromSdkRoot(binaryName); } /** * Retrieve full binary name for the current operating system. * * @param {string} binaryName - simple binary name, for example 'android'. * @return {string} Formatted binary name depending on the current platform, * for example, 'android.bat' on Windows. */ function _getBinaryNameForOS (binaryName) { if (!system.isWindows()) { return binaryName; } if (['android', 'apksigner', 'apkanalyzer'].includes(binaryName)) { return `${binaryName}.bat`; } if (!path.extname(binaryName)) { return `${binaryName}.exe`; } return binaryName; } export const getBinaryNameForOS = _.memoize(_getBinaryNameForOS); /** * Retrieve full path to the given binary and caches it into `binaries` * property of the current ADB instance. * * @this {import('../adb.js').ADB} * @param {string} binaryName - Simple name of a binary file. * @return {Promise<string>} Full path to the given binary. The method tries * to enumerate all the known locations where the binary * might be located and stops the search as soon as the first * match is found on the local file system. * @throws {Error} If the binary with given name is not present at any * of known locations or Android SDK is not installed on the * local file system. */ export async function getBinaryFromSdkRoot (binaryName) { if ((/** @type {import('./types').StringRecord} */ (this.binaries))[binaryName]) { return (/** @type {import('./types').StringRecord} */ (this.binaries))[binaryName]; } const fullBinaryName = this.getBinaryNameForOS(binaryName); const binaryLocs = getSdkBinaryLocationCandidates( /** @type {string} */(this.sdkRoot), fullBinaryName ); // get subpaths for currently installed build tool directories let buildToolsDirs = await getBuildToolsDirs(/** @type {string} */(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 = 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}'`); (/** @type {import('./types').StringRecord} */ (this.binaries))[binaryName] = binaryLoc; return binaryLoc; } /** * Returns the Android binaries locations * * @param {string} sdkRoot The path to Android SDK root. * @param {string} fullBinaryName The name of full binary name. * @return {string[]} The list of SDK_BINARY_ROOTS paths * with sdkRoot and fullBinaryName. */ function getSdkBinaryLocationCandidates (sdkRoot, fullBinaryName) { return SDK_BINARY_ROOTS.map((x) => path.resolve(sdkRoot, ...(_.isArray(x) ? x : [x]), fullBinaryName)); } /** * Retrieve full path to the given binary. * This method does not have cache. * * @param {string} binaryName - Simple name of a binary file. * e.g. 'adb', 'android' * @return {Promise<string>} Full path to the given binary. The method tries * to enumerate all the known locations where the binary * might be located and stops the search as soon as the first * match is found on the local file system. * e.g. '/Path/To/Android/sdk/platform-tools/adb' * @throws {Error} If the binary with given name is not present at any * of known locations or Android SDK is not installed on the * local file system. */ export async function getAndroidBinaryPath (binaryName) { 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. * * @this {import('../adb.js').ADB} * @param {string} binaryName - The name of the binary. * @return {Promise<string>} Full path to the binary received from 'which'/'where' * output. * @throws {Error} If lookup tool returns non-zero return code. */ export async function getBinaryFromPath (binaryName) { if ((/** @type {import('./types').StringRecord} */ (this.binaries))[binaryName]) { return (/** @type {import('./types').StringRecord} */ (this.binaries))[binaryName]; } const fullBinaryName = this.getBinaryNameForOS(binaryName); try { const binaryLoc = await fs.which(fullBinaryName); log.info(`Using '${fullBinaryName}' from '${binaryLoc}'`); (/** @type {import('./types').StringRecord} */ (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. * * @this {import('../adb.js').ADB} * @param {import('./types').ConnectedDevicesOptions} [opts={}] - Additional options mapping. * @return {Promise<import('./types').Device[]>} The list of devices or an empty list if * no devices are connected. * @throws {Error} If there was an error while listing devices. */ export async function getConnectedDevices (opts = {}) { log.debug('Getting connected devices'); const args = [...this.executable.defaultArgs, 'devices']; if (opts.verbose) { args.push('-l'); } let stdout; try { ({stdout} = await exec(this.executable.path, args)); } catch (e) { throw new Error(`Error while getting connected devices. Original error: ${e.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); let 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 = {udid, state}; 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. * * @this {import('../adb.js').ADB} * @param {number} timeoutMs - The maximum number of milliseconds to get at least * one list item. * @return {Promise<import('./types').Device[]>} The list of connected devices. * @throws {Error} If no connected devices can be detected within the given timeout. */ export async function getDevicesWithRetry (timeoutMs = 20000) { log.debug('Trying to find connected Android devices'); try { let devices; await waitForCondition(async () => { try { devices = await this.getConnectedDevices(); if (devices.length) { return true; } log.debug('Could not find online devices'); } catch (err) { log.debug(err.stack); log.warn(`Got an unexpected error while fetching connected devices list: ${err.message}`); } try { await this.reconnect(); } catch { await this.restartAdb(); } return false; }, { waitMs: timeoutMs, intervalMs: 200, }); return /** @type {any} */ (devices); } catch (e) { if (/Condition unmet/.test(e.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 * * @this {import('../adb.js').ADB} * @param {string} [target=offline] One of possible targets to reconnect: * offline, device or null * Providing `null` will cause reconnection to happen from the host side. * * @throws {Error} If either ADB version is too old and does not support this * command or there was a failure during reconnect. */ export async function reconnect (target = 'offline') { log.debug(`Reconnecting adb (target ${target})`); const args = ['reconnect']; if (target) { args.push(target); } try { await this.adbExec(args); } catch (e) { throw new Error(`Cannot reconnect adb. Original error: ${e.stderr || e.message}`); } } /** * Restart adb server, unless _this.suppressKillServer_ property is true. * * @this {import('../adb.js').ADB} */ export async function restartAdb () { 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. * @this {import('../adb.js').ADB} */ export async function killServer () { 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. * * @this {import('../adb.js').ADB} * @returns {Promise<boolean>} If token reset was successful. */ export const resetTelnetAuthToken = _.memoize(async function resetTelnetAuthToken () { // 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) { log.warn(`Error ${e.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. * * @this {import('../adb.js').ADB} * @param {string[]} cmd - The array of rest command line parameters. */ export async function adbExecEmu (cmd) { await this.verifyEmulatorConnected(); await this.resetTelnetAuthToken(); await this.adbExec(['emu', ...cmd]); } let isExecLocked = false; /** @type {{STDOUT: 'stdout', FULL: 'full'}} */ export const EXEC_OUTPUT_FORMAT = Object.freeze({ STDOUT: 'stdout', FULL: 'full', }); /** * Execute the given adb command. * * @template {import('teen_process').TeenProcessExecOptions & import('./types').ShellExecOptions & import('./types').SpecialAdbExecOptions} TExecOpts * @this {import('../adb.js').ADB} * @param {string|string[]} cmd - The array of rest command line parameters * or a single string parameter. * @param {TExecOpts} [opts] Additional options mapping. See * {@link https://github.com/appium/node-teen_process} * for more details. * You can also set the additional `exclusive` param * to `true` that assures no other parallel adb commands * are going to be executed while the current one is running * You can set the `outputFormat` param to `stdout` to receive just the stdout * output (default) or `full` to receive the stdout and stderr response from a * command with a zero exit code * @return {Promise<TExecOpts extends import('./types').TFullOutputOption ? import('teen_process').TeenProcessExecResult : string>} * Command's stdout or an object containing stdout and stderr. * @throws {Error} If the command returned non-zero exit code. */ export async function adbExec (cmd, opts) { if (!cmd) { throw new Error('You need to pass in a command to adbExec()'); } const optsCopy = _.cloneDeep(opts) ?? /** @type {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 () => { try { const args = [...this.executable.defaultArgs, ...cmd]; log.debug(`Running '${this.executable.path} ` + (args.find((arg) => /\s+/.test(arg)) ? util.quote(args) : args.join(' ')) + `'`); let {stdout, stderr} = await exec(this.executable.path, args, optsCopy); // 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 stdout = stdout.replace(LINKER_WARNING_REGEXP, '').trim(); return outputFormat === this.EXEC_OUTPUT_FORMAT.FULL ? {stdout, stderr} : stdout; } catch (e) { const errText = `${e.message}, ${e.stdout}, ${e.stderr}`; if (ADB_RETRY_ERROR_PATTERNS.some((p) => p.test(errText))) { log.info(`Error sending command, reconnecting device and retrying: ${cmd}`); await sleep(1000); await this.getDevicesWithRetry(); // try again one time if (adbRetried) { adbRetried = true; return await execFunc(); } } if (e.code === 0 && e.stdout) { return e.stdout.replace(LINKER_WARNING_REGEXP, '').trim(); } if (_.isNull(e.code)) { e.message = `Error executing adbExec. Original error: '${e.message}'. ` + `Try to increase the ${optsCopy.timeout}ms adb execution timeout ` + `represented by '${optsCopy.timeoutCapName}' capability`; } else { e.message = `Error executing adbExec. Original error: '${e.message}'; ` + `Command output: ${e.stderr || e.stdout || '<empty>'}`; } throw e; } }; 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(); } finally { if (optsCopy.exclusive) { isExecLocked = false; } } } /** * Execute the given command using _adb shell_ prefix. * * @this {import('../adb.js').ADB} * @template {import('./types').ShellExecOptions} TShellExecOpts * @param {string|string[]} cmd - The array of rest command line parameters or a single * string parameter. * @param {TShellExecOpts} [opts] - Additional options mapping. * @return {Promise<TShellExecOpts extends import('./types').TFullOutputOption ? import('teen_process').TeenProcessExecResult : string>} * Command's stdout. * @throws {Error} If the command returned non-zero exit code. */ export async function shell (cmd, opts) { const { privileged, } = opts ?? /** @type {TShellExecOpts} */ ({}); const cmdArr = _.isArray(cmd) ? cmd : [cmd]; const fullCmd = ['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); } /** * * @this {import('../adb.js').ADB} * @param {string[]} [args=[]] * @returns {import('teen_process').SubProcess} */ export function createSubProcess (args = []) { // 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 * * @this {import('../adb.js').ADB} * @return {number} The current adb port number. */ export function getAdbServerPort () { return /** @type {number} */ (this.adbPort); } /** * Retrieve the current emulator port from _adb devives_ output. * * @this {import('../adb.js').ADB} * @return {Promise<number>} The current emulator port. * @throws {Error} If there are no connected devices. */ export async function getEmulatorPort () { log.debug('Getting running emulator port'); if (this.emulatorPort !== null) { return /** @type {number} */ (this.emulatorPort); } try { let devices = await this.getConnectedDevices(); let port = this.getPortFromEmulatorString(devices[0].udid); if (port) { return port; } else { throw new Error(`Emulator port not found`); } } catch (e) { throw new Error(`No devices connected. Original error: ${e.message}`); } } /** * Retrieve the current emulator port by parsing emulator name string. * * @this {import('../adb.js').ADB} * @param {string} emStr - Emulator name string. * @return {number|false} Either the current emulator port or * _false_ if port number cannot be parsed. */ export function getPortFromEmulatorString (emStr) { let portPattern = /emulator-(\d+)/; if (portPattern.test(emStr)) { return parseInt((/** @type {RegExpExecArray} */(portPattern.exec(emStr)))[1], 10); } return false; } /** * Retrieve the list of currently connected emulators. * * @this {import('../adb.js').ADB} * @param {import('./types').ConnectedDevicesOptions} [opts={}] - Additional options mapping. * @return {Promise<import('./types').Device[]>} The list of connected devices. */ export async function getConnectedEmulators (opts = {}) { log.debug('Getting connected emulators'); try { let devices = await this.getConnectedDevices(opts); let emulators = []; for (let device of devices) { let 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) { throw new Error(`Error getting emulators. Original error: ${e.message}`); } } /** * Set _emulatorPort_ property of the current class. * * @this {import('../adb.js').ADB} * @param {number} emPort - The emulator port to be set. */ export function setEmulatorPort (emPort) { this.emulatorPort = emPort; } /** * Set the identifier of the current device (_this.curDeviceId_). * * @this {import('../adb.js').ADB} * @param {string} deviceId - The device identifier. */ export function setDeviceId (deviceId) { log.debug(`Setting device id to ${deviceId}`); this.curDeviceId = deviceId; let 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 the current device object. * * @this {import('../adb.js').ADB} * @param {import('./types').Device} deviceObj - The device object to be set. */ export function setDevice (deviceObj) { 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. * * @this {import('../adb.js').ADB} * @param {string} avdName - Emulator name. * @return {Promise<import('./types').Device|null>} Currently running emulator or _null_. */ export async function getRunningAVD (avdName) { 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) { throw new Error(`Error getting AVD. Original error: ${e.message}`); } } /** * Get the object for the currently running emulator. * * @this {import('../adb.js').ADB} * @param {string} avdName - Emulator name. * @param {number} [timeoutMs=20000] - The maximum number of milliseconds * to wait until at least one running AVD object * is detected. * @return {Promise<import('./types').Device|null>} Currently running emulator or _null_. * @throws {Error} If no device has been detected within the timeout. */ export async function getRunningAVDWithRetry (avdName, timeoutMs = 20000) { try { return /** @type {import('./types').Device|null} */ (await waitForCondition(async () => { try { return await this.getRunningAVD(avdName.replace('@', '')); } catch (e) { log.debug(e.message); return false; } }, { waitMs: timeoutMs, intervalMs: 1000, })); } catch (e) { throw new Error(`Error getting AVD with retry. Original error: ${e.message}`); } } /** * Shutdown all running emulators by killing their processes. * * @this {import('../adb.js').ADB} * @throws {Error} If killing tool returned non-zero return code. */ export async function killAllEmulators () { let cmd, args; 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) { throw new Error(`Error killing emulators. Original error: ${e.message}`); } } /** * Kill emulator with the given name. No error * is thrown is given avd does not exist/is not running. * * @this {import('../adb.js').ADB} * @param {string?} [avdName=null] - The name of the emulator to be killed. If empty, * the current emulator will be killed. * @param {number} [timeout=60000] - The amount of time to wait before throwing * an exception about unsuccessful killing * @return {Promise<boolean>} - True if the emulator was killed, false otherwise. * @throws {Error} if there was a failure by killing the emulator */ export async function killEmulator (avdName = null, timeout = 60000) { 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) : !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. * * @this {import('../adb.js').ADB} * @param {string} avdName - The name of an existing emulator. * @param {import('./types').AvdLaunchOptions} [opts={}] * @returns {Promise<SubProcess>} Emulator subprocess instance * @throws {Error} If the emulator fails to start within the given timeout. */ export async function launchAVD (avdName, opts = {}) { 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'); if (avdName[0] === '@') { avdName = avdName.substr(1); } await this.checkAvdExist(avdName); /** @type {string[]} */ const launchArgs = ['-avd', avdName]; 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(avdName); 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) { log.info(`The -delay-adb emulator startup detection feature will not be enabled. ` + `Original error: ${e.message}`); } } } else { log.info('The -delay-adb emulator startup detection feature has been explicitly disabled'); } if (!_.isEmpty(args)) { launchArgs.push(...(_.isArray(args) ? args : /** @type {string[]} */ (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: Object.assign({}, process.env, env), }); await proc.start(0); for (const streamName of ['stderr', 'stdout']) { proc.on(`line-${streamName}`, (line) => log.debug(`[AVD OUTPUT] ${line}`)); } proc.on('die', (code, signal) => { log.warn(`Emulator avd ${avdName} exited with code ${code}${signal ? `, signal ${signal}` : ''}`); }); await retry(retryTimes, async () => await this.getRunningAVDWithRetry(avdName, 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) { throw new Error(`'${avdName}' Emulator has failed to boot: ${e.stderr || e.message}`); } } await this.waitForEmulatorReady(Math.trunc(readyTimeout - timer.getDuration().asMilliSeconds)); return proc; } /** * Get the adb version. The result of this method is cached. * * @this {import('../adb.js').ADB} * @return {Promise<import('./types').Version>} * @throws {Error} If it is not possible to parse adb binary version. */ export const getVersion = _.memoize(async function getVersion () { let stdout; try { stdout = await this.adbExec('version'); } catch (e) { throw new Error(`Error getting adb version: ${e.stderr || e.message}`); } const result = {}; const binaryVersionMatch = BINARY_VERSION_PATTERN.exec(stdout); if (binaryVersionMatch) { result.binary = { version: semver.coerce(binaryVersionMatch[1]), build: parseInt(binaryVersionMatch[2], 10), }; } const bridgeVersionMatch = BRIDGE_VERSION_PATTERN.exec(stdout); if (bridgeVersionMatch) { result.bridge = { version: semver.coerce(bridgeVersionMatch[1]), }; } return result; }); /** * Check if the current emulator is ready to accept further commands (booting completed). * * @this {import('../adb.js').ADB} * @param {number} [timeoutMs=20000] - The maximum number of milliseconds to wait. * @returns {Promise<void>} * @throws {Error} If the emulator is not ready within the given timeout. */ export async function waitForEmulatorReady (timeoutMs = 20000) { log.debug(`Waiting up to ${timeoutMs}ms for the emulator to be ready`); if (await this.getApiLevel() >= 31) { /** @type {string|undefined} */ let state; try { await waitForCondition(async () => { try { state = await this.shell([ 'cmd', 'reboot_readiness', 'check-subsystems-state', '--list-blocking' ]); } catch (err) { // https://github.com/appium/appium/issues/18717 state = err.stdout || err.stderr; } if (_.includes(state, SUBSYSTEM_STATE_OK)) { return true; } log.debug(`Waiting for emulator startup. Intermediate state: ${state}`); return false; }, { waitMs: timeoutMs, intervalMs: 1000, }); } catch { throw new Error(`Emulator is not ready within ${timeoutMs}ms${state ? ('. Reason: ' + state) : ''}`); } return; } /** @type {RegExp[]} */ const requiredServicesRe = REQUIRED_SERVICES.map((name) => new RegExp(`\\b${name}:`)); let services; try { await waitForCondition(async () => { try { services = await this.shell(['service', 'list']); return requiredServicesRe.every((pattern) => pattern.test(services)); } catch (err) { log.debug(`Waiting for emulator startup. Intermediate error: ${err.message}`); return false; } }, { waitMs: timeoutMs, intervalMs: 3000, }); } catch { if (services) { log.debug(`Recently listed services:\n${services}`); } const missingServices = _.zip(REQUIRED_SERVICES, requiredServicesRe) .filter(([, pattern]) => !(/** @type {RegExp} */ (pattern)).test(services)) .map(([name]) => name); throw new Error(`Emulator is not ready within ${timeoutMs}ms ` + `(${missingServices} service${missingServices.length === 1 ? ' is' : 's are'} not running)`); } } /** * Check if the current device is ready to accept further commands (booting completed). * * @this {import('../adb.js').ADB} * @param {number} [appDeviceReadyTimeout=30] - The maximum number of seconds to wait. * @throws {Error} If the device is not ready within the given timeout. */ export async function waitForDevice (appDeviceReadyTimeout = 30) { this.appDeviceReadyTimeout = appDeviceReadyTimeout; const retries = 3; const timeout = parseInt(`${this.appDeviceReadyTimeout}`, 10) * 1000 / retries; await retry(retries, async () => { try { await this.adbExec('wait-for-device', {timeout}); await this.ping(); } catch (e) { try { await this.reconnect(); } catch { await this.restartAdb(); } await this.getConnectedDevices(); throw new Error(`Error waiting for the device to be available. Original error: '${e.message}'`); } }); } /** * Reboot the current device and wait until it is completed. * * @this {import('../adb.js').ADB} * @param {number} [retries=DEFAULT_ADB_REBOOT_RETRIES] - The maximum number of reboot retries. * @throws {Error} If the device failed to reboot and number of retries is exceeded. */ export async function reboot (retries = DEFAULT_ADB_REBOOT_RETRIES) { // 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) { const {message} = e; // 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 e; } 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. * * @this {import('../adb.js').ADB} * @param {boolean} isElevated - Should we elevate to to root or unroot? (default true) * @return {Promise<import('./types').RootResult>} */ export async function changeUserPrivileges (isElevated) { const cmd = isElevated ? 'root' : 'unroot'; const retryIfOffline = async (cmdFunc) => { try { return await cmdFunc(); } catch (err) { // 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) => (err.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 err; } } }; // 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) { const {stderr = '', message} = err; log.warn(`Unable to ${cmd} adb daemon. Original error: '${message}'. Stderr: '${stderr}'. Continuing.`); return {isSuccessful: false, wasAlreadyRooted}; } } /** * Switch adb server to root mode * * @this {import('../adb.js').ADB} * @return {Promise<import('./types').RootResult>} */ export async function root () { return await this.changeUserPrivileges(true); } /** * Switch adb server to non-root mode. * * @this {import('../adb.js').ADB} * @return {Promise<import('./types').RootResult>} */ export async function unroot () { return await this.changeUserPrivileges(false); } /** * Checks whether the current user is root * * @this {import('../adb.js').ADB} * @return {Promise<boolean>} True if the user is root * @throws {Error} if there was an error while identifying * the user. */ export async function isRoot () { 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 * * @this {import('../adb.js').ADB} * @param {Buffer|string} cert - base64-decoded content of the actual certificate * represented as a string or a buffer * @throws {Error} If openssl tool is not available on the destination system * or if there was an error while installing the certificate */ export async function installMitmCertificate (cert) { 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) { 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: ${err.message}`); } finally { await fs.rimraf(tmpRoot); } } /** * Verifies if the given root certificate is already installed on the device. * * @this {import('../adb.js').ADB} * @param {Buffer|string} cert - base64-decoded content of the actual certificate * represented as a string or a buffer * @throws {Error} If openssl tool is not available on the destination system * or if there was an error while checking the certificate * @returns {Promise<boolean>} true if the given certificate is already installed */ export async function isMitmCertificateInstalled (cert) { const openSsl = await getOpenSslForOs(); const tmpRoot = await tempDir.openDir(); let certHash; 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) { throw new Error(`Cannot retrieve the certificate hash. ` + `Is the certificate properly encoded into base64-string? ` + `Original error: ${err.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. * * @this {import('../adb.js').ADB} * @param {(x: string) => string[]} argTransformer A function, that receives single argument * from the `args` array and transforms it into a shell command. The result * of the function must be an array, where each item is a part of a single command. * The last item of the array could be ';'. If this is not a semicolon then it is going to * be added automatically. * @param {string[]} args Array of argument values to create chunks for * @throws {Error} If any of the chunks returns non-zero exit code after being executed */ export async function shellChunks (argTransformer, args) { const commands = []; /** @type {string[]} */ let cmdChunk = []; 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 = null; for (const cmd of commands) { try { await this.shell(cmd); } catch (e) { lastError = e; } } if (lastError) { throw lastError; } } // #region Private functions /** * Transforms the given language and country abbreviations * to AVD arguments array * * @param {?string} language Language name, for example 'fr' * @param {?string} country Country name, for example 'CA' * @returns {Array<string>} The generated arguments. The * resulting array might be empty if both arguments are empty */ export function toAvdLocaleArgs (language, country) { const result = []; 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; 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 * * @type {(sdkRoot: string) => Promise<string[]>} */ export const getBuildToolsDirs = _.memoize(async function getBuildToolsDirs (sdkRoot) { let buildToolsDirs = await fs.glob('*/', { cwd: path.resolve(sdkRoot, 'build-tools'), absolute: true, }); try { buildToolsDirs = buildToolsDirs .map((dir) => [path.basename(dir), dir]) .sort((a, b) => semver.rcompare(a[0], b[0])) .map((pair) => pair[1]); } catch (err) { 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: ${err.message}`); /** @type {[number, string][]} */ const pairs = await B.map(buildToolsDirs, async (dir) => [(await fs.stat(dir)).mtime.valueOf(), dir]); buildToolsDirs = pairs // @ts-ignore This sorting works .sort((a, b) => a[0] < b[0]) .map((pair) => pair[1]); } log.info(`Found ${buildToolsDirs.length} 'build-tools' folders under '${sdkRoot}' (newest first):`); for (let dir of buildToolsDirs) { log.info(` ${dir}`); } return buildToolsDirs; }); /** * * @returns {Promise<string>} */ async function getOpenSslForOs () { 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'); } } // #endregion