UNPKG

appium-adb

Version:

Android Debug Bridge interface

524 lines (485 loc) 16.2 kB
import {log} from '../logger'; import _ from 'lodash'; import net from 'node:net'; import {util, fs} from '@appium/support'; import B from 'bluebird'; import path from 'node:path'; import * as ini from 'ini'; import type {ADB} from '../adb'; import type { EmuInfo, EmuVersionInfo, StringRecord, PowerAcStates, Sensors, GsmCallActions, GsmSignalStrength, GsmVoiceStates, NetworkSpeed, ExecTelnetOptions, } from './types'; /** * Retrieves the list of available Android emulators * * @returns */ async function listEmulators(): Promise<EmuInfo[]> { let avdsRoot = process.env.ANDROID_AVD_HOME; if (await dirExists(avdsRoot ?? '')) { return await getAvdConfigPaths(avdsRoot as string); } if (avdsRoot) { log.warn( `The value of the ANDROID_AVD_HOME environment variable '${avdsRoot}' is not an existing directory`, ); } const prefsRoot = await getAndroidPrefsRoot(); if (!prefsRoot) { return []; } avdsRoot = path.resolve(prefsRoot, 'avd'); if (!(await dirExists(avdsRoot))) { log.debug(`Virtual devices config root '${avdsRoot}' is not an existing directory`); return []; } return await getAvdConfigPaths(avdsRoot); } /** * Get configuration paths of all virtual devices * * @param avdsRoot Path to the directory that contains the AVD .ini files * @returns */ async function getAvdConfigPaths(avdsRoot: string): Promise<EmuInfo[]> { const configs = await fs.glob('*.ini', { cwd: avdsRoot, absolute: true, }); return configs .map((confPath) => { const avdName = path.basename(confPath).split('.').slice(0, -1).join('.'); return {name: avdName, config: confPath}; }) .filter(({name}) => _.trim(name)); } /** * Check the emulator state. * * @returns True if Emulator is visible to adb. */ export async function isEmulatorConnected(this: ADB): Promise<boolean> { const emulators = await this.getConnectedEmulators(); return !!_.find(emulators, (x) => x && x.udid === this.curDeviceId); } /** * Verify the emulator is connected. * * @throws If Emulator is not visible to adb. */ export async function verifyEmulatorConnected(this: ADB): Promise<void> { if (!(await this.isEmulatorConnected())) { throw new Error(`The emulator "${this.curDeviceId}" was unexpectedly disconnected`); } } /** * Emulate fingerprint touch event on the connected emulator. * * @param fingerprintId - The ID of the fingerprint. */ export async function fingerprint(this: ADB, fingerprintId: string): Promise<void> { if (!fingerprintId) { throw new Error('Fingerprint id parameter must be defined'); } // the method used only works for API level 23 and above const level = await this.getApiLevel(); if (level < 23) { throw new Error(`Device API Level must be >= 23. Current Api level '${level}'`); } await this.adbExecEmu(['finger', 'touch', fingerprintId]); } /** * Change the display orientation on the connected emulator. * The orientation is changed (PI/2 is added) every time * this method is called. */ export async function rotate(this: ADB): Promise<void> { await this.adbExecEmu(['rotate']); } /** * Emulate power state change on the connected emulator. * * @param state - Either 'on' or 'off'. */ export async function powerAC(this: ADB, state: PowerAcStates = 'on'): Promise<void> { if (_.values(this.POWER_AC_STATES).indexOf(state) === -1) { throw new TypeError( `Wrong power AC state sent '${state}'. ` + `Supported values: ${_.values(this.POWER_AC_STATES)}]`, ); } await this.adbExecEmu(['power', 'ac', state]); } /** * Emulate sensors values on the connected emulator. * * @param sensor - Sensor type declared in SENSORS items. * @param value - Number to set as the sensor value. * @throws - If sensor type or sensor value is not defined */ export async function sensorSet(this: ADB, sensor: string, value: Sensors): Promise<void> { if (!_.includes(this.SENSORS, sensor)) { throw new TypeError( `Unsupported sensor sent '${sensor}'. ` + `Supported values: ${_.values(this.SENSORS)}]`, ); } if (_.isNil(value)) { throw new TypeError( `Missing/invalid sensor value argument. ` + `You need to provide a valid value to set to the sensor in ` + `format <value-a>[:<value-b>[:<value-c>[...]]].`, ); } await this.adbExecEmu(['sensor', 'set', sensor, `${value}`]); } /** * Emulate power capacity change on the connected emulator. * * @param percent - Percentage value in range [0, 100]. */ export async function powerCapacity(this: ADB, percent: string | number = 100): Promise<void> { const percentInt = parseInt(`${percent}`, 10); if (isNaN(percentInt) || percentInt < 0 || percentInt > 100) { throw new TypeError(`The percentage value should be valid integer between 0 and 100`); } await this.adbExecEmu(['power', 'capacity', `${percentInt}`]); } /** * Emulate power off event on the connected emulator. */ export async function powerOFF(this: ADB): Promise<void> { await this.powerAC(this.POWER_AC_STATES.POWER_AC_OFF); await this.powerCapacity(0); } /** * Emulate send SMS event on the connected emulator. * * @param phoneNumber - The phone number of message sender. * @param message - The message content. * @throws If phone number has invalid format. */ export async function sendSMS( this: ADB, phoneNumber: string | number, message = '', ): Promise<void> { if (_.isEmpty(message)) { throw new TypeError('SMS message must not be empty'); } if (!_.isInteger(phoneNumber) && _.isEmpty(phoneNumber)) { throw new TypeError('Phone number most not be empty'); } await this.adbExecEmu(['sms', 'send', `${phoneNumber}`, message]); } /** * Emulate GSM call event on the connected emulator. * * @param phoneNumber - The phone number of the caller. * @param action - One of available GSM call actions. * @throws If phone number has invalid format. * @throws If _action_ value is invalid. */ export async function gsmCall( this: ADB, phoneNumber: string | number, action: GsmCallActions, ): Promise<void> { if (!_.values(this.GSM_CALL_ACTIONS).includes(action)) { throw new TypeError( `Invalid gsm action param ${action}. Supported values: ${_.values(this.GSM_CALL_ACTIONS)}`, ); } if (!_.isInteger(phoneNumber) && _.isEmpty(phoneNumber)) { throw new TypeError('Phone number most not be empty'); } await this.adbExecEmu(['gsm', action, `${phoneNumber}`]); } /** * Emulate GSM signal strength change event on the connected emulator. * * @param strength - A number in range [0, 4]; * @throws If _strength_ value is invalid. */ export async function gsmSignal(this: ADB, strength: GsmSignalStrength = 4): Promise<void> { const strengthInt = parseInt(`${strength}`, 10); if (!_.includes(this.GSM_SIGNAL_STRENGTHS, strengthInt)) { throw new TypeError( `Invalid signal strength param ${strength}. Supported values: ${_.values(this.GSM_SIGNAL_STRENGTHS)}`, ); } log.info('gsm signal-profile <strength> changes the reported strength on next (15s) update.'); await this.adbExecEmu(['gsm', 'signal-profile', `${strength}`]); } /** * Emulate GSM voice event on the connected emulator. * * @param state - Either 'on' or 'off'. * @throws If _state_ value is invalid. */ export async function gsmVoice(this: ADB, state: GsmVoiceStates = 'on'): Promise<void> { // gsm voice <state> allows you to change the state of your GPRS connection if (!_.values(this.GSM_VOICE_STATES).includes(state)) { throw new TypeError( `Invalid gsm voice state param ${state}. Supported values: ${_.values(this.GSM_VOICE_STATES)}`, ); } await this.adbExecEmu(['gsm', 'voice', state]); } /** * Emulate network speed change event on the connected emulator. * * @param speed * One of possible NETWORK_SPEED values. * @throws If _speed_ value is invalid. */ export async function networkSpeed(this: ADB, speed: NetworkSpeed = 'full'): Promise<void> { // network speed <speed> allows you to set the network speed emulation. if (!_.values(this.NETWORK_SPEED).includes(speed)) { throw new Error( `Invalid network speed param ${speed}. Supported values: ${_.values(this.NETWORK_SPEED)}`, ); } await this.adbExecEmu(['network', 'speed', speed]); } /** * Executes a command through emulator telnet console interface and returns its output * * @param cmd - The actual command to execute. See * https://developer.android.com/studio/run/emulator-console for more details * on available commands * @param opts * @returns The command output * @throws If there was an error while connecting to the Telnet console * or if the given command returned non-OK response */ export async function execEmuConsoleCommand( this: ADB, cmd: string[] | string, opts: ExecTelnetOptions = {}, ): Promise<string> { let port = parseInt(`${opts.port}`, 10); if (!port) { const portMatch = /emulator-(\d+)/i.exec(this.curDeviceId as string); if (!portMatch) { throw new Error( `Cannot parse the console port number from the device identifier '${this.curDeviceId}'. ` + `Is it an emulator?`, ); } port = parseInt(portMatch[1], 10); } const host = '127.0.0.1'; const {execTimeout = 60000, connTimeout = 5000, initTimeout = 5000} = opts; await this.resetTelnetAuthToken(); const okFlag = /^OK$/m; const nokFlag = /^KO\b/m; const eol = '\r\n'; const client = net.connect({ host, port, }); return await new B((resolve, reject) => { const connTimeoutObj = setTimeout( () => reject( new Error( `Cannot connect to the Emulator console at ${host}:${port} ` + `after ${connTimeout}ms`, ), ), connTimeout, ); let execTimeoutObj: NodeJS.Timeout; let initTimeoutObj: NodeJS.Timeout; let isCommandSent = false; let serverResponse: Buffer[] = []; client.once('error', (e) => { clearTimeout(connTimeoutObj); reject( new Error( `Cannot connect to the Emulator console at ${host}:${port}. ` + `Original error: ${e.message}`, ), ); }); client.once('connect', () => { clearTimeout(connTimeoutObj); initTimeoutObj = setTimeout( () => reject( new Error( `Did not get the initial response from the Emulator console at ${host}:${port} ` + `after ${initTimeout}ms`, ), ), initTimeout, ); }); client.on('data', (chunk: Buffer | string) => { const buf = typeof chunk === 'string' ? Buffer.from(chunk) : chunk; serverResponse.push(buf); const output = Buffer.concat(serverResponse).toString('utf8').trim(); if (okFlag.test(output)) { // The initial incoming data chunk confirms the interface is ready for input if (!isCommandSent) { clearTimeout(initTimeoutObj); serverResponse = []; const cmdStr = _.isArray(cmd) ? util.quote(cmd) : `${cmd}`; log.debug(`Executing Emulator console command: ${cmdStr}`); client.write(cmdStr); client.write(eol); isCommandSent = true; execTimeoutObj = setTimeout( () => reject( new Error( `Did not get any response from the Emulator console at ${host}:${port} ` + `to '${cmd}' command after ${execTimeout}ms`, ), ), execTimeout, ); return; } clearTimeout(execTimeoutObj); client.end(); const outputArr = output.split(eol); // remove the redundant OK flag from the resulting command output return resolve( outputArr .slice(0, outputArr.length - 1) .join('\n') .trim(), ); } else if (nokFlag.test(output)) { clearTimeout(initTimeoutObj); clearTimeout(execTimeoutObj); client.end(); const outputArr = output.split(eol); return reject(_.trim(_.last(outputArr) || '')); } }); }); } /** * Retrieves emulator version from the file system * * @returns If no version info could be parsed then an empty * object is returned */ export async function getEmuVersionInfo(this: ADB): Promise<EmuVersionInfo> { const propsPath = path.join(this.sdkRoot as string, 'emulator', 'source.properties'); if (!(await fs.exists(propsPath))) { return {}; } const content = await fs.readFile(propsPath, 'utf8'); const revisionMatch = /^Pkg\.Revision=([\d.]+)$/m.exec(content); const result: EmuVersionInfo = {}; if (revisionMatch) { result.revision = revisionMatch[1]; } const buildIdMatch = /^Pkg\.BuildId=(\d+)$/m.exec(content); if (buildIdMatch) { result.buildId = parseInt(buildIdMatch[1], 10); } return result; } /** * Retrieves emulator image properties from the local file system * * @param avdName Emulator name. Should NOT start with '@' character * @throws if there was a failure while extracting the properties * @returns The content of emulator image properties file. * Usually this configuration .ini file has the following content: * avd.ini.encoding=UTF-8 * path=/Users/username/.android/avd/Pixel_XL_API_30.avd * path.rel=avd/Pixel_XL_API_30.avd * target=android-30 */ export async function getEmuImageProperties(this: ADB, avdName: string): Promise<StringRecord> { const avds = await listEmulators(); const avd = avds.find(({name}) => name === avdName); if (!avd) { let msg = `Cannot find '${avdName}' emulator. `; if (_.isEmpty(avds)) { msg += `No emulators have been detected on your system`; } else { msg += `Available avd names are: ${avds.map(({name}) => name)}`; } throw new Error(msg); } return ini.parse(await fs.readFile(avd.config, 'utf8')); } /** * Check if given emulator exists in the list of available avds. * * @param avdName - The name of emulator to verify for existence. * Should NOT start with '@' character * @throws If the emulator with given name does not exist. */ export async function checkAvdExist(this: ADB, avdName: string): Promise<boolean> { const avds = await listEmulators(); if (!avds.some(({name}) => name === avdName)) { let msg = `Avd '${avdName}' is not available. `; if (_.isEmpty(avds)) { msg += `No emulators have been detected on your system`; } else { msg += `Please select your avd name from one of these: '${avds.map(({name}) => name)}'`; } throw new Error(msg); } return true; } /** * Send an arbitrary Telnet command to the device under test. * * @param command - The command to be sent. * @returns The actual output of the given command. */ export async function sendTelnetCommand(this: ADB, command: string): Promise<string> { return await this.execEmuConsoleCommand(command, {port: await this.getEmulatorPort()}); } // #region Private functions /** * Retrieves the full path to the Android preferences root * * @returns The full path to the folder or `null` if the folder cannot be found */ async function getAndroidPrefsRoot(): Promise<string | null> { let location = process.env.ANDROID_EMULATOR_HOME; if (await dirExists(location ?? '')) { return location ?? null; } if (location) { log.warn( `The value of the ANDROID_EMULATOR_HOME environment variable '${location}' is not an existing directory`, ); } const home = process.env.HOME || process.env.USERPROFILE; if (home) { location = path.resolve(home, '.android'); } if (!(await dirExists(location ?? ''))) { log.debug(`Android config root '${location}' is not an existing directory`); return null; } return location ?? null; } /** * Check if a path exists on the filesystem and is a directory * * @param location The full path to the directory * @returns */ async function dirExists(location: string): Promise<boolean> { return (await fs.exists(location)) && (await fs.stat(location)).isDirectory(); } // #endregion