appium-adb
Version:
Android Debug Bridge interface
524 lines (485 loc) • 16.2 kB
text/typescript
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