appium-adb
Version:
Android Debug Bridge interface
1,252 lines (1,173 loc) • 42.5 kB
text/typescript
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;
}