UNPKG

appium-adb

Version:

Android Debug Bridge interface

1,189 lines (1,188 loc) 54.5 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.APP_INSTALL_STATE = void 0; exports.isValidClass = isValidClass; exports.resolveLaunchableActivity = resolveLaunchableActivity; exports.forceStop = forceStop; exports.killPackage = killPackage; exports.clear = clear; exports.grantAllPermissions = grantAllPermissions; exports.grantPermissions = grantPermissions; exports.grantPermission = grantPermission; exports.revokePermission = revokePermission; exports.getGrantedPermissions = getGrantedPermissions; exports.getDeniedPermissions = getDeniedPermissions; exports.getReqPermissions = getReqPermissions; exports.stopAndClear = stopAndClear; exports.listProcessStatus = listProcessStatus; exports.getNameByPid = getNameByPid; exports.getPIDsByName = getPIDsByName; exports.killProcessesByName = killProcessesByName; exports.killProcessByPID = killProcessByPID; exports.broadcastProcessEnd = broadcastProcessEnd; exports.broadcast = broadcast; exports.processExists = processExists; exports.getPackageInfo = getPackageInfo; exports.pullApk = pullApk; exports.activateApp = activateApp; exports.isAppInstalled = isAppInstalled; exports.startUri = startUri; exports.startApp = startApp; exports.dumpWindows = dumpWindows; exports.getFocusedPackageAndActivity = getFocusedPackageAndActivity; exports.waitForActivityOrNot = waitForActivityOrNot; exports.waitForActivity = waitForActivity; exports.waitForNotActivity = waitForNotActivity; exports.buildStartCmd = buildStartCmd; exports.parseLaunchableActivityNames = parseLaunchableActivityNames; exports.matchComponentName = matchComponentName; exports.extractMatchingPermissions = extractMatchingPermissions; const lodash_1 = __importDefault(require("lodash")); const support_1 = require("@appium/support"); const logger_js_1 = require("../logger.js"); const asyncbox_1 = require("asyncbox"); const bluebird_1 = __importDefault(require("bluebird")); const path_1 = __importDefault(require("path")); /** @type {import('./types').StringRecord<import('./types').InstallState>} */ exports.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'; const MAIN_ACTION = 'android.intent.action.MAIN'; const LAUNCHER_CATEGORY = 'android.intent.category.LAUNCHER'; /** * 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. */ 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<string>} Fully qualified name of the launchable activity * @throws {Error} If there was an error while resolving the activity name */ 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 (lodash_1.default.isEmpty(names)) { logger_js_1.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 support_1.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); logger_js_1.log.debug(err.stack); logger_js_1.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 support_1.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(lodash_1.default.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<string>} The output of the corresponding adb command. */ 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<string>} The output of the corresponding adb command. */ 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<string>} The output of the corresponding adb command. */ 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 */ 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 logger_js_1.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 = lodash_1.default.difference(requestedPermissions, grantedPermissions); if (lodash_1.default.isEmpty(permissionsToGrant)) { logger_js_1.log.info(`${pkg} contains no permissions available for granting`); } else { await this.grantPermissions(pkg, permissionsToGrant); } } else if (targetSdk < MIN_API_LEVEL_WITH_PERMS_SUPPORT) { logger_js_1.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) { logger_js_1.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<string>} permissions - The list of permissions to be granted. * @throws {Error} If there was an error while changing permissions. */ 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 logger_js_1.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. */ 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. */ 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<string[]>} The list of granted permissions or an empty list. * @throws {Error} If there was an error while changing permissions. */ async function getGrantedPermissions(pkg, cmdOutput = null) { logger_js_1.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<string[]>} The list of denied permissions or an empty list. */ async function getDeniedPermissions(pkg, cmdOutput = null) { logger_js_1.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<string[]>} The list of requested permissions or an empty list. */ async function getReqPermissions(pkg, cmdOutput = null) { logger_js_1.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. */ 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<string>} the output of `ps` command where all processes are included */ async function listProcessStatus() { if (!lodash_1.default.isBoolean(this._doesPsSupportAOption)) { try { this._doesPsSupportAOption = /^-A\b/m.test(await this.shell(['ps', '--help'])); } catch (e) { logger_js_1.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<string>} The process name */ 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) { logger_js_1.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]; } } logger_js_1.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<number[]>} The list of matched process IDs or an empty list. * @throws {Error} If the passed process name is not a valid one */ async function getPIDsByName(name) { logger_js_1.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 (!lodash_1.default.isBoolean(this._isPgrepAvailable)) { // pgrep is in priority, since pidof has been reported of having bugs on some platforms const pgrepOutput = lodash_1.default.trim(await this.shell(['pgrep --help; echo $?'])); this._isPgrepAvailable = parseInt(`${lodash_1.default.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', lodash_1.default.escapeRegExp(`([[:blank:]]|^)${name}(:[a-zA-Z0-9_-]+)?([[:blank:]]|$)`)] // https://github.com/appium/appium/issues/13872 : [`pgrep ^${lodash_1.default.escapeRegExp(name.slice(-MAX_PGREP_PATTERN_LEN))}$ ` + `|| pgrep ^${lodash_1.default.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) => lodash_1.default.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 (lodash_1.default.includes(err.stderr || err.stdout, 'syntax error')) { logger_js_1.log.warn(`Got an unexpected response from the shell interpreter: ${err.stderr || err.stdout}`); } else { return []; } } } } logger_js_1.log.debug('Using ps-based PID detection'); const stdout = await this.listProcessStatus(); const titleMatch = PS_TITLE_PATTERN.exec(stdout); if (!titleMatch) { logger_js_1.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${lodash_1.default.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])) { logger_js_1.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. */ async function killProcessesByName(name) { try { logger_js_1.log.debug(`Attempting to kill all ${name} processes`); const pids = await this.getPIDsByName(name); if (lodash_1.default.isEmpty(pids)) { logger_js_1.log.info(`No '${name}' process has been found`); } else { await bluebird_1.default.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. */ async function killProcessByPID(pid) { logger_js_1.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 (lodash_1.default.includes(err.stderr, noProcessFlag)) { return; } if (!lodash_1.default.includes(err.stderr, 'Operation not permitted')) { throw err; } logger_js_1.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 (lodash_1.default.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. */ 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 (0, asyncbox_1.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. */ async function broadcast(intent) { if (!this.isValidClass(intent)) { throw new Error(`Invalid intent ${intent}`); } logger_js_1.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<boolean>} True if the given process is running. * @throws {Error} If the given process name is not a valid class name. */ async function processExists(processName) { return !lodash_1.default.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<import('./types').AppInfo>} The parsed application information. */ async function getPackageInfo(pkg) { logger_js_1.log.debug(`Getting package info for '${pkg}'`); const result = { name: pkg }; let stdout; try { stdout = await this.shell(['dumpsys', 'package', pkg]); } catch (err) { logger_js_1.log.debug(err.stack); logger_js_1.log.warn(`Got an unexpected error while dumping package info: ${err.message}`); return result; } const installedPattern = new RegExp(`^\\s*Package\\s+\\[${lodash_1.default.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<string>} Full path to the downloaded file * @throws {Error} If there was an error while fetching the .apk */ async function pullApk(pkg, tmpDir) { const stdout = lodash_1.default.trim(await this.shell(['pm', 'path', pkg])); const packageMarker = 'package:'; if (!lodash_1.default.startsWith(stdout, packageMarker)) { throw new Error(`Cannot pull the .apk package for '${pkg}'. Original error: ${stdout}`); } const remotePath = stdout.replace(packageMarker, ''); const tmpApp = path_1.default.resolve(tmpDir, `${pkg}.apk`); await this.pull(remotePath, tmpApp); logger_js_1.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 */ async function activateApp(appId) { logger_js_1.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); logger_js_1.log.debug(`Command stdout: ${output}`); } catch (e) { throw logger_js_1.log.errorWithException(`Cannot activate '${appId}'. Original error: ${e.message}`); } if (output.includes('monkey aborted')) { throw logger_js_1.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 logger_js_1.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, ]); logger_js_1.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<boolean>} True if the package is installed. */ async function isAppInstalled(pkg, opts = {}) { const { user, } = opts; logger_js_1.log.debug(`Getting install status for ${pkg}`); /** @type {boolean} */ let isInstalled; if (await this.getApiLevel() < 26) { try { const cmd = ['pm', 'path']; if (support_1.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 (support_1.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 (lodash_1.default.includes(e.stderr || e.stdout || e.message, 'access user') && lodash_1.default.isEmpty(user)) { stdout = await this.shell([...cmd, '--user', '0']); } else { throw e; } } isInstalled = new RegExp(`^package:${lodash_1.default.escapeRegExp(pkg)}$`, 'm').test(stdout); } logger_js_1.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={}] */ 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<string>} The output of the corresponding adb command. * @throws {Error} If there is an error while executing the activity */ 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 = lodash_1.default.clone(startAppOptions); if (startAppOptions.activity) { startAppOptions.activity = startAppOptions.activity.replace('$', '\\$'); } // initializing defaults lodash_1.default.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 (lodash_1.default.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('.')) { logger_js_1.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<string>} */ 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<import('./types').PackageActivityInfo>} * @throws {Error} If there is an error while parsing the data. */ async function getFocusedPackageAndActivity() { logger_js_1.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 lodash_1.default.intersectionWith(focusedAppCandidates, currentFocusAppCandidates, lodash_1.default.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 }; } } logger_js_1.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. */ async function waitForActivityOrNot(pkg, activity, waitForStop, waitMs = 20000) { if (!pkg || !activity) { throw new Error('Package and activity required.'); } const splitNames = (/** @type {string} */ names) => names.split(',').map(lodash_1.default.trim); const allPackages = splitNames(pkg); const allActivities = splitNames(activity); const toFullyQualifiedActivityName = (/** @type {string} */ prefix, /** @type {string} */ suffix) => `${prefix}${suffix}`.replace(/\/\.?/g, '.').replace(/\.{2,}/g, '.'); /** @type {Set<string>} */ const possibleActivityNamesSet = new Set(); for (const oneActivity of allActivities) { if (oneActivity.startsWith('.')) { // add the package name if activity is not full qualified for (const onePkg of allPackages) { possibleActivityNamesSet.add(toFullyQualifiedActivityName(onePkg, oneActivity)); } } else { // accept fully qualified activity name. possibleActivityNamesSet.add(toFullyQualifiedActivityName(oneActivity, '')); const doesIncludePackage = allPackages.some((p) => oneActivity.startsWith(p)); if (!doesIncludePackage) { for (const onePkg of allPackages) { possibleActivityNamesSet.add(toFullyQualifiedActivityName(onePkg, `.${oneActivity}`)); } } } } logger_js_1.log.debug(`Expected package names to ${waitForStop ? 'not ' : ''}be focused within ${waitMs}ms: ` + allPackages.map((name) => `'${name}'`).join(', ')); const possibleActivityNames = [...possibleActivityNamesSet]; const possibleActivityPatterns = possibleActivityNames.map((actName) => new RegExp(`^${actName.replace(/\./g, '\\.').replace(/\*/g, '.*?').replace(/\$/g, '\\$')}$`)); logger_js_1.log.debug(`Expected activity name patterns to ${waitForStop ? 'not ' : ''}be focused within ${waitMs}ms: ` + possibleActivityPatterns.map((name) => `'${name}'`).join(', ')); const conditionFunc = async () => { let appPackage; let appActivity; try { ({ appPackage, appActivity } = await this.getFocusedPackageAndActivity()); } catch (e) { logger_js_1.log.debug(e.message); return false; } if (appActivity && appPackage) { logger_js_1.log.debug(`Focused package: ${appPackage}`); const fullyQualifiedActivity = toFullyQualifiedActivityName(appActivity.startsWith('.') ? appPackage : '', appActivity); logger_js_1.log.debug(`Focused fully qualified activity name: ${fullyQualifiedActivity}`); const isFound = lodash_1.default.includes(allPackages, appPackage) && possibleActivityPatterns.some((p) => p.test(fullyQualifiedActivity)); if ((!waitForStop && isFound) || (waitForStop && !isFound)) { return true; } } logger_js_1.log.debug('None of the expected package/activity combinations matched to the currently focused one. Retrying'); return false; }; try { await (0, asyncbox_1.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. */ 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. */ async function waitForNotActivity(pkg, act, waitMs = 20000) { await this.waitForActivityOrNot(pkg, act, true, waitMs); } // #region Private functions /** * Builds command line representation for the given * application startup options * * @param {StartCmdOptions} startAppOptions - Application options mapping * @param {number} apiLevel - The actual OS API level * @returns {string[]} The actual command line array */ function buildStartCmd(startAppOptions, apiLevel) { const { user, waitForLaunch, pkg, activity, action, category, stopApp, flags, optionalIntentArguments, } = startAppOptions; const cmd = ['am', (apiLevel < 26) ? 'start' : 'start-activity']; if (support_1.util.hasValue(user)) { cmd.push('--user', `${user}`); } if (waitForLaunch) { cmd.push('-W'); } if (activity && pkg) { cmd.push('-n', activity.startsWith(`${pkg}/`) ? activity : `${pkg}/${activity}`); } if (stopApp && apiLevel >= 15) { cmd.push('-S'); } if (action) { cmd.push('-a', action); } if (category) { cmd.push('-c', category); } if (flags) { cmd.push('-f', flags); } if (optionalIntentArguments) { cmd.push(...parseOptionalIntentArguments(optionalIntentArguments)); } return cmd; } /** * * @param {string} value expect optionalIntentArguments to be a single string of the form: * "-flag key" * "-flag key value" * or a combination of these (e.g., "-flag1 key1 -flag2 key2 value2") * @returns {string[]} */ function parseOptionalIntentArguments(value) { // take a string and parse out the part before any spaces, and anything after // the first space /** @type {(str: string) => string[]} */ const parseKeyValue = (str) => { str = str.trim(); const spacePos = str.indexOf(' '); if (spacePos < 0) { return str.length ? [str] : []; } else { return [str.substring(0, spacePos).trim(), str.substring(spacePos + 1).trim()]; } }; // cycle through the optionalIntentArguments and pull out the arguments // add a space initially so flags can be distinguished from arguments that // have internal hyphens let optionalIntentArguments = ` ${value}`; const re = / (-[^\s]+) (.+)/; /** @type {string[]} */ const result = []; while (true) { const args = re.exec(optionalIntentArguments); if (!args) { if (optionalIntentArguments.length) { // no more flags, so the remainder can be treated as 'key' or 'key value' result.push(...parseKeyValue(optionalIntentArguments)); } // we are done return result; } // take the flag and see if it is at the beginning of the string // if it is not, then it means we have been through already, and // what is before the flag is the argument for the previous flag const flag = args[1]; const flagPos = optionalIntentArguments.indexOf(flag); if (flagPos !== 0) { const prevArgs = optionalIntentArguments.substring(0, flagPos); result.push(...parseKeyValue(prevArgs)); } // add the flag, as there are no more earlier arguments result.push(flag); // make optionalIntentArguments hold the remainder optionalIntentArguments = args[2]; } } /** * Parses the name of launchable package activity * from dumpsys output. * * @param {string} dumpsys the actual dumpsys output * @returns {string[]} Either the fully qualified * activity name as a single list item or an empty list if nothing could be parsed. * In Android 6 and older there is no reliable way to determine * the category name for the given activity, so this API just * returns all activity names belonging to 'android.intent.action.MAIN' * with the expectation that the app manifest could be parsed next * in order to determine category names for these. */ function parseLaunchableActivityNames(dumpsys) { const mainActivityNameRe = new RegExp(`^\\s*${lodash_1.default.escapeRegExp(MAIN_ACTION)}:$`); const categoryNameRe = /^\s*Category:\s+"([a-zA-Z0-9._/-]+)"$/; const blocks = []; let blockStartIndent; let block = []; for (const line of dumpsys.split('\n').map(lodash_1.default.trimEnd)) { const currentIndent = line.length - lodash_1.default.trimStart(line).length; if (mainActivityNameRe.test(line)) { blockStartIndent = currentIndent; if (!lodash_1.default.isEmpty(block)) { blocks.push(block); block = []; } continue; } if (lodash_1.default.isNil(blockStartIndent)) { continue; } if (currentIndent > blockStartIndent) { block.push(line); } else { if (!lodash_1.default.isEmpty(block)) { blocks.push(block); block = []; } blockStartIndent = null; } } if (!lodash_1.default.isEmpty(block)) { blocks.push(block); } const result = []; for (const item of blocks) { let hasCategory = false; let isLauncherCategory = false; for (const line of item) { const match = categoryNameRe.exec(line); if (!match) { continue;