diff --git a/lib/adb.ts b/lib/adb.ts index 2b44e38f..d161b240 100644 --- a/lib/adb.ts +++ b/lib/adb.ts @@ -12,18 +12,20 @@ import type { LogcatOpts, StringRecord } from './tools/types'; import type { LRUCache } from 'lru-cache'; import type { ExecError } from 'teen_process'; -import * as generalMethods from './tools/adb-commands'; -import * as manifestMethods from './tools/android-manifest'; -import * as systemCallMethods from './tools/system-calls'; -import * as apkSigningMethods from './tools/apk-signing'; -import * as apkUtilsMethods from './tools/apk-utils'; -import * as apksUtilsMethods from './tools/apks-utils'; -import * as aabUtilsMethods from './tools/aab-utils'; -import * as emuMethods from './tools/adb-emu-commands'; +import * as generalCommands from './tools/adb-commands'; +import * as manifestCommands from './tools/android-manifest'; +import * as systemCommands from './tools/system-calls'; +import * as signingCommands from './tools/apk-signing'; +import * as apkUtilCommands from './tools/apk-utils'; +import * as apksUtilCommands from './tools/apks-utils'; +import * as aabUtilCommands from './tools/aab-utils'; +import * as emuCommands from './tools/adb-emu-commands'; +import * as emuConstants from './tools/emu-constants'; import * as lockManagementCommands from './tools/lockmgmt'; import * as keyboardCommands from './tools/keyboard-commands'; -import * as emuConstants from './tools/emu-constants'; import * as deviceSettingsCommands from './tools/device-settings'; +import * as fsCommands from './tools/fs-commands'; +import * as appCommands from './tools/app-commands'; export const DEFAULT_ADB_PORT = 5037; @@ -138,95 +140,92 @@ export class ADB implements ADBOptions { } // TODO: Group methods from general to corresponding modules - shellChunks = generalMethods.shellChunks; - getAdbWithCorrectAdbPath = generalMethods.getAdbWithCorrectAdbPath; - initAapt = generalMethods.initAapt; - initAapt2 = generalMethods.initAapt2; - initZipAlign = generalMethods.initZipAlign; - initBundletool = generalMethods.initBundletool; - getApiLevel = generalMethods.getApiLevel; - isDeviceConnected = generalMethods.isDeviceConnected; - mkdir = generalMethods.mkdir; - isValidClass = generalMethods.isValidClass; - resolveLaunchableActivity = generalMethods.resolveLaunchableActivity; - forceStop = generalMethods.forceStop; - killPackage = generalMethods.killPackage; - clear = generalMethods.clear; - grantAllPermissions = generalMethods.grantAllPermissions; - grantPermissions = generalMethods.grantPermissions; - grantPermission = generalMethods.grantPermission; - revokePermission = generalMethods.revokePermission; - getGrantedPermissions = generalMethods.getGrantedPermissions; - getDeniedPermissions = generalMethods.getDeniedPermissions; - getReqPermissions = generalMethods.getReqPermissions; - stopAndClear = generalMethods.stopAndClear; - clearTextField = generalMethods.clearTextField; - lock = generalMethods.lock; - back = generalMethods.back; - goToHome = generalMethods.goToHome; - getAdbPath = generalMethods.getAdbPath; - sendTelnetCommand = generalMethods.sendTelnetCommand; - rimraf = generalMethods.rimraf; - push = generalMethods.push; - pull = generalMethods.pull; - processExists = generalMethods.processExists; - getForwardList = generalMethods.getForwardList; - forwardPort = generalMethods.forwardPort; - removePortForward = generalMethods.removePortForward; - getReverseList = generalMethods.getReverseList; - reversePort = generalMethods.reversePort; - removePortReverse = generalMethods.removePortReverse; - forwardAbstractPort = generalMethods.forwardAbstractPort; - ping = generalMethods.ping; - restart = generalMethods.restart; - startLogcat = generalMethods.startLogcat; - stopLogcat = generalMethods.stopLogcat; - getLogcatLogs = generalMethods.getLogcatLogs; - setLogcatListener = generalMethods.setLogcatListener; - removeLogcatListener = generalMethods.removeLogcatListener; - listProcessStatus = generalMethods.listProcessStatus; - getNameByPid = generalMethods.getNameByPid; - getPIDsByName = generalMethods.getPIDsByName; - killProcessesByName = generalMethods.killProcessesByName; - killProcessByPID = generalMethods.killProcessByPID; - broadcastProcessEnd = generalMethods.broadcastProcessEnd; - broadcast = generalMethods.broadcast; - bugreport = generalMethods.bugreport; - screenrecord = generalMethods.screenrecord; - listFeatures = generalMethods.listFeatures; - isStreamedInstallSupported = generalMethods.isStreamedInstallSupported; - isIncrementalInstallSupported = generalMethods.isIncrementalInstallSupported; - takeScreenshot = generalMethods.takeScreenshot; - listPorts = generalMethods.listPorts; + shellChunks = generalCommands.shellChunks; + getAdbWithCorrectAdbPath = generalCommands.getAdbWithCorrectAdbPath; + initAapt = generalCommands.initAapt; + initAapt2 = generalCommands.initAapt2; + initZipAlign = generalCommands.initZipAlign; + initBundletool = generalCommands.initBundletool; + getApiLevel = generalCommands.getApiLevel; + isDeviceConnected = generalCommands.isDeviceConnected; + clearTextField = generalCommands.clearTextField; + lock = generalCommands.lock; + back = generalCommands.back; + goToHome = generalCommands.goToHome; + getAdbPath = generalCommands.getAdbPath; + sendTelnetCommand = generalCommands.sendTelnetCommand; + getForwardList = generalCommands.getForwardList; + forwardPort = generalCommands.forwardPort; + removePortForward = generalCommands.removePortForward; + getReverseList = generalCommands.getReverseList; + reversePort = generalCommands.reversePort; + removePortReverse = generalCommands.removePortReverse; + forwardAbstractPort = generalCommands.forwardAbstractPort; + ping = generalCommands.ping; + restart = generalCommands.restart; + startLogcat = generalCommands.startLogcat; + stopLogcat = generalCommands.stopLogcat; + getLogcatLogs = generalCommands.getLogcatLogs; + setLogcatListener = generalCommands.setLogcatListener; + removeLogcatListener = generalCommands.removeLogcatListener; + bugreport = generalCommands.bugreport; + screenrecord = generalCommands.screenrecord; + listFeatures = generalCommands.listFeatures; + isStreamedInstallSupported = generalCommands.isStreamedInstallSupported; + isIncrementalInstallSupported = generalCommands.isIncrementalInstallSupported; + takeScreenshot = generalCommands.takeScreenshot; + listPorts = generalCommands.listPorts; - executeApksigner = apkSigningMethods.executeApksigner; - signWithDefaultCert = apkSigningMethods.signWithDefaultCert; - signWithCustomCert = apkSigningMethods.signWithCustomCert; - sign = apkSigningMethods.sign; - zipAlignApk = apkSigningMethods.zipAlignApk; - checkApkCert = apkSigningMethods.checkApkCert; - getKeystoreHash = apkSigningMethods.getKeystoreHash; + executeApksigner = signingCommands.executeApksigner; + signWithDefaultCert = signingCommands.signWithDefaultCert; + signWithCustomCert = signingCommands.signWithCustomCert; + sign = signingCommands.sign; + zipAlignApk = signingCommands.zipAlignApk; + checkApkCert = signingCommands.checkApkCert; + getKeystoreHash = signingCommands.getKeystoreHash; - APP_INSTALL_STATE = apkUtilsMethods.APP_INSTALL_STATE; - isAppInstalled = apkUtilsMethods.isAppInstalled; - startUri = apkUtilsMethods.startUri; - startApp = apkUtilsMethods.startApp; - dumpWindows = apkUtilsMethods.dumpWindows; - getFocusedPackageAndActivity = apkUtilsMethods.getFocusedPackageAndActivity; - waitForActivityOrNot = apkUtilsMethods.waitForActivityOrNot; - waitForActivity = apkUtilsMethods.waitForActivity; - waitForNotActivity = apkUtilsMethods.waitForNotActivity; - uninstallApk = apkUtilsMethods.uninstallApk; - installFromDevicePath = apkUtilsMethods.installFromDevicePath; - cacheApk = apkUtilsMethods.cacheApk; - install = apkUtilsMethods.install; - getApplicationInstallState = apkUtilsMethods.getApplicationInstallState; - installOrUpgrade = apkUtilsMethods.installOrUpgrade; - extractStringsFromApk = apkUtilsMethods.extractStringsFromApk; - getApkInfo = apkUtilsMethods.getApkInfo; - getPackageInfo = apkUtilsMethods.getPackageInfo; - pullApk = apkUtilsMethods.pullApk; - activateApp = apkUtilsMethods.activateApp; + grantAllPermissions = appCommands.grantAllPermissions; + grantPermissions = appCommands.grantPermissions; + grantPermission = appCommands.grantPermission; + revokePermission = appCommands.revokePermission; + getGrantedPermissions = appCommands.getGrantedPermissions; + getDeniedPermissions = appCommands.getDeniedPermissions; + getReqPermissions = appCommands.getReqPermissions; + stopAndClear = appCommands.stopAndClear; + isValidClass = appCommands.isValidClass; + resolveLaunchableActivity = appCommands.resolveLaunchableActivity; + forceStop = appCommands.forceStop; + killPackage = appCommands.killPackage; + clear = appCommands.clear; + listProcessStatus = appCommands.listProcessStatus; + getNameByPid = appCommands.getNameByPid; + getPIDsByName = appCommands.getPIDsByName; + killProcessesByName = appCommands.killProcessesByName; + killProcessByPID = appCommands.killProcessByPID; + broadcastProcessEnd = appCommands.broadcastProcessEnd; + broadcast = appCommands.broadcast; + processExists = appCommands.processExists; + readonly APP_INSTALL_STATE = appCommands.APP_INSTALL_STATE; + isAppInstalled = appCommands.isAppInstalled; + startUri = appCommands.startUri; + startApp = appCommands.startApp; + dumpWindows = appCommands.dumpWindows; + getFocusedPackageAndActivity = appCommands.getFocusedPackageAndActivity; + waitForActivityOrNot = appCommands.waitForActivityOrNot; + waitForActivity = appCommands.waitForActivity; + waitForNotActivity = appCommands.waitForNotActivity; + getPackageInfo = appCommands.getPackageInfo; + pullApk = appCommands.pullApk; + activateApp = appCommands.activateApp; + + uninstallApk = apkUtilCommands.uninstallApk; + installFromDevicePath = apkUtilCommands.installFromDevicePath; + cacheApk = apkUtilCommands.cacheApk; + install = apkUtilCommands.install; + installOrUpgrade = apkUtilCommands.installOrUpgrade; + extractStringsFromApk = apkUtilCommands.extractStringsFromApk; + getApkInfo = apkUtilCommands.getApkInfo; + getApplicationInstallState = apkUtilCommands.getApplicationInstallState; hideKeyboard = keyboardCommands.hideKeyboard; isSoftKeyboardPresent = keyboardCommands.isSoftKeyboardPresent; @@ -249,81 +248,78 @@ export class ADB implements ADBOptions { dismissKeyguard = lockManagementCommands.dismissKeyguard; cycleWakeUp = lockManagementCommands.cycleWakeUp; - getSdkBinaryPath = systemCallMethods.getSdkBinaryPath; - getBinaryNameForOS = systemCallMethods.getBinaryNameForOS; - getBinaryFromSdkRoot = systemCallMethods.getBinaryFromSdkRoot; - getBinaryFromPath = systemCallMethods.getBinaryFromPath; - getConnectedDevices = systemCallMethods.getConnectedDevices; - getDevicesWithRetry = systemCallMethods.getDevicesWithRetry; - reconnect = systemCallMethods.reconnect; - restartAdb = systemCallMethods.restartAdb; - killServer = systemCallMethods.killServer; - resetTelnetAuthToken = systemCallMethods.resetTelnetAuthToken; - adbExecEmu = systemCallMethods.adbExecEmu; - EXEC_OUTPUT_FORMAT = systemCallMethods.EXEC_OUTPUT_FORMAT; - adbExec = systemCallMethods.adbExec; - shell = systemCallMethods.shell; - createSubProcess = systemCallMethods.createSubProcess; - getAdbServerPort = systemCallMethods.getAdbServerPort; - getEmulatorPort = systemCallMethods.getEmulatorPort; - getPortFromEmulatorString = systemCallMethods.getPortFromEmulatorString; - getConnectedEmulators = systemCallMethods.getConnectedEmulators; - setEmulatorPort = systemCallMethods.setEmulatorPort; - setDeviceId = systemCallMethods.setDeviceId; - setDevice = systemCallMethods.setDevice; - getRunningAVD = systemCallMethods.getRunningAVD; - getRunningAVDWithRetry = systemCallMethods.getRunningAVDWithRetry; - killAllEmulators = systemCallMethods.killAllEmulators; - killEmulator = systemCallMethods.killEmulator; - launchAVD = systemCallMethods.launchAVD; - getVersion = systemCallMethods.getVersion; - waitForEmulatorReady = systemCallMethods.waitForEmulatorReady; - waitForDevice = systemCallMethods.waitForDevice; - reboot = systemCallMethods.reboot; - changeUserPrivileges = systemCallMethods.changeUserPrivileges; - root = systemCallMethods.root; - unroot = systemCallMethods.unroot; - isRoot = systemCallMethods.isRoot; - fileExists = systemCallMethods.fileExists; - ls = systemCallMethods.ls; - fileSize = systemCallMethods.fileSize; - installMitmCertificate = systemCallMethods.installMitmCertificate; - isMitmCertificateInstalled = systemCallMethods.isMitmCertificateInstalled; + getSdkBinaryPath = systemCommands.getSdkBinaryPath; + getBinaryNameForOS = systemCommands.getBinaryNameForOS; + getBinaryFromSdkRoot = systemCommands.getBinaryFromSdkRoot; + getBinaryFromPath = systemCommands.getBinaryFromPath; + getConnectedDevices = systemCommands.getConnectedDevices; + getDevicesWithRetry = systemCommands.getDevicesWithRetry; + reconnect = systemCommands.reconnect; + restartAdb = systemCommands.restartAdb; + killServer = systemCommands.killServer; + resetTelnetAuthToken = systemCommands.resetTelnetAuthToken; + adbExecEmu = systemCommands.adbExecEmu; + EXEC_OUTPUT_FORMAT = systemCommands.EXEC_OUTPUT_FORMAT; + adbExec = systemCommands.adbExec; + shell = systemCommands.shell; + createSubProcess = systemCommands.createSubProcess; + getAdbServerPort = systemCommands.getAdbServerPort; + getEmulatorPort = systemCommands.getEmulatorPort; + getPortFromEmulatorString = systemCommands.getPortFromEmulatorString; + getConnectedEmulators = systemCommands.getConnectedEmulators; + setEmulatorPort = systemCommands.setEmulatorPort; + setDeviceId = systemCommands.setDeviceId; + setDevice = systemCommands.setDevice; + getRunningAVD = systemCommands.getRunningAVD; + getRunningAVDWithRetry = systemCommands.getRunningAVDWithRetry; + killAllEmulators = systemCommands.killAllEmulators; + killEmulator = systemCommands.killEmulator; + launchAVD = systemCommands.launchAVD; + getVersion = systemCommands.getVersion; + waitForEmulatorReady = systemCommands.waitForEmulatorReady; + waitForDevice = systemCommands.waitForDevice; + reboot = systemCommands.reboot; + changeUserPrivileges = systemCommands.changeUserPrivileges; + root = systemCommands.root; + unroot = systemCommands.unroot; + isRoot = systemCommands.isRoot; + installMitmCertificate = systemCommands.installMitmCertificate; + isMitmCertificateInstalled = systemCommands.isMitmCertificateInstalled; - execBundletool = apksUtilsMethods.execBundletool; - getDeviceSpec = apksUtilsMethods.getDeviceSpec; - installMultipleApks = apksUtilsMethods.installMultipleApks; - installApks = apksUtilsMethods.installApks; - extractBaseApk = apksUtilsMethods.extractBaseApk; - extractLanguageApk = apksUtilsMethods.extractLanguageApk; - isTestPackageOnlyError = apksUtilsMethods.isTestPackageOnlyError; + execBundletool = apksUtilCommands.execBundletool; + getDeviceSpec = apksUtilCommands.getDeviceSpec; + installMultipleApks = apksUtilCommands.installMultipleApks; + installApks = apksUtilCommands.installApks; + extractBaseApk = apksUtilCommands.extractBaseApk; + extractLanguageApk = apksUtilCommands.extractLanguageApk; + isTestPackageOnlyError = apksUtilCommands.isTestPackageOnlyError; - packageAndLaunchActivityFromManifest = manifestMethods.packageAndLaunchActivityFromManifest; - targetSdkVersionFromManifest = manifestMethods.targetSdkVersionFromManifest; - targetSdkVersionUsingPKG = manifestMethods.targetSdkVersionUsingPKG; - compileManifest = manifestMethods.compileManifest; - insertManifest = manifestMethods.insertManifest; - hasInternetPermissionFromManifest = manifestMethods.hasInternetPermissionFromManifest; + packageAndLaunchActivityFromManifest = manifestCommands.packageAndLaunchActivityFromManifest; + targetSdkVersionFromManifest = manifestCommands.targetSdkVersionFromManifest; + targetSdkVersionUsingPKG = manifestCommands.targetSdkVersionUsingPKG; + compileManifest = manifestCommands.compileManifest; + insertManifest = manifestCommands.insertManifest; + hasInternetPermissionFromManifest = manifestCommands.hasInternetPermissionFromManifest; - extractUniversalApk = aabUtilsMethods.extractUniversalApk; + extractUniversalApk = aabUtilCommands.extractUniversalApk; - isEmulatorConnected = emuMethods.isEmulatorConnected; - verifyEmulatorConnected = emuMethods.verifyEmulatorConnected; - fingerprint = emuMethods.fingerprint; - rotate = emuMethods.rotate; - powerAC = emuMethods.powerAC; - sensorSet = emuMethods.sensorSet; - powerCapacity = emuMethods.powerCapacity; - powerOFF = emuMethods.powerOFF; - sendSMS = emuMethods.sendSMS; - gsmCall = emuMethods.gsmCall; - gsmSignal = emuMethods.gsmSignal; - gsmVoice = emuMethods.gsmVoice; - networkSpeed = emuMethods.networkSpeed; - execEmuConsoleCommand = emuMethods.execEmuConsoleCommand; - getEmuVersionInfo = emuMethods.getEmuVersionInfo; - getEmuImageProperties = emuMethods.getEmuImageProperties; - checkAvdExist = emuMethods.checkAvdExist; + isEmulatorConnected = emuCommands.isEmulatorConnected; + verifyEmulatorConnected = emuCommands.verifyEmulatorConnected; + fingerprint = emuCommands.fingerprint; + rotate = emuCommands.rotate; + powerAC = emuCommands.powerAC; + sensorSet = emuCommands.sensorSet; + powerCapacity = emuCommands.powerCapacity; + powerOFF = emuCommands.powerOFF; + sendSMS = emuCommands.sendSMS; + gsmCall = emuCommands.gsmCall; + gsmSignal = emuCommands.gsmSignal; + gsmVoice = emuCommands.gsmVoice; + networkSpeed = emuCommands.networkSpeed; + execEmuConsoleCommand = emuCommands.execEmuConsoleCommand; + getEmuVersionInfo = emuCommands.getEmuVersionInfo; + getEmuImageProperties = emuCommands.getEmuImageProperties; + checkAvdExist = emuCommands.checkAvdExist; readonly POWER_AC_STATES = emuConstants.POWER_AC_STATES; readonly GSM_CALL_ACTIONS = emuConstants.GSM_CALL_ACTIONS; readonly GSM_VOICE_STATES = emuConstants.GSM_VOICE_STATES; @@ -331,6 +327,14 @@ export class ADB implements ADBOptions { readonly NETWORK_SPEED = emuConstants.NETWORK_SPEED; readonly SENSORS = emuConstants.SENSORS; + fileExists = fsCommands.fileExists; + ls = fsCommands.ls; + fileSize = fsCommands.fileSize; + rimraf = fsCommands.rimraf; + push = fsCommands.push; + pull = fsCommands.pull; + mkdir = fsCommands.mkdir; + getDeviceProperty = deviceSettingsCommands.getDeviceProperty; setDeviceProperty = deviceSettingsCommands.setDeviceProperty; getDeviceSysLanguage = deviceSettingsCommands.getDeviceSysLanguage; diff --git a/lib/tools/adb-commands.js b/lib/tools/adb-commands.js index 6436b763..cbd7446d 100644 --- a/lib/tools/adb-commands.js +++ b/lib/tools/adb-commands.js @@ -1,27 +1,13 @@ import log from '../logger.js'; -import { - extractMatchingPermissions, parseLaunchableActivityNames, matchComponentName, -} from '../helpers.js'; -import path from 'path'; import _ from 'lodash'; -import { fs, util, tempDir } from '@appium/support'; +import { fs, util } from '@appium/support'; import { EOL } from 'os'; import { Logcat } from '../logcat'; -import { sleep, waitForCondition } from 'asyncbox'; import { SubProcess, exec } from 'teen_process'; -import B from 'bluebird'; +import { waitForCondition } from 'asyncbox'; const MAX_SHELL_BUFFER_LENGTH = 1000; -const NOT_CHANGEABLE_PERM_ERROR = /not a changeable permission type/i; -const IGNORED_PERM_ERRORS = [ - NOT_CHANGEABLE_PERM_ERROR, - /Unknown permission/i, -]; -const MAX_PGREP_PATTERN_LEN = 15; -const PID_COLUMN_TITLE = 'PID'; -const PROCESS_NAME_COLUMN_TITLE = 'NAME'; -const PS_TITLE_PATTERN = new RegExp(`^(.*\\b${PID_COLUMN_TITLE}\\b.*\\b${PROCESS_NAME_COLUMN_TITLE}\\b.*)$`, 'm'); -const MIN_API_LEVEL_WITH_PERMS_SUPPORT = 23; + /** * Creates chunks for the given arguments and executes them in `adb shell`. @@ -176,298 +162,6 @@ export async function isDeviceConnected () { return devices.length > 0; } -/** - * Recursively create a new folder on the device under test. - * - * @this {import('../adb.js').ADB} - * @param {string} remotePath - The new path to be created. - * @return {Promise} mkdir command output. - */ -export async function mkdir (remotePath) { - return await this.shell(['mkdir', '-p', remotePath]); -} - -/** - * Verify whether the given argument is a - * valid class name. - * - * @this {import('../adb.js').ADB} - * @param {string} classString - The actual class name to be verified. - * @return {boolean} The result of Regexp.exec operation - * or _null_ if no matches are found. - */ -export function isValidClass (classString) { - // some.package/some.package.Activity - return !!matchComponentName(classString); -} - -/** - * Fetches the fully qualified name of the launchable activity for the - * given package. It is expected the package is already installed on - * the device under test. - * - * @this {import('../adb.js').ADB} - * @param {string} pkg - The target package identifier - * @param {import('./types').ResolveActivityOptions} opts - * @return {Promise} Fully qualified name of the launchable activity - * @throws {Error} If there was an error while resolving the activity name - */ -export async function resolveLaunchableActivity (pkg, opts = {}) { - const { preferCmd = true } = opts; - if (!preferCmd || await this.getApiLevel() < 24) { - const stdout = await this.shell(['dumpsys', 'package', pkg]); - const names = parseLaunchableActivityNames(stdout); - if (_.isEmpty(names)) { - log.debug(stdout); - throw new Error(`Unable to resolve the launchable activity of '${pkg}'. Is it installed on the device?`); - } - if (names.length === 1) { - return names[0]; - } - - const tmpRoot = await tempDir.openDir(); - try { - const tmpApp = await this.pullApk(pkg, tmpRoot); - const {apkActivity} = await this.packageAndLaunchActivityFromManifest(tmpApp); - return /** @type {string} */ (apkActivity); - } catch (e) { - const err = /** @type {Error} */ (e); - log.debug(err.stack); - log.warn(`Unable to resolve the launchable activity of '${pkg}'. ` + - `The very first match of the dumpsys output is going to be used. ` + - `Original error: ${err.message}`); - return names[0]; - } finally { - await fs.rimraf(tmpRoot); - } - } - const {stdout, stderr} = await this.shell(['cmd', 'package', 'resolve-activity', '--brief', pkg], { - outputFormat: this.EXEC_OUTPUT_FORMAT.FULL - }); - for (const line of (stdout || '').split('\n').map(_.trim)) { - if (this.isValidClass(line)) { - return line; - } - } - throw new Error( - `Unable to resolve the launchable activity of '${pkg}'. Original error: ${stderr || stdout}` - ); -} - -/** - * Force application to stop on the device under test. - * - * @this {import('../adb.js').ADB} - * @param {string} pkg - The package name to be stopped. - * @return {Promise} The output of the corresponding adb command. - */ -export async function forceStop (pkg) { - return await this.shell(['am', 'force-stop', pkg]); -} - -/** - * Kill application - * - * @this {import('../adb.js').ADB} - * @param {string} pkg - The package name to be stopped. - * @return {Promise} The output of the corresponding adb command. - */ -export async function killPackage (pkg) { - return await this.shell(['am', 'kill', pkg]); -} - -/** - * Clear the user data of the particular application on the device - * under test. - * - * @this {import('../adb.js').ADB} - * @param {string} pkg - The package name to be cleared. - * @return {Promise} The output of the corresponding adb command. - */ -export async function clear (pkg) { - return await this.shell(['pm', 'clear', pkg]); -} - -/** - * Grant all permissions requested by the particular package. - * This method is only useful on Android 6.0+ and for applications - * that support components-based permissions setting. - * - * @this {import('../adb.js').ADB} - * @param {string} pkg - The package name to be processed. - * @param {string} [apk] - The path to the actual apk file. - * @throws {Error} If there was an error while granting permissions - */ -export async function grantAllPermissions (pkg, apk) { - const apiLevel = await this.getApiLevel(); - let targetSdk = 0; - let dumpsysOutput = null; - try { - if (!apk) { - /** - * If apk not provided, considering apk already installed on the device - * and fetching targetSdk using package name. - */ - dumpsysOutput = await this.shell(['dumpsys', 'package', pkg]); - targetSdk = await this.targetSdkVersionUsingPKG(pkg, dumpsysOutput); - } else { - targetSdk = await this.targetSdkVersionFromManifest(apk); - } - } catch { - //avoiding logging error stack, as calling library function would have logged - log.warn(`Ran into problem getting target SDK version; ignoring...`); - } - if (apiLevel >= MIN_API_LEVEL_WITH_PERMS_SUPPORT && targetSdk >= MIN_API_LEVEL_WITH_PERMS_SUPPORT) { - /** - * If the device is running Android 6.0(API 23) or higher, and your app's target SDK is 23 or higher: - * The app has to list the permissions in the manifest. - * refer: https://developer.android.com/training/permissions/requesting.html - */ - dumpsysOutput = dumpsysOutput || await this.shell(['dumpsys', 'package', pkg]); - const requestedPermissions = await this.getReqPermissions(pkg, dumpsysOutput); - const grantedPermissions = await this.getGrantedPermissions(pkg, dumpsysOutput); - const permissionsToGrant = _.difference(requestedPermissions, grantedPermissions); - if (_.isEmpty(permissionsToGrant)) { - log.info(`${pkg} contains no permissions available for granting`); - } else { - await this.grantPermissions(pkg, permissionsToGrant); - } - } else if (targetSdk < MIN_API_LEVEL_WITH_PERMS_SUPPORT) { - log.info(`It is only possible to grant permissions in runtime for ` + - `apps whose targetSdkVersion in the manifest is set to ${MIN_API_LEVEL_WITH_PERMS_SUPPORT} or above. ` + - `The current ${pkg} targetSdkVersion is ${targetSdk || 'unset'}.`); - } else if (apiLevel < MIN_API_LEVEL_WITH_PERMS_SUPPORT) { - log.info(`The device's OS API level is ${apiLevel}. ` + - `It is only possible to grant permissions on devices running Android 6 or above.`); - } -} - -/** - * Grant multiple permissions for the particular package. - * This call is more performant than `grantPermission` one, since it combines - * multiple `adb shell` calls into a single command. - * - * @this {import('../adb.js').ADB} - * @param {string} pkg - The package name to be processed. - * @param {Array} permissions - The list of permissions to be granted. - * @throws {Error} If there was an error while changing permissions. - */ -export async function grantPermissions (pkg, permissions) { - // As it consumes more time for granting each permission, - // trying to grant all permission by forming equivalent command. - // Also, it is necessary to split long commands into chunks, since the maximum length of - // adb shell buffer is limited - log.debug(`Granting permissions ${JSON.stringify(permissions)} to '${pkg}'`); - try { - await this.shellChunks((perm) => ['pm', 'grant', pkg, perm], permissions); - } catch (e) { - const err = /** @type {import('teen_process').ExecError} */(e); - if (!IGNORED_PERM_ERRORS.some((pattern) => pattern.test(err.stderr || err.message))) { - throw err; - } - } -} - -/** - * Grant single permission for the particular package. - * - * @this {import('../adb.js').ADB} - * @param {string} pkg - The package name to be processed. - * @param {string} permission - The full name of the permission to be granted. - * @throws {Error} If there was an error while changing permissions. - */ -export async function grantPermission (pkg, permission) { - try { - await this.shell(['pm', 'grant', pkg, permission]); - } catch (e) { - const err = /** @type {import('teen_process').ExecError} */(e); - if (!NOT_CHANGEABLE_PERM_ERROR.test(err.stderr || err.message)) { - throw err; - } - } -} - -/** - * Revoke single permission from the particular package. - * - * @this {import('../adb.js').ADB} - * @param {string} pkg - The package name to be processed. - * @param {string} permission - The full name of the permission to be revoked. - * @throws {Error} If there was an error while changing permissions. - */ -export async function revokePermission (pkg, permission) { - try { - await this.shell(['pm', 'revoke', pkg, permission]); - } catch (e) { - const err = /** @type {import('teen_process').ExecError} */(e); - if (!NOT_CHANGEABLE_PERM_ERROR.test(err.stderr || err.message)) { - throw err; - } - } -} - -/** - * Retrieve the list of granted permissions for the particular package. - * - * @this {import('../adb.js').ADB} - * @param {string} pkg - The package name to be processed. - * @param {string?} [cmdOutput=null] - Optional parameter containing command output of - * _dumpsys package_ command. It may speed up the method execution. - * @return {Promise} The list of granted permissions or an empty list. - * @throws {Error} If there was an error while changing permissions. - */ -export async function getGrantedPermissions (pkg, cmdOutput = null) { - log.debug('Retrieving granted permissions'); - const stdout = cmdOutput || await this.shell(['dumpsys', 'package', pkg]); - return extractMatchingPermissions(stdout, ['install', 'runtime'], true); -} - -/** - * Retrieve the list of denied permissions for the particular package. - * - * @this {import('../adb.js').ADB} - * @param {string} pkg - The package name to be processed. - * @param {string?} [cmdOutput=null] - Optional parameter containing command output of - * _dumpsys package_ command. It may speed up the method execution. - * @return {Promise} The list of denied permissions or an empty list. - */ -export async function getDeniedPermissions (pkg, cmdOutput = null) { - log.debug('Retrieving denied permissions'); - const stdout = cmdOutput || await this.shell(['dumpsys', 'package', pkg]); - return extractMatchingPermissions(stdout, ['install', 'runtime'], false); -} - -/** - * Retrieve the list of requested permissions for the particular package. - * - * @this {import('../adb.js').ADB} - * @param {string} pkg - The package name to be processed. - * @param {string?} [cmdOutput=null] - Optional parameter containing command output of - * _dumpsys package_ command. It may speed up the method execution. - * @return {Promise} The list of requested permissions or an empty list. - */ -export async function getReqPermissions (pkg, cmdOutput = null) { - log.debug('Retrieving requested permissions'); - const stdout = cmdOutput || await this.shell(['dumpsys', 'package', pkg]); - return extractMatchingPermissions(stdout, ['requested']); -} - -/** - * Stop the particular package if it is running and clears its application data. - * - * @this {import('../adb.js').ADB} - * @param {string} pkg - The package name to be processed. - */ -export async function stopAndClear (pkg) { - try { - await this.forceStop(pkg); - await this.clear(pkg); - } catch (e) { - const err = /** @type {Error} */ (e); - throw new Error(`Cannot stop and clear ${pkg}. Original error: ${err.message}`); - } -} - /** * Clear the active text field on the device under test by sending * special keyevents to it. @@ -554,62 +248,6 @@ export async function sendTelnetCommand (command) { return await this.execEmuConsoleCommand(command, {port: await this.getEmulatorPort()}); } -/** - * Forcefully recursively remove a path on the device under test. - * Be careful while calling this method. - * - * @this {import('../adb.js').ADB} - * @param {string} path - The path to be removed recursively. - */ -export async function rimraf (path) { - await this.shell(['rm', '-rf', path]); -} - -/** - * Send a file to the device under test. - * - * @this {import('../adb.js').ADB} - * @param {string} localPath - The path to the file on the local file system. - * @param {string} remotePath - The destination path on the remote device. - * @param {object} [opts] - Additional options mapping. See - * https://github.com/appium/node-teen_process, - * _exec_ method options, for more information about available - * options. - */ -export async function push (localPath, remotePath, opts) { - await this.mkdir(path.posix.dirname(remotePath)); - await this.adbExec(['push', localPath, remotePath], opts); -} - -/** - * Receive a file from the device under test. - * - * @this {import('../adb.js').ADB} - * @param {string} remotePath - The source path on the remote device. - * @param {string} localPath - The destination path to the file on the local file system. - * @param {import('teen_process').TeenProcessExecOptions} [opts={}] - Additional options mapping. See - * https://github.com/appium/node-teen_process, - * _exec_ method options, for more information about available - * options. - */ -export async function pull (remotePath, localPath, opts = {}) { - // pull folder can take more time, increasing time out to 60 secs - await this.adbExec(['pull', remotePath, localPath], {...opts, timeout: opts.timeout ?? 60000}); -} - -/** - * Check whether the process with the particular name is running on the device - * under test. - * - * @this {import('../adb.js').ADB} - * @param {string} processName - The name of the process to be checked. - * @return {Promise} True if the given process is running. - * @throws {Error} If the given process name is not a valid class name. - */ -export async function processExists (processName) { - return !_.isEmpty(await this.getPIDsByName(processName)); -} - /** * Get TCP port forwarding with adb on the device under test. * @@ -818,250 +456,6 @@ export function removeLogcatListener (listener) { this.logcat.removeListener('output', listener); } -/** - * At some point of time Google has changed the default `ps` behaviour, so it only - * lists processes that belong to the current shell user rather to all - * users. It is necessary to execute ps with -A command line argument - * to mimic the previous behaviour. - * - * @this {import('../adb.js').ADB} - * @returns {Promise} the output of `ps` command where all processes are included - */ -export async function listProcessStatus () { - if (!_.isBoolean(this._doesPsSupportAOption)) { - try { - this._doesPsSupportAOption = /^-A\b/m.test(await this.shell(['ps', '--help'])); - } catch (e) { - log.debug((/** @type {Error} */ (e)).stack); - this._doesPsSupportAOption = false; - } - } - return await this.shell(this._doesPsSupportAOption ? ['ps', '-A'] : ['ps']); -} - -/** - * Returns process name for the given process identifier - * - * @this {import('../adb.js').ADB} - * @param {string|number} pid - The valid process identifier - * @throws {Error} If the given PID is either invalid or is not present - * in the active processes list - * @returns {Promise} The process name - */ -export async function getNameByPid (pid) { - // @ts-ignore This validation works as expected - if (isNaN(pid)) { - throw new Error(`The PID value must be a valid number. '${pid}' is given instead`); - } - pid = parseInt(`${pid}`, 10); - - const stdout = await this.listProcessStatus(); - const titleMatch = PS_TITLE_PATTERN.exec(stdout); - if (!titleMatch) { - log.debug(stdout); - throw new Error(`Could not get the process name for PID '${pid}'`); - } - const allTitles = titleMatch[1].trim().split(/\s+/); - const pidIndex = allTitles.indexOf(PID_COLUMN_TITLE); - // it might not be stable to take NAME by index, because depending on the - // actual SDK the ps output might not contain an abbreviation for the S flag: - // USER PID PPID VSIZE RSS WCHAN PC NAME - // USER PID PPID VSIZE RSS WCHAN PC S NAME - const nameOffset = allTitles.indexOf(PROCESS_NAME_COLUMN_TITLE) - allTitles.length; - const pidRegex = new RegExp(`^(.*\\b${pid}\\b.*)$`, 'gm'); - let matchedLine; - while ((matchedLine = pidRegex.exec(stdout))) { - const items = matchedLine[1].trim().split(/\s+/); - if (parseInt(items[pidIndex], 10) === pid && items[items.length + nameOffset]) { - return items[items.length + nameOffset]; - } - } - log.debug(stdout); - throw new Error(`Could not get the process name for PID '${pid}'`); -} - -/** - * Get the list of process ids for the particular process on the device under test. - * - * @this {import('../adb.js').ADB} - * @param {string} name - The part of process name. - * @return {Promise} The list of matched process IDs or an empty list. - * @throws {Error} If the passed process name is not a valid one - */ -export async function getPIDsByName (name) { - log.debug(`Getting IDs of all '${name}' processes`); - if (!this.isValidClass(name)) { - throw new Error(`Invalid process name: '${name}'`); - } - // https://github.com/appium/appium/issues/13567 - if (await this.getApiLevel() >= 23) { - if (!_.isBoolean(this._isPgrepAvailable)) { - // pgrep is in priority, since pidof has been reported of having bugs on some platforms - const pgrepOutput = _.trim(await this.shell(['pgrep --help; echo $?'])); - this._isPgrepAvailable = parseInt(`${_.last(pgrepOutput.split(/\s+/))}`, 10) === 0; - if (this._isPgrepAvailable) { - this._canPgrepUseFullCmdLineSearch = /^-f\b/m.test(pgrepOutput); - } else { - this._isPidofAvailable = parseInt(await this.shell(['pidof --help > /dev/null; echo $?']), 10) === 0; - } - } - if (this._isPgrepAvailable || this._isPidofAvailable) { - const shellCommand = this._isPgrepAvailable - ? (this._canPgrepUseFullCmdLineSearch - ? ['pgrep', '-f', _.escapeRegExp(`([[:blank:]]|^)${name}(:[a-zA-Z0-9_-]+)?([[:blank:]]|$)`)] - // https://github.com/appium/appium/issues/13872 - : [`pgrep ^${_.escapeRegExp(name.slice(-MAX_PGREP_PATTERN_LEN))}$ ` + - `|| pgrep ^${_.escapeRegExp(name.slice(0, MAX_PGREP_PATTERN_LEN))}$`]) - : ['pidof', name]; - try { - return (await this.shell(shellCommand)) - .split(/\s+/) - .map((x) => parseInt(x, 10)) - .filter((x) => _.isInteger(x)); - } catch (e) { - const err = /** @type {import('teen_process').ExecError} */ (e); - // error code 1 is returned if the utility did not find any processes - // with the given name - if (err.code !== 1) { - throw new Error(`Could not extract process ID of '${name}': ${err.message}`); - } - if (_.includes(err.stderr || err.stdout, 'syntax error')) { - log.warn(`Got an unexpected response from the shell interpreter: ${err.stderr || err.stdout}`); - } else { - return []; - } - } - } - } - - log.debug('Using ps-based PID detection'); - const stdout = await this.listProcessStatus(); - const titleMatch = PS_TITLE_PATTERN.exec(stdout); - if (!titleMatch) { - log.debug(stdout); - throw new Error(`Could not extract PID of '${name}' from ps output`); - } - const allTitles = titleMatch[1].trim().split(/\s+/); - const pidIndex = allTitles.indexOf(PID_COLUMN_TITLE); - const pids = []; - const processNameRegex = new RegExp(`^(.*\\b\\d+\\b.*\\b${_.escapeRegExp(name)}\\b.*)$`, 'gm'); - let matchedLine; - while ((matchedLine = processNameRegex.exec(stdout))) { - const items = matchedLine[1].trim().split(/\s+/); - // @ts-ignore This validation worka as expected - if (pidIndex >= allTitles.length || isNaN(items[pidIndex])) { - log.debug(stdout); - throw new Error(`Could not extract PID of '${name}' from '${matchedLine[1].trim()}'`); - } - pids.push(parseInt(items[pidIndex], 10)); - } - return pids; -} - -/** - * Get the list of process ids for the particular process on the device under test. - * - * @this {import('../adb.js').ADB} - * @param {string} name - The part of process name. - */ -export async function killProcessesByName (name) { - try { - log.debug(`Attempting to kill all ${name} processes`); - const pids = await this.getPIDsByName(name); - if (_.isEmpty(pids)) { - log.info(`No '${name}' process has been found`); - } else { - await B.all(pids.map((p) => this.killProcessByPID(p))); - } - } catch (e) { - const err = /** @type {Error} */ (e); - throw new Error(`Unable to kill ${name} processes. Original error: ${err.message}`); - } -} - -/** - * Kill the particular process on the device under test. - * The current user is automatically switched to root if necessary in order - * to properly kill the process. - * - * @this {import('../adb.js').ADB} - * @param {string|number} pid - The ID of the process to be killed. - * @throws {Error} If the process cannot be killed. - */ -export async function killProcessByPID (pid) { - log.debug(`Attempting to kill process ${pid}`); - const noProcessFlag = 'No such process'; - try { - // Check if the process exists and throw an exception otherwise - await this.shell(['kill', `${pid}`]); - } catch (e) { - const err = /** @type {import('teen_process').ExecError} */ (e); - if (_.includes(err.stderr, noProcessFlag)) { - return; - } - if (!_.includes(err.stderr, 'Operation not permitted')) { - throw err; - } - log.info(`Cannot kill PID ${pid} due to insufficient permissions. Retrying as root`); - try { - await this.shell(['kill', `${pid}`], { - privileged: true - }); - } catch (e1) { - const err1 = /** @type {import('teen_process').ExecError} */ (e1); - if (_.includes(err1.stderr, noProcessFlag)) { - return; - } - throw err1; - } - } -} - -/** - * Broadcast process killing on the device under test. - * - * @this {import('../adb.js').ADB} - * @param {string} intent - The name of the intent to broadcast to. - * @param {string} processName - The name of the killed process. - * @throws {error} If the process was not killed. - */ -export async function broadcastProcessEnd (intent, processName) { - // start the broadcast without waiting for it to finish. - this.broadcast(intent); - // wait for the process to end - let start = Date.now(); - let timeoutMs = 40000; - try { - while ((Date.now() - start) < timeoutMs) { - if (await this.processExists(processName)) { - // cool down - await sleep(400); - continue; - } - return; - } - throw new Error(`Process never died within ${timeoutMs} ms`); - } catch (e) { - const err = /** @type {Error} */ (e); - throw new Error(`Unable to broadcast process end. Original error: ${err.message}`); - } -} - -/** - * Broadcast a message to the given intent. - * - * @this {import('../adb.js').ADB} - * @param {string} intent - The name of the intent to broadcast to. - * @throws {error} If intent name is not a valid class name. - */ -export async function broadcast (intent) { - if (!this.isValidClass(intent)) { - throw new Error(`Invalid intent ${intent}`); - } - log.debug(`Broadcasting: ${intent}`); - await this.shell(['am', 'broadcast', '-a', intent]); -} - /** * Retrieve the `adb bugreport` command output. This * operation may take up to several minutes. diff --git a/lib/tools/apk-utils.js b/lib/tools/apk-utils.js index ab432d9a..b77cf860 100644 --- a/lib/tools/apk-utils.js +++ b/lib/tools/apk-utils.js @@ -1,374 +1,19 @@ import { - buildStartCmd, APKS_EXTENSION, buildInstallArgs, + APKS_EXTENSION, buildInstallArgs, APK_INSTALL_TIMEOUT, DEFAULT_ADB_EXEC_TIMEOUT, parseAaptStrings, parseAapt2Strings, formatConfigMarker, - escapeShellArg, readPackageManifest + readPackageManifest } from '../helpers.js'; import { exec } from 'teen_process'; import log from '../logger.js'; import path from 'path'; import _ from 'lodash'; -import { waitForCondition } from 'asyncbox'; import { fs, util, mkdirp, timing } from '@appium/support'; import * as semver from 'semver'; import os from 'os'; import { LRUCache } from 'lru-cache'; -/** @type {import('./types').StringRecord} */ -export const APP_INSTALL_STATE = { - UNKNOWN: 'unknown', - NOT_INSTALLED: 'notInstalled', - NEWER_VERSION_INSTALLED: 'newerVersionInstalled', - SAME_VERSION_INSTALLED: 'sameVersionInstalled', - OLDER_VERSION_INSTALLED: 'olderVersionInstalled', -}; export const REMOTE_CACHE_ROOT = '/data/local/tmp/appium_cache'; -const RESOLVER_ACTIVITY_NAME = 'android/com.android.internal.app.ResolverActivity'; - -/** - * Check whether the particular package is present on the device under test. - * - * @this {import('../adb.js').ADB} - * @param {string} pkg - The name of the package to check. - * @param {import('./types').IsAppInstalledOptions} [opts={}] - * @return {Promise} True if the package is installed. - */ -export async function isAppInstalled (pkg, opts = {}) { - const { - user, - } = opts; - - log.debug(`Getting install status for ${pkg}`); - /** @type {boolean} */ - let isInstalled; - if (await this.getApiLevel() < 26) { - try { - const cmd = ['pm', 'path']; - if (util.hasValue(user)) { - cmd.push('--user', user); - } - cmd.push(pkg); - const stdout = await this.shell(cmd); - isInstalled = /^package:/m.test(stdout); - } catch { - isInstalled = false; - } - } else { - const cmd = ['cmd', 'package', 'list', 'packages']; - if (util.hasValue(user)) { - cmd.push('--user', user); - } - /** @type {string} */ - let stdout; - try { - stdout = await this.shell(cmd); - } catch (e) { - // https://github.com/appium/appium-uiautomator2-driver/issues/810 - if (_.includes(e.stderr || e.stdout || e.message, 'access user') && _.isEmpty(user)) { - stdout = await this.shell([...cmd, '--user', '0']); - } else { - throw e; - } - } - isInstalled = new RegExp(`^package:${_.escapeRegExp(pkg)}$`, 'm').test(stdout); - } - log.debug(`'${pkg}' is${!isInstalled ? ' not' : ''} installed`); - return isInstalled; -} - -/** - * Start the particular URI on the device under test. - * - * @this {import('../adb.js').ADB} - * @param {string} uri - The name of URI to start. - * @param {string?} [pkg=null] - The name of the package to start the URI with. - * @param {import('./types').StartUriOptions} [opts={}] - */ -export async function startUri (uri, pkg = null, opts = {}) { - const { - waitForLaunch = true, - } = opts; - - if (!uri) { - throw new Error('URI argument is required'); - } - - const args = ['am', 'start']; - if (waitForLaunch) { - args.push('-W'); - } - args.push('-a', 'android.intent.action.VIEW', - '-d', escapeShellArg(uri)); - if (pkg) { - args.push(pkg); - } - - try { - const res = await this.shell(args); - if (res.toLowerCase().includes('unable to resolve intent')) { - throw new Error(res); - } - } catch (e) { - throw new Error(`Error attempting to start URI. Original error: ${e}`); - } -} - -/** - * Start the particular package/activity on the device under test. - * - * @this {import('../adb.js').ADB} - * @param {import('./types').StartAppOptions} startAppOptions - Startup options mapping. - * @return {Promise} The output of the corresponding adb command. - * @throws {Error} If there is an error while executing the activity - */ -export async function startApp (startAppOptions) { - if (!startAppOptions.pkg || !(startAppOptions.activity || startAppOptions.action)) { - throw new Error('pkg, and activity or intent action, are required to start an application'); - } - - startAppOptions = _.clone(startAppOptions); - if (startAppOptions.activity) { - startAppOptions.activity = startAppOptions.activity.replace('$', '\\$'); - } - // initializing defaults - _.defaults(startAppOptions, { - waitPkg: startAppOptions.pkg, - waitForLaunch: true, - waitActivity: false, - retry: true, - stopApp: true - }); - // preventing null waitpkg - startAppOptions.waitPkg = startAppOptions.waitPkg || startAppOptions.pkg; - - const apiLevel = await this.getApiLevel(); - const cmd = buildStartCmd(startAppOptions, apiLevel); - const intentName = `${startAppOptions.action}${startAppOptions.optionalIntentArguments - ? ' ' + startAppOptions.optionalIntentArguments - : ''}`; - try { - const shellOpts = {}; - if (_.isInteger(startAppOptions.waitDuration) - // @ts-ignore waitDuration is an integer here - && startAppOptions.waitDuration >= 0) { - shellOpts.timeout = startAppOptions.waitDuration; - } - const stdout = await this.shell(cmd, shellOpts); - if (stdout.includes('Error: Activity class') && stdout.includes('does not exist')) { - if (startAppOptions.retry && startAppOptions.activity && !startAppOptions.activity.startsWith('.')) { - log.debug(`We tried to start an activity that doesn't exist, ` + - `retrying with '.${startAppOptions.activity}' activity name`); - startAppOptions.activity = `.${startAppOptions.activity}`; - startAppOptions.retry = false; - return await this.startApp(startAppOptions); - } - throw new Error(`Activity name '${startAppOptions.activity}' used to start the app doesn't ` + - `exist or cannot be launched! Make sure it exists and is a launchable activity`); - } else if (stdout.includes('Error: Intent does not match any activities') - || stdout.includes('Error: Activity not started, unable to resolve Intent')) { - throw new Error(`Activity for intent '${intentName}' used to start the app doesn't ` + - `exist or cannot be launched! Make sure it exists and is a launchable activity`); - } else if (stdout.includes('java.lang.SecurityException')) { - // if the app is disabled on a real device it will throw a security exception - throw new Error(`The permission to start '${startAppOptions.activity}' activity has been denied.` + - `Make sure the activity/package names are correct.`); - } - if (startAppOptions.waitActivity) { - await this.waitForActivity(startAppOptions.waitPkg, startAppOptions.waitActivity, startAppOptions.waitDuration); - } - return stdout; - } catch (e) { - const appDescriptor = startAppOptions.pkg || intentName; - throw new Error(`Cannot start the '${appDescriptor}' application. ` + - `Consider checking the driver's troubleshooting documentation. ` + - `Original error: ${e.message}`); - } -} - -/** - * Helper method to call `adb dumpsys window windows/displays` - * @this {import('../adb.js').ADB} - * @returns {Promise} - */ -export async function dumpWindows () { - const apiLevel = await this.getApiLevel(); - - // With version 29, Android changed the dumpsys syntax - const dumpsysArg = apiLevel >= 29 ? 'displays' : 'windows'; - const cmd = ['dumpsys', 'window', dumpsysArg]; - - return await this.shell(cmd); -} - -/** - * Get the name of currently focused package and activity. - * - * @this {import('../adb.js').ADB} - * @return {Promise} - * @throws {Error} If there is an error while parsing the data. - */ -export async function getFocusedPackageAndActivity () { - log.debug('Getting focused package and activity'); - let stdout; - try { - stdout = await this.dumpWindows(); - } catch (e) { - throw new Error( - `Could not retrieve the currently focused package and activity. Original error: ${e.message}` - ); - } - - const nullFocusedAppRe = /^\s*mFocusedApp=null/m; - // https://regex101.com/r/xZ8vF7/1 - const focusedAppRe = new RegExp( - '^\\s*mFocusedApp.+Record\\{.*\\s([^\\s\\/\\}]+)\\/([^\\s\\/\\}\\,]+)\\,?(\\s[^\\s\\/\\}]+)*\\}', - 'mg' - ); - const nullCurrentFocusRe = /^\s*mCurrentFocus=null/m; - const currentFocusAppRe = new RegExp('^\\s*mCurrentFocus.+\\{.+\\s([^\\s\\/]+)\\/([^\\s]+)\\b', 'mg'); - - /** @type {import('./types').PackageActivityInfo[]} */ - const focusedAppCandidates = []; - /** @type {import('./types').PackageActivityInfo[]} */ - const currentFocusAppCandidates = []; - /** @type {[import('./types').PackageActivityInfo[], RegExp][]} */ - const pairs = [ - [focusedAppCandidates, focusedAppRe], - [currentFocusAppCandidates, currentFocusAppRe] - ]; - for (const [candidates, pattern] of pairs) { - let match; - while ((match = pattern.exec(stdout))) { - candidates.push({ - appPackage: match[1].trim(), - appActivity: match[2].trim() - }); - } - } - if (focusedAppCandidates.length > 1 && currentFocusAppCandidates.length > 0) { - // https://github.com/appium/appium/issues/17106 - return _.intersectionWith(focusedAppCandidates, currentFocusAppCandidates, _.isEqual)[0] - ?? focusedAppCandidates[0]; - } - if (focusedAppCandidates.length > 0 || currentFocusAppCandidates.length > 0) { - return focusedAppCandidates[0] ?? currentFocusAppCandidates[0]; - } - - for (const pattern of [nullFocusedAppRe, nullCurrentFocusRe]) { - if (pattern.exec(stdout)) { - return { - appPackage: null, - appActivity: null - }; - } - } - - log.debug(stdout); - throw new Error('Could not retrieve the currently focused package and activity'); -} - -/** - * Wait for the given activity to be focused/non-focused. - * - * @this {import('../adb.js').ADB} - * @param {string} pkg - The name of the package to wait for. - * @param {string} activity - The name of the activity, belonging to that package, - * to wait for. - * @param {boolean} waitForStop - Whether to wait until the activity is focused (true) - * or is not focused (false). - * @param {number} [waitMs=20000] - Number of milliseconds to wait before timeout occurs. - * @throws {error} If timeout happens. - */ -export async function waitForActivityOrNot (pkg, activity, waitForStop, waitMs = 20000) { - if (!pkg || !activity) { - throw new Error('Package and activity required.'); - } - log.debug(`Waiting up to ${waitMs}ms for activity matching pkg: '${pkg}' and ` + - `activity: '${activity}' to${waitForStop ? ' not' : ''} be focused`); - - const splitNames = (names) => names.split(',').map((name) => name.trim()); - const allPackages = splitNames(pkg); - const allActivities = splitNames(activity); - - const possibleActivityNames = []; - for (const oneActivity of allActivities) { - if (oneActivity.startsWith('.')) { - // add the package name if activity is not full qualified - for (const currentPkg of allPackages) { - possibleActivityNames.push(`${currentPkg}${oneActivity}`.replace(/\.+/g, '.')); - } - } else { - // accept fully qualified activity name. - possibleActivityNames.push(oneActivity); - possibleActivityNames.push(`${pkg}.${oneActivity}`); - } - } - log.debug(`Possible activities, to be checked: ${possibleActivityNames.map((name) => `'${name}'`).join(', ')}`); - - const possibleActivityPatterns = possibleActivityNames.map( - (actName) => new RegExp(`^${actName.replace(/\./g, '\\.').replace(/\*/g, '.*?').replace(/\$/g, '\\$')}$`) - ); - - const conditionFunc = async () => { - let appPackage; - let appActivity; - try { - ({appPackage, appActivity} = await this.getFocusedPackageAndActivity()); - } catch (e) { - log.debug(e.message); - return false; - } - if (appActivity && appPackage) { - const fullyQualifiedActivity = appActivity.startsWith('.') ? `${appPackage}${appActivity}` : appActivity; - log.debug(`Found package: '${appPackage}' and fully qualified activity name : '${fullyQualifiedActivity}'`); - const isActivityFound = _.includes(allPackages, appPackage) - && possibleActivityPatterns.some((p) => p.test(fullyQualifiedActivity)); - if ((!waitForStop && isActivityFound) || (waitForStop && !isActivityFound)) { - return true; - } - } - log.debug('Incorrect package and activity. Retrying.'); - return false; - }; - - try { - await waitForCondition(conditionFunc, { - waitMs: parseInt(`${waitMs}`, 10), - intervalMs: 500, - }); - } catch { - throw new Error(`${possibleActivityNames.map((name) => `'${name}'`).join(' or ')} never ${waitForStop ? 'stopped' : 'started'}. ` + - `Consider checking the driver's troubleshooting documentation.`); - } -} - -/** - * Wait for the given activity to be focused - * - * @this {import('../adb.js').ADB} - * @param {string} pkg - The name of the package to wait for. - * @param {string} act - The name of the activity, belonging to that package, - * to wait for. - * @param {number} [waitMs=20000] - Number of milliseconds to wait before timeout occurs. - * @throws {error} If timeout happens. - */ -export async function waitForActivity (pkg, act, waitMs = 20000) { - await this.waitForActivityOrNot(pkg, act, false, waitMs); -} - -/** - * Wait for the given activity to be non-focused. - * - * @this {import('../adb.js').ADB} - * @param {string} pkg - The name of the package to wait for. - * @param {string} act - The name of the activity, belonging to that package, - * to wait for. - * @param {number} [waitMs=20000] - Number of milliseconds to wait before timeout occurs. - * @throws {error} If timeout happens. - */ -export async function waitForNotActivity (pkg, act, waitMs = 20000) { - await this.waitForActivityOrNot(pkg, act, true, waitMs); -} /** * Uninstall the given package from the device under test. @@ -885,121 +530,3 @@ export async function getApkInfo (appPath) { } return {}; } - -/** - * Get the package info from the installed application. - * - * @this {import('../adb.js').ADB} - * @param {string} pkg - The name of the installed package. - * @return {Promise} The parsed application information. - */ -export async function getPackageInfo (pkg) { - log.debug(`Getting package info for '${pkg}'`); - const result = {name: pkg}; - let stdout; - try { - stdout = await this.shell(['dumpsys', 'package', pkg]); - } catch (err) { - log.debug(err.stack); - log.warn(`Got an unexpected error while dumping package info: ${err.message}`); - return result; - } - - const installedPattern = new RegExp(`^\\s*Package\\s+\\[${_.escapeRegExp(pkg)}\\][^:]+:$`, 'm'); - result.isInstalled = installedPattern.test(stdout); - if (!result.isInstalled) { - return result; - } - - const versionNameMatch = new RegExp(/versionName=([\d+.]+)/).exec(stdout); - if (versionNameMatch) { - result.versionName = versionNameMatch[1]; - } - const versionCodeMatch = new RegExp(/versionCode=(\d+)/).exec(stdout); - if (versionCodeMatch) { - result.versionCode = parseInt(versionCodeMatch[1], 10); - } - return result; -} - -/** - * Fetches base.apk of the given package to the local file system - * - * @this {import('../adb.js').ADB} - * @param {string} pkg The package identifier (must be already installed on the device) - * @param {string} tmpDir The destination folder path - * @returns {Promise} Full path to the downloaded file - * @throws {Error} If there was an error while fetching the .apk - */ -export async function pullApk (pkg, tmpDir) { - const stdout = _.trim(await this.shell(['pm', 'path', pkg])); - const packageMarker = 'package:'; - if (!_.startsWith(stdout, packageMarker)) { - throw new Error(`Cannot pull the .apk package for '${pkg}'. Original error: ${stdout}`); - } - - const remotePath = stdout.replace(packageMarker, ''); - const tmpApp = path.resolve(tmpDir, `${pkg}.apk`); - await this.pull(remotePath, tmpApp); - log.debug(`Pulled app for package '${pkg}' to '${tmpApp}'`); - return tmpApp; -} - -/** - * Activates the given application or launches it if necessary. - * The action literally simulates - * clicking the corresponding application icon on the dashboard. - * - * @this {import('../adb.js').ADB} - * @param {string} appId - Application package identifier - * @throws {Error} If the app cannot be activated - */ -export async function activateApp (appId) { - log.debug(`Activating '${appId}'`); - const apiLevel = await this.getApiLevel(); - // Fallback to Monkey in older APIs - if (apiLevel < 24) { - // The monkey command could raise an issue as https://stackoverflow.com/questions/44860475/how-to-use-the-monkey-command-with-an-android-system-that-doesnt-have-physical - // but '--pct-syskeys 0' could cause another background process issue. https://github.com/appium/appium/issues/16941#issuecomment-1129837285 - const cmd = ['monkey', - '-p', appId, - '-c', 'android.intent.category.LAUNCHER', - '1']; - let output = ''; - try { - output = await this.shell(cmd); - log.debug(`Command stdout: ${output}`); - } catch (e) { - throw log.errorWithException(`Cannot activate '${appId}'. Original error: ${e.message}`); - } - if (output.includes('monkey aborted')) { - throw log.errorWithException(`Cannot activate '${appId}'. Are you sure it is installed?`); - } - return; - } - - let activityName = await this.resolveLaunchableActivity(appId); - if (activityName === RESOLVER_ACTIVITY_NAME) { - // https://github.com/appium/appium/issues/17128 - log.debug( - `The launchable activity name of '${appId}' was resolved to '${activityName}'. ` + - `Switching the resolver to not use cmd` - ); - activityName = await this.resolveLaunchableActivity(appId, {preferCmd: false}); - } - - const stdout = await this.shell([ - 'am', (apiLevel < 26) ? 'start' : 'start-activity', - '-a', 'android.intent.action.MAIN', - '-c', 'android.intent.category.LAUNCHER', - // FLAG_ACTIVITY_NEW_TASK | FLAG_ACTIVITY_RESET_TASK_IF_NEEDED - // https://developer.android.com/reference/android/content/Intent#FLAG_ACTIVITY_NEW_TASK - // https://developer.android.com/reference/android/content/Intent#FLAG_ACTIVITY_RESET_TASK_IF_NEEDED - '-f', '0x10200000', - '-n', activityName, - ]); - log.debug(stdout); - if (/^error:/mi.test(stdout)) { - throw new Error(`Cannot activate '${appId}'. Original error: ${stdout}`); - } -} diff --git a/lib/tools/app-commands.js b/lib/tools/app-commands.js new file mode 100644 index 00000000..8f990efd --- /dev/null +++ b/lib/tools/app-commands.js @@ -0,0 +1,1035 @@ +import _ from 'lodash'; +import { + extractMatchingPermissions, parseLaunchableActivityNames, matchComponentName, + buildStartCmd, escapeShellArg, +} from '../helpers.js'; +import { fs, tempDir, util } from '@appium/support'; +import log from '../logger.js'; +import { sleep, waitForCondition } from 'asyncbox'; +import B from 'bluebird'; +import path from 'path'; + +/** @type {import('./types').StringRecord} */ +export const APP_INSTALL_STATE = { + UNKNOWN: 'unknown', + NOT_INSTALLED: 'notInstalled', + NEWER_VERSION_INSTALLED: 'newerVersionInstalled', + SAME_VERSION_INSTALLED: 'sameVersionInstalled', + OLDER_VERSION_INSTALLED: 'olderVersionInstalled', +}; +const NOT_CHANGEABLE_PERM_ERROR = /not a changeable permission type/i; +const IGNORED_PERM_ERRORS = [ + NOT_CHANGEABLE_PERM_ERROR, + /Unknown permission/i, +]; +const MIN_API_LEVEL_WITH_PERMS_SUPPORT = 23; +const MAX_PGREP_PATTERN_LEN = 15; +const PID_COLUMN_TITLE = 'PID'; +const PROCESS_NAME_COLUMN_TITLE = 'NAME'; +const PS_TITLE_PATTERN = new RegExp(`^(.*\\b${PID_COLUMN_TITLE}\\b.*\\b${PROCESS_NAME_COLUMN_TITLE}\\b.*)$`, 'm'); +const RESOLVER_ACTIVITY_NAME = 'android/com.android.internal.app.ResolverActivity'; + + +/** + * Verify whether the given argument is a + * valid class name. + * + * @this {import('../adb.js').ADB} + * @param {string} classString - The actual class name to be verified. + * @return {boolean} The result of Regexp.exec operation + * or _null_ if no matches are found. + */ +export function isValidClass (classString) { + // some.package/some.package.Activity + return !!matchComponentName(classString); +} + +/** + * Fetches the fully qualified name of the launchable activity for the + * given package. It is expected the package is already installed on + * the device under test. + * + * @this {import('../adb.js').ADB} + * @param {string} pkg - The target package identifier + * @param {import('./types').ResolveActivityOptions} opts + * @return {Promise} Fully qualified name of the launchable activity + * @throws {Error} If there was an error while resolving the activity name + */ +export async function resolveLaunchableActivity (pkg, opts = {}) { + const { preferCmd = true } = opts; + if (!preferCmd || await this.getApiLevel() < 24) { + const stdout = await this.shell(['dumpsys', 'package', pkg]); + const names = parseLaunchableActivityNames(stdout); + if (_.isEmpty(names)) { + log.debug(stdout); + throw new Error(`Unable to resolve the launchable activity of '${pkg}'. Is it installed on the device?`); + } + if (names.length === 1) { + return names[0]; + } + + const tmpRoot = await tempDir.openDir(); + try { + const tmpApp = await this.pullApk(pkg, tmpRoot); + const {apkActivity} = await this.packageAndLaunchActivityFromManifest(tmpApp); + return /** @type {string} */ (apkActivity); + } catch (e) { + const err = /** @type {Error} */ (e); + log.debug(err.stack); + log.warn(`Unable to resolve the launchable activity of '${pkg}'. ` + + `The very first match of the dumpsys output is going to be used. ` + + `Original error: ${err.message}`); + return names[0]; + } finally { + await fs.rimraf(tmpRoot); + } + } + const {stdout, stderr} = await this.shell(['cmd', 'package', 'resolve-activity', '--brief', pkg], { + outputFormat: this.EXEC_OUTPUT_FORMAT.FULL + }); + for (const line of (stdout || '').split('\n').map(_.trim)) { + if (this.isValidClass(line)) { + return line; + } + } + throw new Error( + `Unable to resolve the launchable activity of '${pkg}'. Original error: ${stderr || stdout}` + ); +} + +/** + * Force application to stop on the device under test. + * + * @this {import('../adb.js').ADB} + * @param {string} pkg - The package name to be stopped. + * @return {Promise} The output of the corresponding adb command. + */ +export async function forceStop (pkg) { + return await this.shell(['am', 'force-stop', pkg]); +} + +/** + * Kill application + * + * @this {import('../adb.js').ADB} + * @param {string} pkg - The package name to be stopped. + * @return {Promise} The output of the corresponding adb command. + */ +export async function killPackage (pkg) { + return await this.shell(['am', 'kill', pkg]); +} + +/** + * Clear the user data of the particular application on the device + * under test. + * + * @this {import('../adb.js').ADB} + * @param {string} pkg - The package name to be cleared. + * @return {Promise} The output of the corresponding adb command. + */ +export async function clear (pkg) { + return await this.shell(['pm', 'clear', pkg]); +} + +/** + * Grant all permissions requested by the particular package. + * This method is only useful on Android 6.0+ and for applications + * that support components-based permissions setting. + * + * @this {import('../adb.js').ADB} + * @param {string} pkg - The package name to be processed. + * @param {string} [apk] - The path to the actual apk file. + * @throws {Error} If there was an error while granting permissions + */ +export async function grantAllPermissions (pkg, apk) { + const apiLevel = await this.getApiLevel(); + let targetSdk = 0; + let dumpsysOutput = null; + try { + if (!apk) { + /** + * If apk not provided, considering apk already installed on the device + * and fetching targetSdk using package name. + */ + dumpsysOutput = await this.shell(['dumpsys', 'package', pkg]); + targetSdk = await this.targetSdkVersionUsingPKG(pkg, dumpsysOutput); + } else { + targetSdk = await this.targetSdkVersionFromManifest(apk); + } + } catch { + //avoiding logging error stack, as calling library function would have logged + log.warn(`Ran into problem getting target SDK version; ignoring...`); + } + if (apiLevel >= MIN_API_LEVEL_WITH_PERMS_SUPPORT && targetSdk >= MIN_API_LEVEL_WITH_PERMS_SUPPORT) { + /** + * If the device is running Android 6.0(API 23) or higher, and your app's target SDK is 23 or higher: + * The app has to list the permissions in the manifest. + * refer: https://developer.android.com/training/permissions/requesting.html + */ + dumpsysOutput = dumpsysOutput || await this.shell(['dumpsys', 'package', pkg]); + const requestedPermissions = await this.getReqPermissions(pkg, dumpsysOutput); + const grantedPermissions = await this.getGrantedPermissions(pkg, dumpsysOutput); + const permissionsToGrant = _.difference(requestedPermissions, grantedPermissions); + if (_.isEmpty(permissionsToGrant)) { + log.info(`${pkg} contains no permissions available for granting`); + } else { + await this.grantPermissions(pkg, permissionsToGrant); + } + } else if (targetSdk < MIN_API_LEVEL_WITH_PERMS_SUPPORT) { + log.info(`It is only possible to grant permissions in runtime for ` + + `apps whose targetSdkVersion in the manifest is set to ${MIN_API_LEVEL_WITH_PERMS_SUPPORT} or above. ` + + `The current ${pkg} targetSdkVersion is ${targetSdk || 'unset'}.`); + } else if (apiLevel < MIN_API_LEVEL_WITH_PERMS_SUPPORT) { + log.info(`The device's OS API level is ${apiLevel}. ` + + `It is only possible to grant permissions on devices running Android 6 or above.`); + } +} + +/** + * Grant multiple permissions for the particular package. + * This call is more performant than `grantPermission` one, since it combines + * multiple `adb shell` calls into a single command. + * + * @this {import('../adb.js').ADB} + * @param {string} pkg - The package name to be processed. + * @param {Array} permissions - The list of permissions to be granted. + * @throws {Error} If there was an error while changing permissions. + */ +export async function grantPermissions (pkg, permissions) { + // As it consumes more time for granting each permission, + // trying to grant all permission by forming equivalent command. + // Also, it is necessary to split long commands into chunks, since the maximum length of + // adb shell buffer is limited + log.debug(`Granting permissions ${JSON.stringify(permissions)} to '${pkg}'`); + try { + await this.shellChunks((perm) => ['pm', 'grant', pkg, perm], permissions); + } catch (e) { + const err = /** @type {import('teen_process').ExecError} */(e); + if (!IGNORED_PERM_ERRORS.some((pattern) => pattern.test(err.stderr || err.message))) { + throw err; + } + } +} + +/** + * Grant single permission for the particular package. + * + * @this {import('../adb.js').ADB} + * @param {string} pkg - The package name to be processed. + * @param {string} permission - The full name of the permission to be granted. + * @throws {Error} If there was an error while changing permissions. + */ +export async function grantPermission (pkg, permission) { + try { + await this.shell(['pm', 'grant', pkg, permission]); + } catch (e) { + const err = /** @type {import('teen_process').ExecError} */(e); + if (!NOT_CHANGEABLE_PERM_ERROR.test(err.stderr || err.message)) { + throw err; + } + } +} + +/** + * Revoke single permission from the particular package. + * + * @this {import('../adb.js').ADB} + * @param {string} pkg - The package name to be processed. + * @param {string} permission - The full name of the permission to be revoked. + * @throws {Error} If there was an error while changing permissions. + */ +export async function revokePermission (pkg, permission) { + try { + await this.shell(['pm', 'revoke', pkg, permission]); + } catch (e) { + const err = /** @type {import('teen_process').ExecError} */(e); + if (!NOT_CHANGEABLE_PERM_ERROR.test(err.stderr || err.message)) { + throw err; + } + } +} + +/** + * Retrieve the list of granted permissions for the particular package. + * + * @this {import('../adb.js').ADB} + * @param {string} pkg - The package name to be processed. + * @param {string?} [cmdOutput=null] - Optional parameter containing command output of + * _dumpsys package_ command. It may speed up the method execution. + * @return {Promise} The list of granted permissions or an empty list. + * @throws {Error} If there was an error while changing permissions. + */ +export async function getGrantedPermissions (pkg, cmdOutput = null) { + log.debug('Retrieving granted permissions'); + const stdout = cmdOutput || await this.shell(['dumpsys', 'package', pkg]); + return extractMatchingPermissions(stdout, ['install', 'runtime'], true); +} + +/** + * Retrieve the list of denied permissions for the particular package. + * + * @this {import('../adb.js').ADB} + * @param {string} pkg - The package name to be processed. + * @param {string?} [cmdOutput=null] - Optional parameter containing command output of + * _dumpsys package_ command. It may speed up the method execution. + * @return {Promise} The list of denied permissions or an empty list. + */ +export async function getDeniedPermissions (pkg, cmdOutput = null) { + log.debug('Retrieving denied permissions'); + const stdout = cmdOutput || await this.shell(['dumpsys', 'package', pkg]); + return extractMatchingPermissions(stdout, ['install', 'runtime'], false); +} + +/** + * Retrieve the list of requested permissions for the particular package. + * + * @this {import('../adb.js').ADB} + * @param {string} pkg - The package name to be processed. + * @param {string?} [cmdOutput=null] - Optional parameter containing command output of + * _dumpsys package_ command. It may speed up the method execution. + * @return {Promise} The list of requested permissions or an empty list. + */ +export async function getReqPermissions (pkg, cmdOutput = null) { + log.debug('Retrieving requested permissions'); + const stdout = cmdOutput || await this.shell(['dumpsys', 'package', pkg]); + return extractMatchingPermissions(stdout, ['requested']); +} + +/** + * Stop the particular package if it is running and clears its application data. + * + * @this {import('../adb.js').ADB} + * @param {string} pkg - The package name to be processed. + */ +export async function stopAndClear (pkg) { + try { + await this.forceStop(pkg); + await this.clear(pkg); + } catch (e) { + const err = /** @type {Error} */ (e); + throw new Error(`Cannot stop and clear ${pkg}. Original error: ${err.message}`); + } +} + + +/** + * At some point of time Google has changed the default `ps` behaviour, so it only + * lists processes that belong to the current shell user rather to all + * users. It is necessary to execute ps with -A command line argument + * to mimic the previous behaviour. + * + * @this {import('../adb.js').ADB} + * @returns {Promise} the output of `ps` command where all processes are included + */ +export async function listProcessStatus () { + if (!_.isBoolean(this._doesPsSupportAOption)) { + try { + this._doesPsSupportAOption = /^-A\b/m.test(await this.shell(['ps', '--help'])); + } catch (e) { + log.debug((/** @type {Error} */ (e)).stack); + this._doesPsSupportAOption = false; + } + } + return await this.shell(this._doesPsSupportAOption ? ['ps', '-A'] : ['ps']); +} + +/** + * Returns process name for the given process identifier + * + * @this {import('../adb.js').ADB} + * @param {string|number} pid - The valid process identifier + * @throws {Error} If the given PID is either invalid or is not present + * in the active processes list + * @returns {Promise} The process name + */ +export async function getNameByPid (pid) { + // @ts-ignore This validation works as expected + if (isNaN(pid)) { + throw new Error(`The PID value must be a valid number. '${pid}' is given instead`); + } + pid = parseInt(`${pid}`, 10); + + const stdout = await this.listProcessStatus(); + const titleMatch = PS_TITLE_PATTERN.exec(stdout); + if (!titleMatch) { + log.debug(stdout); + throw new Error(`Could not get the process name for PID '${pid}'`); + } + const allTitles = titleMatch[1].trim().split(/\s+/); + const pidIndex = allTitles.indexOf(PID_COLUMN_TITLE); + // it might not be stable to take NAME by index, because depending on the + // actual SDK the ps output might not contain an abbreviation for the S flag: + // USER PID PPID VSIZE RSS WCHAN PC NAME + // USER PID PPID VSIZE RSS WCHAN PC S NAME + const nameOffset = allTitles.indexOf(PROCESS_NAME_COLUMN_TITLE) - allTitles.length; + const pidRegex = new RegExp(`^(.*\\b${pid}\\b.*)$`, 'gm'); + let matchedLine; + while ((matchedLine = pidRegex.exec(stdout))) { + const items = matchedLine[1].trim().split(/\s+/); + if (parseInt(items[pidIndex], 10) === pid && items[items.length + nameOffset]) { + return items[items.length + nameOffset]; + } + } + log.debug(stdout); + throw new Error(`Could not get the process name for PID '${pid}'`); +} + +/** + * Get the list of process ids for the particular process on the device under test. + * + * @this {import('../adb.js').ADB} + * @param {string} name - The part of process name. + * @return {Promise} The list of matched process IDs or an empty list. + * @throws {Error} If the passed process name is not a valid one + */ +export async function getPIDsByName (name) { + log.debug(`Getting IDs of all '${name}' processes`); + if (!this.isValidClass(name)) { + throw new Error(`Invalid process name: '${name}'`); + } + // https://github.com/appium/appium/issues/13567 + if (await this.getApiLevel() >= 23) { + if (!_.isBoolean(this._isPgrepAvailable)) { + // pgrep is in priority, since pidof has been reported of having bugs on some platforms + const pgrepOutput = _.trim(await this.shell(['pgrep --help; echo $?'])); + this._isPgrepAvailable = parseInt(`${_.last(pgrepOutput.split(/\s+/))}`, 10) === 0; + if (this._isPgrepAvailable) { + this._canPgrepUseFullCmdLineSearch = /^-f\b/m.test(pgrepOutput); + } else { + this._isPidofAvailable = parseInt(await this.shell(['pidof --help > /dev/null; echo $?']), 10) === 0; + } + } + if (this._isPgrepAvailable || this._isPidofAvailable) { + const shellCommand = this._isPgrepAvailable + ? (this._canPgrepUseFullCmdLineSearch + ? ['pgrep', '-f', _.escapeRegExp(`([[:blank:]]|^)${name}(:[a-zA-Z0-9_-]+)?([[:blank:]]|$)`)] + // https://github.com/appium/appium/issues/13872 + : [`pgrep ^${_.escapeRegExp(name.slice(-MAX_PGREP_PATTERN_LEN))}$ ` + + `|| pgrep ^${_.escapeRegExp(name.slice(0, MAX_PGREP_PATTERN_LEN))}$`]) + : ['pidof', name]; + try { + return (await this.shell(shellCommand)) + .split(/\s+/) + .map((x) => parseInt(x, 10)) + .filter((x) => _.isInteger(x)); + } catch (e) { + const err = /** @type {import('teen_process').ExecError} */ (e); + // error code 1 is returned if the utility did not find any processes + // with the given name + if (err.code !== 1) { + throw new Error(`Could not extract process ID of '${name}': ${err.message}`); + } + if (_.includes(err.stderr || err.stdout, 'syntax error')) { + log.warn(`Got an unexpected response from the shell interpreter: ${err.stderr || err.stdout}`); + } else { + return []; + } + } + } + } + + log.debug('Using ps-based PID detection'); + const stdout = await this.listProcessStatus(); + const titleMatch = PS_TITLE_PATTERN.exec(stdout); + if (!titleMatch) { + log.debug(stdout); + throw new Error(`Could not extract PID of '${name}' from ps output`); + } + const allTitles = titleMatch[1].trim().split(/\s+/); + const pidIndex = allTitles.indexOf(PID_COLUMN_TITLE); + const pids = []; + const processNameRegex = new RegExp(`^(.*\\b\\d+\\b.*\\b${_.escapeRegExp(name)}\\b.*)$`, 'gm'); + let matchedLine; + while ((matchedLine = processNameRegex.exec(stdout))) { + const items = matchedLine[1].trim().split(/\s+/); + // @ts-ignore This validation worka as expected + if (pidIndex >= allTitles.length || isNaN(items[pidIndex])) { + log.debug(stdout); + throw new Error(`Could not extract PID of '${name}' from '${matchedLine[1].trim()}'`); + } + pids.push(parseInt(items[pidIndex], 10)); + } + return pids; +} + +/** + * Get the list of process ids for the particular process on the device under test. + * + * @this {import('../adb.js').ADB} + * @param {string} name - The part of process name. + */ +export async function killProcessesByName (name) { + try { + log.debug(`Attempting to kill all ${name} processes`); + const pids = await this.getPIDsByName(name); + if (_.isEmpty(pids)) { + log.info(`No '${name}' process has been found`); + } else { + await B.all(pids.map((p) => this.killProcessByPID(p))); + } + } catch (e) { + const err = /** @type {Error} */ (e); + throw new Error(`Unable to kill ${name} processes. Original error: ${err.message}`); + } +} + +/** + * Kill the particular process on the device under test. + * The current user is automatically switched to root if necessary in order + * to properly kill the process. + * + * @this {import('../adb.js').ADB} + * @param {string|number} pid - The ID of the process to be killed. + * @throws {Error} If the process cannot be killed. + */ +export async function killProcessByPID (pid) { + log.debug(`Attempting to kill process ${pid}`); + const noProcessFlag = 'No such process'; + try { + // Check if the process exists and throw an exception otherwise + await this.shell(['kill', `${pid}`]); + } catch (e) { + const err = /** @type {import('teen_process').ExecError} */ (e); + if (_.includes(err.stderr, noProcessFlag)) { + return; + } + if (!_.includes(err.stderr, 'Operation not permitted')) { + throw err; + } + log.info(`Cannot kill PID ${pid} due to insufficient permissions. Retrying as root`); + try { + await this.shell(['kill', `${pid}`], { + privileged: true + }); + } catch (e1) { + const err1 = /** @type {import('teen_process').ExecError} */ (e1); + if (_.includes(err1.stderr, noProcessFlag)) { + return; + } + throw err1; + } + } +} + +/** + * Broadcast process killing on the device under test. + * + * @this {import('../adb.js').ADB} + * @param {string} intent - The name of the intent to broadcast to. + * @param {string} processName - The name of the killed process. + * @throws {error} If the process was not killed. + */ +export async function broadcastProcessEnd (intent, processName) { + // start the broadcast without waiting for it to finish. + this.broadcast(intent); + // wait for the process to end + let start = Date.now(); + let timeoutMs = 40000; + try { + while ((Date.now() - start) < timeoutMs) { + if (await this.processExists(processName)) { + // cool down + await sleep(400); + continue; + } + return; + } + throw new Error(`Process never died within ${timeoutMs} ms`); + } catch (e) { + const err = /** @type {Error} */ (e); + throw new Error(`Unable to broadcast process end. Original error: ${err.message}`); + } +} + +/** + * Broadcast a message to the given intent. + * + * @this {import('../adb.js').ADB} + * @param {string} intent - The name of the intent to broadcast to. + * @throws {error} If intent name is not a valid class name. + */ +export async function broadcast (intent) { + if (!this.isValidClass(intent)) { + throw new Error(`Invalid intent ${intent}`); + } + log.debug(`Broadcasting: ${intent}`); + await this.shell(['am', 'broadcast', '-a', intent]); +} + +/** + * Check whether the process with the particular name is running on the device + * under test. + * + * @this {import('../adb.js').ADB} + * @param {string} processName - The name of the process to be checked. + * @return {Promise} True if the given process is running. + * @throws {Error} If the given process name is not a valid class name. + */ +export async function processExists (processName) { + return !_.isEmpty(await this.getPIDsByName(processName)); +} + + +/** + * Get the package info from the installed application. + * + * @this {import('../adb.js').ADB} + * @param {string} pkg - The name of the installed package. + * @return {Promise} The parsed application information. + */ +export async function getPackageInfo (pkg) { + log.debug(`Getting package info for '${pkg}'`); + const result = {name: pkg}; + let stdout; + try { + stdout = await this.shell(['dumpsys', 'package', pkg]); + } catch (err) { + log.debug(err.stack); + log.warn(`Got an unexpected error while dumping package info: ${err.message}`); + return result; + } + + const installedPattern = new RegExp(`^\\s*Package\\s+\\[${_.escapeRegExp(pkg)}\\][^:]+:$`, 'm'); + result.isInstalled = installedPattern.test(stdout); + if (!result.isInstalled) { + return result; + } + + const versionNameMatch = new RegExp(/versionName=([\d+.]+)/).exec(stdout); + if (versionNameMatch) { + result.versionName = versionNameMatch[1]; + } + const versionCodeMatch = new RegExp(/versionCode=(\d+)/).exec(stdout); + if (versionCodeMatch) { + result.versionCode = parseInt(versionCodeMatch[1], 10); + } + return result; +} + +/** + * Fetches base.apk of the given package to the local file system + * + * @this {import('../adb.js').ADB} + * @param {string} pkg The package identifier (must be already installed on the device) + * @param {string} tmpDir The destination folder path + * @returns {Promise} Full path to the downloaded file + * @throws {Error} If there was an error while fetching the .apk + */ +export async function pullApk (pkg, tmpDir) { + const stdout = _.trim(await this.shell(['pm', 'path', pkg])); + const packageMarker = 'package:'; + if (!_.startsWith(stdout, packageMarker)) { + throw new Error(`Cannot pull the .apk package for '${pkg}'. Original error: ${stdout}`); + } + + const remotePath = stdout.replace(packageMarker, ''); + const tmpApp = path.resolve(tmpDir, `${pkg}.apk`); + await this.pull(remotePath, tmpApp); + log.debug(`Pulled app for package '${pkg}' to '${tmpApp}'`); + return tmpApp; +} + +/** + * Activates the given application or launches it if necessary. + * The action literally simulates + * clicking the corresponding application icon on the dashboard. + * + * @this {import('../adb.js').ADB} + * @param {string} appId - Application package identifier + * @throws {Error} If the app cannot be activated + */ +export async function activateApp (appId) { + log.debug(`Activating '${appId}'`); + const apiLevel = await this.getApiLevel(); + // Fallback to Monkey in older APIs + if (apiLevel < 24) { + // The monkey command could raise an issue as https://stackoverflow.com/questions/44860475/how-to-use-the-monkey-command-with-an-android-system-that-doesnt-have-physical + // but '--pct-syskeys 0' could cause another background process issue. https://github.com/appium/appium/issues/16941#issuecomment-1129837285 + const cmd = ['monkey', + '-p', appId, + '-c', 'android.intent.category.LAUNCHER', + '1']; + let output = ''; + try { + output = await this.shell(cmd); + log.debug(`Command stdout: ${output}`); + } catch (e) { + throw log.errorWithException(`Cannot activate '${appId}'. Original error: ${e.message}`); + } + if (output.includes('monkey aborted')) { + throw log.errorWithException(`Cannot activate '${appId}'. Are you sure it is installed?`); + } + return; + } + + let activityName = await this.resolveLaunchableActivity(appId); + if (activityName === RESOLVER_ACTIVITY_NAME) { + // https://github.com/appium/appium/issues/17128 + log.debug( + `The launchable activity name of '${appId}' was resolved to '${activityName}'. ` + + `Switching the resolver to not use cmd` + ); + activityName = await this.resolveLaunchableActivity(appId, {preferCmd: false}); + } + + const stdout = await this.shell([ + 'am', (apiLevel < 26) ? 'start' : 'start-activity', + '-a', 'android.intent.action.MAIN', + '-c', 'android.intent.category.LAUNCHER', + // FLAG_ACTIVITY_NEW_TASK | FLAG_ACTIVITY_RESET_TASK_IF_NEEDED + // https://developer.android.com/reference/android/content/Intent#FLAG_ACTIVITY_NEW_TASK + // https://developer.android.com/reference/android/content/Intent#FLAG_ACTIVITY_RESET_TASK_IF_NEEDED + '-f', '0x10200000', + '-n', activityName, + ]); + log.debug(stdout); + if (/^error:/mi.test(stdout)) { + throw new Error(`Cannot activate '${appId}'. Original error: ${stdout}`); + } +} + + +/** + * Check whether the particular package is present on the device under test. + * + * @this {import('../adb.js').ADB} + * @param {string} pkg - The name of the package to check. + * @param {import('./types').IsAppInstalledOptions} [opts={}] + * @return {Promise} True if the package is installed. + */ +export async function isAppInstalled (pkg, opts = {}) { + const { + user, + } = opts; + + log.debug(`Getting install status for ${pkg}`); + /** @type {boolean} */ + let isInstalled; + if (await this.getApiLevel() < 26) { + try { + const cmd = ['pm', 'path']; + if (util.hasValue(user)) { + cmd.push('--user', user); + } + cmd.push(pkg); + const stdout = await this.shell(cmd); + isInstalled = /^package:/m.test(stdout); + } catch { + isInstalled = false; + } + } else { + const cmd = ['cmd', 'package', 'list', 'packages']; + if (util.hasValue(user)) { + cmd.push('--user', user); + } + /** @type {string} */ + let stdout; + try { + stdout = await this.shell(cmd); + } catch (e) { + // https://github.com/appium/appium-uiautomator2-driver/issues/810 + if (_.includes(e.stderr || e.stdout || e.message, 'access user') && _.isEmpty(user)) { + stdout = await this.shell([...cmd, '--user', '0']); + } else { + throw e; + } + } + isInstalled = new RegExp(`^package:${_.escapeRegExp(pkg)}$`, 'm').test(stdout); + } + log.debug(`'${pkg}' is${!isInstalled ? ' not' : ''} installed`); + return isInstalled; +} + +/** + * Start the particular URI on the device under test. + * + * @this {import('../adb.js').ADB} + * @param {string} uri - The name of URI to start. + * @param {string?} [pkg=null] - The name of the package to start the URI with. + * @param {import('./types').StartUriOptions} [opts={}] + */ +export async function startUri (uri, pkg = null, opts = {}) { + const { + waitForLaunch = true, + } = opts; + + if (!uri) { + throw new Error('URI argument is required'); + } + + const args = ['am', 'start']; + if (waitForLaunch) { + args.push('-W'); + } + args.push('-a', 'android.intent.action.VIEW', + '-d', escapeShellArg(uri)); + if (pkg) { + args.push(pkg); + } + + try { + const res = await this.shell(args); + if (res.toLowerCase().includes('unable to resolve intent')) { + throw new Error(res); + } + } catch (e) { + throw new Error(`Error attempting to start URI. Original error: ${e}`); + } +} + +/** + * Start the particular package/activity on the device under test. + * + * @this {import('../adb.js').ADB} + * @param {import('./types').StartAppOptions} startAppOptions - Startup options mapping. + * @return {Promise} The output of the corresponding adb command. + * @throws {Error} If there is an error while executing the activity + */ +export async function startApp (startAppOptions) { + if (!startAppOptions.pkg || !(startAppOptions.activity || startAppOptions.action)) { + throw new Error('pkg, and activity or intent action, are required to start an application'); + } + + startAppOptions = _.clone(startAppOptions); + if (startAppOptions.activity) { + startAppOptions.activity = startAppOptions.activity.replace('$', '\\$'); + } + // initializing defaults + _.defaults(startAppOptions, { + waitPkg: startAppOptions.pkg, + waitForLaunch: true, + waitActivity: false, + retry: true, + stopApp: true + }); + // preventing null waitpkg + startAppOptions.waitPkg = startAppOptions.waitPkg || startAppOptions.pkg; + + const apiLevel = await this.getApiLevel(); + const cmd = buildStartCmd(startAppOptions, apiLevel); + const intentName = `${startAppOptions.action}${startAppOptions.optionalIntentArguments + ? ' ' + startAppOptions.optionalIntentArguments + : ''}`; + try { + const shellOpts = {}; + if (_.isInteger(startAppOptions.waitDuration) + // @ts-ignore waitDuration is an integer here + && startAppOptions.waitDuration >= 0) { + shellOpts.timeout = startAppOptions.waitDuration; + } + const stdout = await this.shell(cmd, shellOpts); + if (stdout.includes('Error: Activity class') && stdout.includes('does not exist')) { + if (startAppOptions.retry && startAppOptions.activity && !startAppOptions.activity.startsWith('.')) { + log.debug(`We tried to start an activity that doesn't exist, ` + + `retrying with '.${startAppOptions.activity}' activity name`); + startAppOptions.activity = `.${startAppOptions.activity}`; + startAppOptions.retry = false; + return await this.startApp(startAppOptions); + } + throw new Error(`Activity name '${startAppOptions.activity}' used to start the app doesn't ` + + `exist or cannot be launched! Make sure it exists and is a launchable activity`); + } else if (stdout.includes('Error: Intent does not match any activities') + || stdout.includes('Error: Activity not started, unable to resolve Intent')) { + throw new Error(`Activity for intent '${intentName}' used to start the app doesn't ` + + `exist or cannot be launched! Make sure it exists and is a launchable activity`); + } else if (stdout.includes('java.lang.SecurityException')) { + // if the app is disabled on a real device it will throw a security exception + throw new Error(`The permission to start '${startAppOptions.activity}' activity has been denied.` + + `Make sure the activity/package names are correct.`); + } + if (startAppOptions.waitActivity) { + await this.waitForActivity(startAppOptions.waitPkg, startAppOptions.waitActivity, startAppOptions.waitDuration); + } + return stdout; + } catch (e) { + const appDescriptor = startAppOptions.pkg || intentName; + throw new Error(`Cannot start the '${appDescriptor}' application. ` + + `Consider checking the driver's troubleshooting documentation. ` + + `Original error: ${e.message}`); + } +} + +/** + * Helper method to call `adb dumpsys window windows/displays` + * @this {import('../adb.js').ADB} + * @returns {Promise} + */ +export async function dumpWindows () { + const apiLevel = await this.getApiLevel(); + + // With version 29, Android changed the dumpsys syntax + const dumpsysArg = apiLevel >= 29 ? 'displays' : 'windows'; + const cmd = ['dumpsys', 'window', dumpsysArg]; + + return await this.shell(cmd); +} + +/** + * Get the name of currently focused package and activity. + * + * @this {import('../adb.js').ADB} + * @return {Promise} + * @throws {Error} If there is an error while parsing the data. + */ +export async function getFocusedPackageAndActivity () { + log.debug('Getting focused package and activity'); + let stdout; + try { + stdout = await this.dumpWindows(); + } catch (e) { + throw new Error( + `Could not retrieve the currently focused package and activity. Original error: ${e.message}` + ); + } + + const nullFocusedAppRe = /^\s*mFocusedApp=null/m; + // https://regex101.com/r/xZ8vF7/1 + const focusedAppRe = new RegExp( + '^\\s*mFocusedApp.+Record\\{.*\\s([^\\s\\/\\}]+)\\/([^\\s\\/\\}\\,]+)\\,?(\\s[^\\s\\/\\}]+)*\\}', + 'mg' + ); + const nullCurrentFocusRe = /^\s*mCurrentFocus=null/m; + const currentFocusAppRe = new RegExp('^\\s*mCurrentFocus.+\\{.+\\s([^\\s\\/]+)\\/([^\\s]+)\\b', 'mg'); + + /** @type {import('./types').PackageActivityInfo[]} */ + const focusedAppCandidates = []; + /** @type {import('./types').PackageActivityInfo[]} */ + const currentFocusAppCandidates = []; + /** @type {[import('./types').PackageActivityInfo[], RegExp][]} */ + const pairs = [ + [focusedAppCandidates, focusedAppRe], + [currentFocusAppCandidates, currentFocusAppRe] + ]; + for (const [candidates, pattern] of pairs) { + let match; + while ((match = pattern.exec(stdout))) { + candidates.push({ + appPackage: match[1].trim(), + appActivity: match[2].trim() + }); + } + } + if (focusedAppCandidates.length > 1 && currentFocusAppCandidates.length > 0) { + // https://github.com/appium/appium/issues/17106 + return _.intersectionWith(focusedAppCandidates, currentFocusAppCandidates, _.isEqual)[0] + ?? focusedAppCandidates[0]; + } + if (focusedAppCandidates.length > 0 || currentFocusAppCandidates.length > 0) { + return focusedAppCandidates[0] ?? currentFocusAppCandidates[0]; + } + + for (const pattern of [nullFocusedAppRe, nullCurrentFocusRe]) { + if (pattern.exec(stdout)) { + return { + appPackage: null, + appActivity: null + }; + } + } + + log.debug(stdout); + throw new Error('Could not retrieve the currently focused package and activity'); +} + +/** + * Wait for the given activity to be focused/non-focused. + * + * @this {import('../adb.js').ADB} + * @param {string} pkg - The name of the package to wait for. + * @param {string} activity - The name of the activity, belonging to that package, + * to wait for. + * @param {boolean} waitForStop - Whether to wait until the activity is focused (true) + * or is not focused (false). + * @param {number} [waitMs=20000] - Number of milliseconds to wait before timeout occurs. + * @throws {error} If timeout happens. + */ +export async function waitForActivityOrNot (pkg, activity, waitForStop, waitMs = 20000) { + if (!pkg || !activity) { + throw new Error('Package and activity required.'); + } + log.debug(`Waiting up to ${waitMs}ms for activity matching pkg: '${pkg}' and ` + + `activity: '${activity}' to${waitForStop ? ' not' : ''} be focused`); + + const splitNames = (names) => names.split(',').map((name) => name.trim()); + const allPackages = splitNames(pkg); + const allActivities = splitNames(activity); + + const possibleActivityNames = []; + for (const oneActivity of allActivities) { + if (oneActivity.startsWith('.')) { + // add the package name if activity is not full qualified + for (const currentPkg of allPackages) { + possibleActivityNames.push(`${currentPkg}${oneActivity}`.replace(/\.+/g, '.')); + } + } else { + // accept fully qualified activity name. + possibleActivityNames.push(oneActivity); + possibleActivityNames.push(`${pkg}.${oneActivity}`); + } + } + log.debug(`Possible activities, to be checked: ${possibleActivityNames.map((name) => `'${name}'`).join(', ')}`); + + const possibleActivityPatterns = possibleActivityNames.map( + (actName) => new RegExp(`^${actName.replace(/\./g, '\\.').replace(/\*/g, '.*?').replace(/\$/g, '\\$')}$`) + ); + + const conditionFunc = async () => { + let appPackage; + let appActivity; + try { + ({appPackage, appActivity} = await this.getFocusedPackageAndActivity()); + } catch (e) { + log.debug(e.message); + return false; + } + if (appActivity && appPackage) { + const fullyQualifiedActivity = appActivity.startsWith('.') ? `${appPackage}${appActivity}` : appActivity; + log.debug(`Found package: '${appPackage}' and fully qualified activity name : '${fullyQualifiedActivity}'`); + const isActivityFound = _.includes(allPackages, appPackage) + && possibleActivityPatterns.some((p) => p.test(fullyQualifiedActivity)); + if ((!waitForStop && isActivityFound) || (waitForStop && !isActivityFound)) { + return true; + } + } + log.debug('Incorrect package and activity. Retrying.'); + return false; + }; + + try { + await waitForCondition(conditionFunc, { + waitMs: parseInt(`${waitMs}`, 10), + intervalMs: 500, + }); + } catch { + throw new Error(`${possibleActivityNames.map((name) => `'${name}'`).join(' or ')} never ${waitForStop ? 'stopped' : 'started'}. ` + + `Consider checking the driver's troubleshooting documentation.`); + } +} + +/** + * Wait for the given activity to be focused + * + * @this {import('../adb.js').ADB} + * @param {string} pkg - The name of the package to wait for. + * @param {string} act - The name of the activity, belonging to that package, + * to wait for. + * @param {number} [waitMs=20000] - Number of milliseconds to wait before timeout occurs. + * @throws {error} If timeout happens. + */ +export async function waitForActivity (pkg, act, waitMs = 20000) { + await this.waitForActivityOrNot(pkg, act, false, waitMs); +} + +/** + * Wait for the given activity to be non-focused. + * + * @this {import('../adb.js').ADB} + * @param {string} pkg - The name of the package to wait for. + * @param {string} act - The name of the activity, belonging to that package, + * to wait for. + * @param {number} [waitMs=20000] - Number of milliseconds to wait before timeout occurs. + * @throws {error} If timeout happens. + */ +export async function waitForNotActivity (pkg, act, waitMs = 20000) { + await this.waitForActivityOrNot(pkg, act, true, waitMs); +} diff --git a/lib/tools/fs-commands.js b/lib/tools/fs-commands.js new file mode 100644 index 00000000..938a2ad9 --- /dev/null +++ b/lib/tools/fs-commands.js @@ -0,0 +1,124 @@ +import _ from 'lodash'; +import path from 'path'; + +/** + * Verify whether a remote path exists on the device under test. + * + * @this {import('../adb.js').ADB} + * @param {string} remotePath - The remote path to verify. + * @return {Promise} True if the given path exists on the device. + */ +export async function fileExists (remotePath) { + const passFlag = '__PASS__'; + const checkCmd = `[ -e '${remotePath.replace(/'/g, `\\'`)}' ] && echo ${passFlag}`; + try { + return _.includes(await this.shell([checkCmd]), passFlag); + } catch { + return false; + } +} + +/** + * Get the output of _ls_ command on the device under test. + * + * @this {import('../adb.js').ADB} + * @param {string} remotePath - The remote path (the first argument to the _ls_ command). + * @param {string[]} [opts] - Additional _ls_ options. + * @return {Promise} The _ls_ output as an array of split lines. + * An empty array is returned of the given _remotePath_ + * does not exist. + */ +export async function ls (remotePath, opts = []) { + try { + let args = ['ls', ...opts, remotePath]; + let stdout = await this.shell(args); + let lines = stdout.split('\n'); + return lines.map((l) => l.trim()) + .filter(Boolean) + .filter((l) => l.indexOf('No such file') === -1); + } catch (err) { + if (err.message.indexOf('No such file or directory') === -1) { + throw err; + } + return []; + } +} + +/** + * Get the size of the particular file located on the device under test. + * + * @this {import('../adb.js').ADB} + * @param {string} remotePath - The remote path to the file. + * @return {Promise} File size in bytes. + * @throws {Error} If there was an error while getting the size of the given file. + */ +export async function fileSize (remotePath) { + try { + const files = await this.ls(remotePath, ['-la']); + if (files.length !== 1) { + throw new Error(`Remote path is not a file`); + } + // https://regex101.com/r/fOs4P4/8 + const match = /[rwxsStT\-+]{10}[\s\d]*\s[^\s]+\s+[^\s]+\s+(\d+)/.exec(files[0]); + if (!match || _.isNaN(parseInt(match[1], 10))) { + throw new Error(`Unable to parse size from list output: '${files[0]}'`); + } + return parseInt(match[1], 10); + } catch (err) { + throw new Error(`Unable to get file size for '${remotePath}': ${err.message}`); + } +} + +/** + * Forcefully recursively remove a path on the device under test. + * Be careful while calling this method. + * + * @this {import('../adb.js').ADB} + * @param {string} path - The path to be removed recursively. + */ +export async function rimraf (path) { + await this.shell(['rm', '-rf', path]); +} + +/** + * Send a file to the device under test. + * + * @this {import('../adb.js').ADB} + * @param {string} localPath - The path to the file on the local file system. + * @param {string} remotePath - The destination path on the remote device. + * @param {object} [opts] - Additional options mapping. See + * https://github.com/appium/node-teen_process, + * _exec_ method options, for more information about available + * options. + */ +export async function push (localPath, remotePath, opts) { + await this.mkdir(path.posix.dirname(remotePath)); + await this.adbExec(['push', localPath, remotePath], opts); +} + +/** + * Receive a file from the device under test. + * + * @this {import('../adb.js').ADB} + * @param {string} remotePath - The source path on the remote device. + * @param {string} localPath - The destination path to the file on the local file system. + * @param {import('teen_process').TeenProcessExecOptions} [opts={}] - Additional options mapping. See + * https://github.com/appium/node-teen_process, + * _exec_ method options, for more information about available + * options. + */ +export async function pull (remotePath, localPath, opts = {}) { + // pull folder can take more time, increasing time out to 60 secs + await this.adbExec(['pull', remotePath, localPath], {...opts, timeout: opts.timeout ?? 60000}); +} + +/** + * Recursively create a new folder on the device under test. + * + * @this {import('../adb.js').ADB} + * @param {string} remotePath - The new path to be created. + * @return {Promise} mkdir command output. + */ +export async function mkdir (remotePath) { + return await this.shell(['mkdir', '-p', remotePath]); +} diff --git a/lib/tools/system-calls.js b/lib/tools/system-calls.js index 6e726d65..4e771b57 100644 --- a/lib/tools/system-calls.js +++ b/lib/tools/system-calls.js @@ -1148,74 +1148,6 @@ export async function isRoot () { return (await this.shell(['whoami'])).trim() === 'root'; } -/** - * Verify whether a remote path exists on the device under test. - * - * @this {import('../adb.js').ADB} - * @param {string} remotePath - The remote path to verify. - * @return {Promise} True if the given path exists on the device. - */ -export async function fileExists (remotePath) { - const passFlag = '__PASS__'; - const checkCmd = `[ -e '${remotePath.replace(/'/g, `\\'`)}' ] && echo ${passFlag}`; - try { - return _.includes(await this.shell([checkCmd]), passFlag); - } catch { - return false; - } -} - -/** - * Get the output of _ls_ command on the device under test. - * - * @this {import('../adb.js').ADB} - * @param {string} remotePath - The remote path (the first argument to the _ls_ command). - * @param {string[]} [opts] - Additional _ls_ options. - * @return {Promise} The _ls_ output as an array of split lines. - * An empty array is returned of the given _remotePath_ - * does not exist. - */ -export async function ls (remotePath, opts = []) { - try { - let args = ['ls', ...opts, remotePath]; - let stdout = await this.shell(args); - let lines = stdout.split('\n'); - return lines.map((l) => l.trim()) - .filter(Boolean) - .filter((l) => l.indexOf('No such file') === -1); - } catch (err) { - if (err.message.indexOf('No such file or directory') === -1) { - throw err; - } - return []; - } -} - -/** - * Get the size of the particular file located on the device under test. - * - * @this {import('../adb.js').ADB} - * @param {string} remotePath - The remote path to the file. - * @return {Promise} File size in bytes. - * @throws {Error} If there was an error while getting the size of the given file. - */ -export async function fileSize (remotePath) { - try { - const files = await this.ls(remotePath, ['-la']); - if (files.length !== 1) { - throw new Error(`Remote path is not a file`); - } - // https://regex101.com/r/fOs4P4/8 - const match = /[rwxsStT\-+]{10}[\s\d]*\s[^\s]+\s+[^\s]+\s+(\d+)/.exec(files[0]); - if (!match || _.isNaN(parseInt(match[1], 10))) { - throw new Error(`Unable to parse size from list output: '${files[0]}'`); - } - return parseInt(match[1], 10); - } catch (err) { - throw new Error(`Unable to get file size for '${remotePath}': ${err.message}`); - } -} - /** * Installs the given certificate on a rooted real device or * an emulator. The emulator must be executed with `-writable-system`