UNPKG

appium-adb

Version:

Android Debug Bridge interface

1,252 lines (1,173 loc) 42.5 kB
import _ from 'lodash'; import {fs, tempDir, util, system} from '@appium/support'; import {log} from '../logger'; import {waitForCondition} from 'asyncbox'; import path from 'node:path'; import type {ADB} from '../adb'; import type {ExecError} from 'teen_process'; import type { StringRecord, InstallState, ResolveActivityOptions, IsAppInstalledOptions, StartUriOptions, StartAppOptions, AppInfo, PackageActivityInfo, ListInstalledPackagesOptions, ListInstalledPackagesResult, } from './types'; // Constants export const APP_INSTALL_STATE: StringRecord<InstallState> = { 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 RESOLVER_ACTIVITY_NAME = 'android/com.android.internal.app.ResolverActivity'; const MAIN_ACTION = 'android.intent.action.MAIN'; const LAUNCHER_CATEGORY = 'android.intent.category.LAUNCHER'; // Public methods /** * Verify whether the given argument is a * valid class name. * * @param classString - The actual class name to be verified. * @returns The result of Regexp.exec operation * or _null_ if no matches are found. */ export function isValidClass(this: ADB, classString: string): boolean { // 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. * * @param pkg - The target package identifier * @param opts - Options for resolving the activity * @returns Fully qualified name of the launchable activity * @throws {Error} If there was an error while resolving the activity name */ export async function resolveLaunchableActivity( this: ADB, pkg: string, opts: ResolveActivityOptions = {}, ): Promise<string> { 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 apkActivity as string; } catch (e) { const err = e as Error; 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}`, ); } /** * Forcefully stops the app and puts it in the "stopped" state. * Android treats a "stopped" app as if it was never launched since boot: * - It cannot receive broadcast intents (except for explicit ones). * - Scheduled jobs, alarms, and services are cancelled. * - The app won't restart until the user explicitly launches it again. * It's the same as when a user swipes an app away from Settings → Apps → Force Stop. * * @param pkg - The package name to be stopped. * @returns The output of the corresponding adb command. */ export async function forceStop(this: ADB, pkg: string): Promise<string> { return await this.shell(['am', 'force-stop', pkg]); } /** * Gracefully kills the app's process, similar to how Android would do it * automatically when low on memory. * It only kills the process, without changing the app's "stopped" state. * Background services or broadcast receivers may restart soon after, * if they are still scheduled or registered. * No data or state (like alarms, jobs, etc.) are cleared. * * @param pkg - The package name to be stopped. * @returns The output of the corresponding adb command. */ export async function killPackage(this: ADB, pkg: string): Promise<string> { return await this.shell(['am', 'kill', pkg]); } /** * Clear the user data of the particular application on the device * under test. * * @param pkg - The package name to be cleared. * @returns The output of the corresponding adb command. */ export async function clear(this: ADB, pkg: string): Promise<string> { 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. * * @param pkg - The package name to be processed. * @param apk - The path to the actual apk file. * @throws {Error} If there was an error while granting permissions */ export async function grantAllPermissions(this: ADB, pkg: string, apk?: string): Promise<void> { const apiLevel = await this.getApiLevel(); let targetSdk = 0; let dumpsysOutput: string | null = 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. * * @param pkg - The package name to be processed. * @param permissions - The list of permissions to be granted. * @throws {Error} If there was an error while changing permissions. */ export async function grantPermissions( this: ADB, pkg: string, permissions: string[], ): Promise<void> { // 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 = e as ExecError; if (!IGNORED_PERM_ERRORS.some((pattern) => pattern.test(err.stderr || err.message))) { throw err; } } } /** * Grant single permission for the particular package. * * @param pkg - The package name to be processed. * @param permission - The full name of the permission to be granted. * @throws {Error} If there was an error while changing permissions. */ export async function grantPermission(this: ADB, pkg: string, permission: string): Promise<void> { try { await this.shell(['pm', 'grant', pkg, permission]); } catch (e) { const err = e as ExecError; if (!NOT_CHANGEABLE_PERM_ERROR.test(err.stderr || err.message)) { throw err; } } } /** * Revoke single permission from the particular package. * * @param pkg - The package name to be processed. * @param permission - The full name of the permission to be revoked. * @throws {Error} If there was an error while changing permissions. */ export async function revokePermission(this: ADB, pkg: string, permission: string): Promise<void> { try { await this.shell(['pm', 'revoke', pkg, permission]); } catch (e) { const err = e as ExecError; if (!NOT_CHANGEABLE_PERM_ERROR.test(err.stderr || err.message)) { throw err; } } } /** * Retrieve the list of granted permissions for the particular package. * * @param pkg - The package name to be processed. * @param cmdOutput - Optional parameter containing command output of * _dumpsys package_ command. It may speed up the method execution. * @returns The list of granted permissions or an empty list. * @throws {Error} If there was an error while changing permissions. */ export async function getGrantedPermissions( this: ADB, pkg: string, cmdOutput: string | null = null, ): Promise<string[]> { 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. * * @param pkg - The package name to be processed. * @param cmdOutput - Optional parameter containing command output of * _dumpsys package_ command. It may speed up the method execution. * @returns The list of denied permissions or an empty list. */ export async function getDeniedPermissions( this: ADB, pkg: string, cmdOutput: string | null = null, ): Promise<string[]> { 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. * * @param pkg - The package name to be processed. * @param cmdOutput - Optional parameter containing command output of * _dumpsys package_ command. It may speed up the method execution. * @returns The list of requested permissions or an empty list. */ export async function getReqPermissions( this: ADB, pkg: string, cmdOutput: string | null = null, ): Promise<string[]> { 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. * * @param pkg - The package name to be processed. */ export async function stopAndClear(this: ADB, pkg: string): Promise<void> { try { await this.forceStop(pkg); await this.clear(pkg); } catch (e) { const err = e as Error; throw new Error(`Cannot stop and clear ${pkg}. Original error: ${err.message}`); } } /** * Get the package info from the installed application. * * @param pkg - The name of the installed package. * @returns The parsed application information. */ export async function getPackageInfo(this: ADB, pkg: string): Promise<AppInfo> { log.debug(`Getting package info for '${pkg}'`); const result: AppInfo = {name: pkg}; let stdout: string; try { stdout = await this.shell(['dumpsys', 'package', pkg]); } catch (err) { const error = err as Error; log.debug(error.stack); log.warn(`Got an unexpected error while dumping package info: ${error.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 * * @param pkg - The package identifier (must be already installed on the device) * @param tmpDir - The destination folder path * @returns Full path to the downloaded file * @throws {Error} If there was an error while fetching the .apk */ export async function pullApk(this: ADB, pkg: string, tmpDir: string): Promise<string> { 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. * * @param appId - Application package identifier * @throws {Error} If the app cannot be activated */ export async function activateApp(this: ADB, appId: string): Promise<void> { 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) { const error = e as Error; throw log.errorWithException(`Cannot activate '${appId}'. Original error: ${error.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:/im.test(stdout)) { throw new Error(`Cannot activate '${appId}'. Original error: ${stdout}`); } } /** * Check whether the particular package is present on the device under test. * * @param pkg - The name of the package to check. * @param opts - Options for checking installation * @returns True if the package is installed. */ export async function isAppInstalled( this: ADB, pkg: string, opts: IsAppInstalledOptions = {}, ): Promise<boolean> { const {user} = opts; log.debug(`Getting install status for ${pkg}`); let isInstalled: boolean; 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 installedPAckages = await this.listInstalledPackages(opts); isInstalled = installedPAckages.some((p) => p.appPackage === pkg); } log.debug(`'${pkg}' is${!isInstalled ? ' not' : ''} installed`); return isInstalled; } /** * Retrieves a list of installed packages on the device. * Lower than API Level 26 would raise an exception. * * @param opts - Options for retrieving installed packages * @returns A promise that resolves to an array of installed package information, * including package name (for API level 26+) and optional version code (for API level 28+). * @throws {Error} If there is an error while retrieving the package list * */ export async function listInstalledPackages( this: ADB, opts: ListInstalledPackagesOptions = {}, ): Promise<ListInstalledPackagesResult[]> { const {user} = opts; const cmd = ['cmd', 'package', 'list', 'packages']; if ((await this.getApiLevel()) >= 28) { cmd.push('--show-versioncode'); } if (util.hasValue(user)) { cmd.push('--user', user); } let stdout: string; try { stdout = await this.shell(cmd); } catch (e) { const error = e as ExecError; // https://github.com/appium/appium-uiautomator2-driver/issues/810 if ( _.includes(error.stderr || error.stdout || error.message, 'access user') && _.isEmpty(user) ) { stdout = await this.shell([...cmd, '--user', '0']); } else { throw e; } } // Parse the output: "package:com.example.app" or "package:com.example.app versionCode:123" const packageRegex = /^package:(\S+)(?:\s+versionCode:(\d+))?/gm; return Array.from(stdout.matchAll(packageRegex), (match) => ({ appPackage: match[1], versionCode: match[2] || null, })); } /** * Start the particular URI on the device under test. * * @param uri - The name of URI to start. * @param pkg - The name of the package to start the URI with. * @param opts - Options for starting the URI */ export async function startUri( this: ADB, uri: string, pkg: string | null = null, opts: StartUriOptions = {}, ): Promise<void> { 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. * * @param startAppOptions - Startup options mapping. * @returns The output of the corresponding adb command. * @throws {Error} If there is an error while executing the activity */ export async function startApp(this: ADB, startAppOptions: StartAppOptions): Promise<string> { if (!startAppOptions.pkg || !(startAppOptions.activity || startAppOptions.action)) { throw new Error('pkg, and activity or intent action, are required to start an application'); } const options = _.clone(startAppOptions); if (options.activity) { options.activity = options.activity.replace('$', '\\$'); } // initializing defaults _.defaults(options, { waitPkg: options.pkg, waitForLaunch: true, waitActivity: false, retry: true, stopApp: true, }); // preventing null waitpkg options.waitPkg = options.waitPkg || options.pkg; const apiLevel = await this.getApiLevel(); const cmd = buildStartCmd(options, apiLevel); const intentName = `${options.action}${ options.optionalIntentArguments ? ' ' + options.optionalIntentArguments : '' }`; try { const shellOpts: {timeout?: number} = {}; if ( options.waitDuration !== undefined && _.isInteger(options.waitDuration) && options.waitDuration >= 0 ) { shellOpts.timeout = options.waitDuration; } const stdout = await this.shell(cmd, shellOpts); if (stdout.includes('Error: Activity class') && stdout.includes('does not exist')) { if (options.retry && options.activity && !options.activity.startsWith('.')) { log.debug( `We tried to start an activity that doesn't exist, ` + `retrying with '.${options.activity}' activity name`, ); options.activity = `.${options.activity}`; options.retry = false; return await this.startApp(options); } throw new Error( `Activity name '${options.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 '${options.activity}' activity has been denied.` + `Make sure the activity/package names are correct.`, ); } if (options.waitActivity) { await this.waitForActivity(options.waitPkg, options.waitActivity, options.waitDuration); } return stdout; } catch (e) { const error = e as Error; const appDescriptor = options.pkg || intentName; throw new Error( `Cannot start the '${appDescriptor}' application. ` + `Consider checking the driver's troubleshooting documentation. ` + `Original error: ${error.message}`, ); } } /** * Helper method to call `adb dumpsys window windows/displays` * * @returns The output of the dumpsys command */ export async function dumpWindows(this: ADB): Promise<string> { 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. * * @returns The focused package and activity information * @throws {Error} If there is an error while parsing the data. */ export async function getFocusedPackageAndActivity(this: ADB): Promise<PackageActivityInfo> { log.debug('Getting focused package and activity'); let stdout: string; try { stdout = await this.dumpWindows(); } catch (e) { const error = e as Error; throw new Error( `Could not retrieve the currently focused package and activity. Original error: ${error.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', ); const focusedAppCandidates: PackageActivityInfo[] = []; const currentFocusAppCandidates: PackageActivityInfo[] = []; const pairs: [PackageActivityInfo[], RegExp][] = [ [focusedAppCandidates, focusedAppRe], [currentFocusAppCandidates, currentFocusAppRe], ]; for (const [candidates, pattern] of pairs) { let match: RegExpExecArray | null; 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, (value, other) => { if (!_.isEqual(value.appPackage, other.appPackage)) { return false; } // https://github.com/appium/appium-adb/issues/797 const [thisActivity, otherActivity] = [value.appActivity, other.appActivity].map((name) => name?.replace(value.appPackage || '', ''), ); return Boolean(thisActivity && otherActivity && _.isEqual(thisActivity, otherActivity)); })[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. * * @param pkg - The name of the package to wait for. * @param activity - The name of the activity, belonging to that package, * to wait for. * @param waitForStop - Whether to wait until the activity is focused (true) * or is not focused (false). * @param waitMs - Number of milliseconds to wait before timeout occurs. * @throws {Error} If timeout happens. */ export async function waitForActivityOrNot( this: ADB, pkg: string, activity: string, waitForStop: boolean, waitMs: number = 20000, ): Promise<void> { if (!pkg || !activity) { throw new Error('Package and activity required.'); } const splitNames = (names: string) => names.split(',').map(_.trim); const allPackages = splitNames(pkg); const allActivities = splitNames(activity); const toFullyQualifiedActivityName = (prefix: string, suffix: string) => `${prefix}${suffix}`.replace(/\/\.?/g, '.').replace(/\.{2,}/g, '.'); const possibleActivityNamesSet = new Set<string>(); 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. if (oneActivity.includes('/')) { possibleActivityNamesSet.add(oneActivity.split('/')[1]); // Add the activity component after '/' for a case the fully qualified name starts with a different package 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}`)); } } } } 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, '\\$')}$`), ); log.debug( `Expected activity name patterns to ${waitForStop ? 'not ' : ''}be focused within ${waitMs}ms: ` + possibleActivityPatterns.map((name) => `'${name}'`).join(', '), ); const conditionFunc = async () => { let appPackage: string | null | undefined; let appActivity: string | null | undefined; try { ({appPackage, appActivity} = await this.getFocusedPackageAndActivity()); } catch (e) { const error = e as Error; log.debug(error.message); return false; } if (appActivity && appPackage) { log.debug(`Focused package: ${appPackage}`); const fullyQualifiedActivity = toFullyQualifiedActivityName( appActivity.startsWith('.') ? appPackage : '', appActivity, ); log.debug(`Focused fully qualified activity name: ${fullyQualifiedActivity}`); const isFound = _.includes(allPackages, appPackage) && possibleActivityPatterns.some((p) => p.test(fullyQualifiedActivity)); if ((!waitForStop && isFound) || (waitForStop && !isFound)) { return true; } } log.debug( 'None of the expected package/activity combinations matched to the currently focused one. 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 * * @param pkg - The name of the package to wait for. * @param act - The name of the activity, belonging to that package, * to wait for. * @param waitMs - Number of milliseconds to wait before timeout occurs. * @throws {Error} If timeout happens. */ export async function waitForActivity( this: ADB, pkg: string, act: string, waitMs: number = 20000, ): Promise<void> { await this.waitForActivityOrNot(pkg, act, false, waitMs); } /** * Wait for the given activity to be non-focused. * * @param pkg - The name of the package to wait for. * @param act - The name of the activity, belonging to that package, * to wait for. * @param waitMs - Number of milliseconds to wait before timeout occurs. * @throws {Error} If timeout happens. */ export async function waitForNotActivity( this: ADB, pkg: string, act: string, waitMs: number = 20000, ): Promise<void> { await this.waitForActivityOrNot(pkg, act, true, waitMs); } /** * Builds command line representation for the given * application startup options * * @param startAppOptions - Application options mapping * @param apiLevel - The actual OS API level * @returns The actual command line array */ export function buildStartCmd(startAppOptions: StartCmdOptions, apiLevel: number): string[] { const { user, waitForLaunch, pkg, activity, action, category, stopApp, flags, optionalIntentArguments, } = startAppOptions; const cmd = ['am', apiLevel < 26 ? 'start' : 'start-activity']; if (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; } /** * Parses the name of launchable package activity * from dumpsys output. * * @param dumpsys - The actual dumpsys output * @returns 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. */ export function parseLaunchableActivityNames(dumpsys: string): string[] { const mainActivityNameRe = new RegExp(`^\\s*${_.escapeRegExp(MAIN_ACTION)}:$`); const categoryNameRe = /^\s*Category:\s+"([a-zA-Z0-9._/-]+)"$/; const blocks: string[][] = []; let blockStartIndent: number | null | undefined; let block: string[] = []; for (const line of dumpsys.split('\n').map(_.trimEnd)) { const currentIndent = line.length - _.trimStart(line).length; if (mainActivityNameRe.test(line)) { blockStartIndent = currentIndent; if (!_.isEmpty(block)) { blocks.push(block); block = []; } continue; } if (_.isNil(blockStartIndent)) { continue; } if (currentIndent > blockStartIndent) { block.push(line); } else { if (!_.isEmpty(block)) { blocks.push(block); block = []; } blockStartIndent = null; } } if (!_.isEmpty(block)) { blocks.push(block); } const result: string[] = []; for (const item of blocks) { let hasCategory = false; let isLauncherCategory = false; for (const line of item) { const match = categoryNameRe.exec(line); if (!match) { continue; } hasCategory = true; isLauncherCategory = match[1] === LAUNCHER_CATEGORY; break; } // On older Android versions the category name // might not be listed, so we just try to fetch // all matches instead if (hasCategory && !isLauncherCategory) { continue; } for (const activityNameStr of item.map(_.trim).filter(Boolean)) { const fqActivityName = activityNameStr.split(/\s+/)[1]; if (!matchComponentName(fqActivityName)) { continue; } if (isLauncherCategory) { return [fqActivityName]; } result.push(fqActivityName); } } return result; } /** * Check if the given string is a valid component name * * @param classString - The string to verify * @returns The result of Regexp.exec operation * or _null_ if no matches are found */ export function matchComponentName(classString: string): RegExpExecArray | null { // some.package/some.package.Activity return /^[\p{L}0-9./_]+$/u.exec(classString); } /** * Retrieves the list of permission names encoded in `dumpsys package` command output. * * @param dumpsysOutput - The actual command output. * @param groupNames - The list of group names to list permissions for. * @param grantedState - The expected state of `granted` attribute to filter with. * No filtering is done if the parameter is not set. * @returns The list of matched permission names or an empty list if no matches were found. */ export function extractMatchingPermissions( dumpsysOutput: string, groupNames: string[], grantedState: boolean | null = null, ): string[] { const groupPatternByName = (groupName: string) => new RegExp(`^(\\s*${_.escapeRegExp(groupName)} permissions:[\\s\\S]+)`, 'm'); const indentPattern = /\S|$/; const permissionNamePattern = /android\.\w*\.?permission\.\w+/; const grantedStatePattern = /\bgranted=(\w+)/; const result: Array<{permission: string; granted?: boolean}> = []; for (const groupName of groupNames) { const groupMatch = groupPatternByName(groupName).exec(dumpsysOutput); if (!groupMatch) { continue; } const lines = groupMatch[1].split('\n'); if (lines.length < 2) { continue; } const titleIndent = lines[0].search(indentPattern); for (const line of lines.slice(1)) { const currentIndent = line.search(indentPattern); if (currentIndent <= titleIndent) { break; } const permissionNameMatch = permissionNamePattern.exec(line); if (!permissionNameMatch) { continue; } const item: {permission: string; granted?: boolean} = { permission: permissionNameMatch[0], }; const grantedStateMatch = grantedStatePattern.exec(line); if (grantedStateMatch) { item.granted = grantedStateMatch[1] === 'true'; } result.push(item); } } const filteredResult = result .filter((item) => !_.isBoolean(grantedState) || item.granted === grantedState) .map((item) => item.permission); log.debug( `Retrieved ${util.pluralize('permission', filteredResult.length, true)} ` + `from ${groupNames} ${util.pluralize('group', groupNames.length, false)}`, ); return filteredResult; } /** * Broadcast a message to the given intent. * * @param intent - The name of the intent to broadcast to. * @throws {Error} If intent name is not a valid class name. */ export async function broadcast(this: ADB, intent: string): Promise<void> { if (!this.isValidClass(intent)) { throw new Error(`Invalid intent ${intent}`); } log.debug(`Broadcasting: ${intent}`); await this.shell(['am', 'broadcast', '-a', intent]); } /** * Get the list of process ids for the particular package on the device under test. * * @param pkg - The package name * @returns The list of matched process IDs or an empty list. */ export async function listAppProcessIds(this: ADB, pkg: string): Promise<number[]> { log.debug(`Getting IDs of all '${pkg}' package`); const pidRegex = new RegExp(`ProcessRecord\\{[\\w]+\\s+(\\d+):${_.escapeRegExp(pkg)}\\/`); const processesInfo = await this.shell(['dumpsys', 'activity', 'processes']); const pids = processesInfo .split('\n') .map((line) => line.match(pidRegex)) .filter((match) => !!match) .map(([, pidStr]) => parseInt(pidStr, 10)); return _.uniq(pids); } /** * Check whether the process with the particular name is running on the device * under test. * * @param pkg - The id of the package to be checked. * @returns True if the given package is running. */ export async function isAppRunning(this: ADB, pkg: string): Promise<boolean> { return !_.isEmpty(await this.listAppProcessIds(pkg)); } // Private methods /** * Parses optional intent arguments from a string. * * @param 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 Parsed arguments array */ function parseOptionalIntentArguments(value: string): string[] { // take a string and parse out the part before any spaces, and anything after // the first space const parseKeyValue = (str: string): string[] => { 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]+) (.+)/; const result: string[] = []; 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]; } } /** * Escapes special characters in command line arguments. * This is needed to avoid possible issues with how system `spawn` * call handles them. * See https://discuss.appium.io/t/how-to-modify-wd-proxy-and-uiautomator2-source-code-to-support-unicode/33466 * for more details. * * @param arg - Non-escaped argument string * @returns The escaped argument */ function escapeShellArg(arg: string): string { arg = `${arg}`; if (system.isWindows()) { return /[&|^\s]/.test(arg) ? `"${arg.replace(/"/g, '""')}"` : arg; } return arg.replace(/&/g, '\\&'); } // Type definitions export interface StartCmdOptions { user?: number | string; waitForLaunch?: boolean; pkg?: string; activity?: string; action?: string; category?: string; stopApp?: boolean; flags?: string; optionalIntentArguments?: string; }