UNPKG

appium-adb

Version:

Android Debug Bridge interface

816 lines (769 loc) 26.8 kB
import { APKS_EXTENSION, buildInstallArgs, APK_INSTALL_TIMEOUT, DEFAULT_ADB_EXEC_TIMEOUT, readPackageManifest, } from '../helpers'; import {exec, type ExecError} from 'teen_process'; import {log} from '../logger'; import path from 'node:path'; import _ from 'lodash'; import {fs, util, mkdirp, timing} from '@appium/support'; import * as semver from 'semver'; import os from 'node:os'; import {LRUCache} from 'lru-cache'; import type {ADB} from '../adb'; import type { UninstallOptions, ShellExecOptions, CachingOptions, InstallOptions, InstallOrUpgradeOptions, InstallOrUpgradeResult, ApkStrings, AppInfo, InstallState, StringRecord, } from './types'; export const REMOTE_CACHE_ROOT = '/data/local/tmp/appium_cache'; /** * Uninstall the given package from the device under test. * * @param pkg - The name of the package to be uninstalled. * @param options - The set of uninstall options. * @returns True if the package was found on the device and * successfully uninstalled. */ export async function uninstallApk( this: ADB, pkg: string, options: UninstallOptions = {}, ): Promise<boolean> { log.debug(`Uninstalling ${pkg}`); if (!options.skipInstallCheck && !(await this.isAppInstalled(pkg))) { log.info(`${pkg} was not uninstalled, because it was not present on the device`); return false; } const cmd = ['uninstall']; if (options.keepData) { cmd.push('-k'); } cmd.push(pkg); let stdout: string; try { await this.forceStop(pkg); stdout = (await this.adbExec(cmd, {timeout: options.timeout})).trim(); } catch (e) { const err = e as Error; throw new Error(`Unable to uninstall APK. Original error: ${err.message}`); } log.debug(`'adb ${cmd.join(' ')}' command output: ${stdout}`); if (stdout.includes('Success')) { log.info(`${pkg} was successfully uninstalled`); return true; } log.info(`${pkg} was not uninstalled`); return false; } /** * Install the package after it was pushed to the device under test. * * @param apkPathOnDevice - The full path to the package on the device file system. * @param opts - Additional exec options. * @throws If there was a failure during application install. */ export async function installFromDevicePath( this: ADB, apkPathOnDevice: string, opts: ShellExecOptions = {}, ): Promise<void> { const stdout = await this.shell(['pm', 'install', '-r', apkPathOnDevice], opts); if (stdout.includes('Failure')) { throw new Error(`Remote install failed: ${stdout}`); } } /** * Caches the given APK at a remote location to speed up further APK deployments. * * @param apkPath - Full path to the apk on the local FS * @param options - Caching options * @returns Full path to the cached apk on the remote file system * @throws if there was a failure while caching the app */ export async function cacheApk( this: ADB, apkPath: string, options: CachingOptions = {}, ): Promise<string> { const appHash = await fs.hash(apkPath); const remotePath = path.posix.join(REMOTE_CACHE_ROOT, `${appHash}.apk`); const remoteCachedFiles: string[] = []; // Get current contents of the remote cache or create it for the first time try { const errorMarker = '_ERROR_'; let lsOutput: string | null = null; if ( this._areExtendedLsOptionsSupported === true || !_.isBoolean(this._areExtendedLsOptionsSupported) ) { lsOutput = await this.shell([`ls -t -1 ${REMOTE_CACHE_ROOT} 2>&1 || echo ${errorMarker}`]); } if ( !_.isString(lsOutput) || (lsOutput.includes(errorMarker) && !lsOutput.includes(REMOTE_CACHE_ROOT)) ) { if (!_.isBoolean(this._areExtendedLsOptionsSupported)) { log.debug( 'The current Android API does not support extended ls options. ' + 'Defaulting to no-options call', ); } lsOutput = await this.shell([`ls ${REMOTE_CACHE_ROOT} 2>&1 || echo ${errorMarker}`]); this._areExtendedLsOptionsSupported = false; } else { this._areExtendedLsOptionsSupported = true; } if (lsOutput.includes(errorMarker)) { throw new Error(lsOutput.substring(0, lsOutput.indexOf(errorMarker))); } remoteCachedFiles.push( ...lsOutput .split('\n') .map((x) => x.trim()) .filter(Boolean), ); } catch (e) { const err = e as Error; log.debug( `Got an error '${err.message.trim()}' while getting the list of files in the cache. ` + `Assuming the cache does not exist yet`, ); await this.shell(['mkdir', '-p', REMOTE_CACHE_ROOT]); } log.debug(`The count of applications in the cache: ${remoteCachedFiles.length}`); const toHash = (remotePath: string) => path.posix.parse(remotePath).name; // Push the apk to the remote cache if needed if (remoteCachedFiles.some((x) => toHash(x) === appHash)) { log.info(`The application at '${apkPath}' is already cached to '${remotePath}'`); // Update the application timestamp asynchronously in order to bump its position // in the sorted ls output // eslint-disable-next-line promise/prefer-await-to-then this.shell(['touch', '-am', remotePath]).catch(() => {}); } else { log.info(`Caching the application at '${apkPath}' to '${remotePath}'`); const timer = new timing.Timer().start(); await this.push(apkPath, remotePath, {timeout: options.timeout}); const {size} = await fs.stat(apkPath); log.info( `The upload of '${path.basename(apkPath)}' (${util.toReadableSizeString(size)}) ` + `took ${timer.getDuration().asMilliSeconds.toFixed(0)}ms`, ); } if (!this.remoteAppsCache) { this.remoteAppsCache = new LRUCache({ max: this.remoteAppsCacheLimit as number, }); } // Cleanup the invalid entries from the cache _.difference([...this.remoteAppsCache.keys()], remoteCachedFiles.map(toHash)).forEach((hash) => (this.remoteAppsCache as LRUCache<string, string>).delete(hash), ); // Bump the cache record for the recently cached item this.remoteAppsCache.set(appHash, remotePath); // If the remote cache exceeds this.remoteAppsCacheLimit, remove the least recently used entries const entriesToCleanup = remoteCachedFiles .map((x) => path.posix.join(REMOTE_CACHE_ROOT, x)) .filter((x) => !(this.remoteAppsCache as LRUCache<string, string>).has(toHash(x))) .slice((this.remoteAppsCacheLimit as number) - [...this.remoteAppsCache.keys()].length); if (!_.isEmpty(entriesToCleanup)) { try { await this.shell(['rm', '-f', ...entriesToCleanup]); log.debug(`Deleted ${entriesToCleanup.length} expired application cache entries`); } catch (e) { const err = e as Error; log.warn( `Cannot delete ${entriesToCleanup.length} expired application cache entries. ` + `Original error: ${err.message}`, ); } } return remotePath; } /** * Install the package from the local file system. * * @param appPath - The full path to the local package. * @param options - The set of installation options. * @throws If an unexpected error happens during install. */ export async function install( this: ADB, appPath: string, options: InstallOptions = {}, ): Promise<void> { if (appPath.endsWith(APKS_EXTENSION)) { return await this.installApks(appPath, options); } options = _.cloneDeep(options); _.defaults(options, { replace: true, timeout: this.adbExecTimeout === DEFAULT_ADB_EXEC_TIMEOUT ? APK_INSTALL_TIMEOUT : this.adbExecTimeout, timeoutCapName: 'androidInstallTimeout', }); const installArgs = buildInstallArgs(await this.getApiLevel(), options); if (options.noIncremental && (await this.isIncrementalInstallSupported())) { // Adb throws an error if it does not know about an arg, // which is the case here for older adb versions. installArgs.push('--no-incremental'); } const installOpts = { timeout: options.timeout, timeoutCapName: options.timeoutCapName, }; const installCmd = ['install', ...installArgs, appPath]; try { const timer = new timing.Timer().start(); const output = await this.adbExec(installCmd, installOpts); log.info( `The installation of '${path.basename(appPath)}' took ${timer.getDuration().asMilliSeconds.toFixed(0)}ms`, ); const truncatedOutput = !_.isString(output) || output.length <= 300 ? output : `${output.substring(0, 150)}...${output.substring(output.length - 150)}`; log.debug(`Install command stdout: ${truncatedOutput}`); if (/\[INSTALL[A-Z_]+FAILED[A-Z_]+\]/.test(output)) { if (this.isTestPackageOnlyError(output)) { const msg = `Set 'allowTestPackages' capability to true in order to allow test packages installation.`; log.warn(msg); throw new Error(`${output}\n${msg}`); } throw new Error(output); } } catch (err) { const error = err as Error; // on some systems this will throw an error if the app already // exists if (!error.message.includes('INSTALL_FAILED_ALREADY_EXISTS')) { throw error; } log.debug(`Application '${appPath}' already installed. Continuing.`); } } /** * Retrieves the current installation state of the particular application * * @param appPath - Full path to the application * @param pkg - Package identifier. If omitted then the script will * try to extract it on its own * @returns One of `APP_INSTALL_STATE` constants */ export async function getApplicationInstallState( this: ADB, appPath: string, pkg: string | null = null, ): Promise<InstallState> { let apkInfo: AppInfo | null = null; if (!pkg) { apkInfo = (await this.getApkInfo(appPath)) as AppInfo; pkg = apkInfo?.name; } if (!pkg) { log.warn(`Cannot read the package name of '${appPath}'`); return this.APP_INSTALL_STATE.UNKNOWN; } const { versionCode: pkgVersionCode, versionName: pkgVersionNameStr, isInstalled, } = await this.getPackageInfo(pkg); if (!isInstalled) { log.debug(`App '${appPath}' is not installed`); return this.APP_INSTALL_STATE.NOT_INSTALLED; } const pkgVersionName = semver.valid(semver.coerce(pkgVersionNameStr)); if (!apkInfo) { apkInfo = (await this.getApkInfo(appPath)) as AppInfo; } // @ts-ignore We validate the values below const {versionCode: apkVersionCode, versionName: apkVersionNameStr} = apkInfo || {}; const apkVersionName = semver.valid(semver.coerce(apkVersionNameStr)); if (!_.isInteger(apkVersionCode) || !_.isInteger(pkgVersionCode)) { log.warn(`Cannot read version codes of '${appPath}' and/or '${pkg}'`); if (!_.isString(apkVersionName) || !_.isString(pkgVersionName)) { log.warn(`Cannot read version names of '${appPath}' and/or '${pkg}'`); return this.APP_INSTALL_STATE.UNKNOWN; } } if (_.isInteger(apkVersionCode) && _.isInteger(pkgVersionCode)) { if ((pkgVersionCode as number) > (apkVersionCode as number)) { log.debug( `The version code of the installed '${pkg}' is greater than the application version code (${pkgVersionCode} > ${apkVersionCode})`, ); return this.APP_INSTALL_STATE.NEWER_VERSION_INSTALLED; } // Version codes might not be maintained. Check version names. if (pkgVersionCode === apkVersionCode) { if ( _.isString(apkVersionName) && _.isString(pkgVersionName) && semver.satisfies(pkgVersionName, `>=${apkVersionName}`) ) { log.debug( `The version name of the installed '${pkg}' is greater or equal to the application version name ('${pkgVersionName}' >= '${apkVersionName}')`, ); return semver.satisfies(pkgVersionName, `>${apkVersionName}`) ? this.APP_INSTALL_STATE.NEWER_VERSION_INSTALLED : this.APP_INSTALL_STATE.SAME_VERSION_INSTALLED; } if (!_.isString(apkVersionName) || !_.isString(pkgVersionName)) { log.debug( `The version name of the installed '${pkg}' is equal to application version name (${pkgVersionCode} === ${apkVersionCode})`, ); return this.APP_INSTALL_STATE.SAME_VERSION_INSTALLED; } } } else if ( _.isString(apkVersionName) && _.isString(pkgVersionName) && semver.satisfies(pkgVersionName, `>=${apkVersionName}`) ) { log.debug( `The version name of the installed '${pkg}' is greater or equal to the application version name ('${pkgVersionName}' >= '${apkVersionName}')`, ); return semver.satisfies(pkgVersionName, `>${apkVersionName}`) ? this.APP_INSTALL_STATE.NEWER_VERSION_INSTALLED : this.APP_INSTALL_STATE.SAME_VERSION_INSTALLED; } log.debug( `The installed '${pkg}' package is older than '${appPath}' (${pkgVersionCode} < ${apkVersionCode} or '${pkgVersionName}' < '${apkVersionName}')'`, ); return this.APP_INSTALL_STATE.OLDER_VERSION_INSTALLED; } /** * Install the package from the local file system or upgrade it if an older * version of the same package is already installed. * * @param appPath - The full path to the local package. * @param pkg - The name of the installed package. The method will * perform faster if it is set. * @param options - Set of install options. * @throws If an unexpected error happens during install. */ export async function installOrUpgrade( this: ADB, appPath: string, pkg: string | null = null, options: InstallOrUpgradeOptions = {}, ): Promise<InstallOrUpgradeResult> { if (!pkg) { const apkInfo = await this.getApkInfo(appPath); if ('name' in apkInfo) { pkg = apkInfo.name; } else { log.warn( `Cannot determine the package name of '${appPath}'. ` + `Continuing with the install anyway`, ); } } const {enforceCurrentBuild} = options; const appState = await this.getApplicationInstallState(appPath, pkg); let wasUninstalled = false; const uninstallPackage = async () => { if (!(await this.uninstallApk(pkg as string, {skipInstallCheck: true}))) { throw new Error(`'${pkg}' package cannot be uninstalled`); } wasUninstalled = true; }; switch (appState) { case this.APP_INSTALL_STATE.NOT_INSTALLED: log.debug(`Installing '${appPath}'`); await this.install(appPath, {...options, replace: false}); return { appState, wasUninstalled, }; case this.APP_INSTALL_STATE.NEWER_VERSION_INSTALLED: if (enforceCurrentBuild) { log.info(`Downgrading '${pkg}' as requested`); await uninstallPackage(); break; } log.debug(`There is no need to downgrade '${pkg}'`); return { appState, wasUninstalled, }; case this.APP_INSTALL_STATE.SAME_VERSION_INSTALLED: if (enforceCurrentBuild) { break; } log.debug(`There is no need to install/upgrade '${appPath}'`); return { appState, wasUninstalled, }; case this.APP_INSTALL_STATE.OLDER_VERSION_INSTALLED: log.debug(`Executing upgrade of '${appPath}'`); break; default: log.debug(`The current install state of '${appPath}' is unknown. Installing anyway`); break; } try { await this.install(appPath, {...options, replace: true}); } catch (err) { const error = err as Error; log.warn( `Cannot install/upgrade '${pkg}' because of '${error.message}'. Trying full reinstall`, ); await uninstallPackage(); await this.install(appPath, {...options, replace: false}); } return { appState, wasUninstalled, }; } /** * Extract string resources from the given package on local file system. * * @param appPath - The full path to the .apk(s) package. * @param language - The name of the language to extract the resources for. * The default language is used if this equals to `null` * @param outRoot - The name of the destination folder on the local file system to * store the extracted file to. If not provided then the `localPath` property in the returned object * will be undefined. */ export async function extractStringsFromApk( this: ADB, appPath: string, language: string | null = null, outRoot: string | null = null, ): Promise<ApkStrings> { log.debug(`Extracting strings from for language: ${language || 'default'}`); const originalAppPath = appPath; if (appPath.endsWith(APKS_EXTENSION)) { appPath = await this.extractLanguageApk(appPath, language); } let apkStrings: StringRecord = {}; let configMarker: string; try { await this.initAapt(); configMarker = await formatConfigMarker( async () => { const {stdout} = await exec((this.binaries as StringRecord).aapt as string, [ 'd', 'configurations', appPath, ]); return _.uniq(stdout.split(os.EOL)); }, language, '(default)', ); const {stdout} = await exec((this.binaries as StringRecord).aapt as string, [ 'd', '--values', 'resources', appPath, ]); apkStrings = parseAaptStrings(stdout, configMarker); } catch (e) { const err = e as ExecError; log.debug( 'Cannot extract resources using aapt. Trying aapt2. ' + `Original error: ${err.stderr || err.message}`, ); await this.initAapt2(); configMarker = await formatConfigMarker( async () => { const {stdout} = await exec((this.binaries as StringRecord).aapt2 as string, [ 'd', 'configurations', appPath, ]); return _.uniq(stdout.split(os.EOL)); }, language, '', ); try { const {stdout} = await exec((this.binaries as StringRecord).aapt2 as string, [ 'd', 'resources', appPath, ]); apkStrings = parseAapt2Strings(stdout, configMarker); } catch (e) { const error = e as Error; throw new Error( `Cannot extract resources from '${originalAppPath}'. ` + `Original error: ${error.message}`, ); } } if (_.isEmpty(apkStrings)) { log.warn( `No strings have been found in '${originalAppPath}' resources ` + `for '${configMarker || 'default'}' configuration`, ); } else { log.info( `Successfully extracted ${_.keys(apkStrings).length} strings from ` + `'${originalAppPath}' resources for '${configMarker || 'default'}' configuration`, ); } if (!outRoot) { return {apkStrings}; } const localPath = path.resolve(outRoot, 'strings.json'); await mkdirp(outRoot); await fs.writeFile(localPath, JSON.stringify(apkStrings, null, 2), 'utf-8'); return {apkStrings, localPath}; } /** * Get the package info from local apk file. * * @param appPath - The full path to existing .apk(s) package on the local * file system. * @returns The parsed application information. */ export async function getApkInfo(this: ADB, appPath: string): Promise<AppInfo | {}> { if (!(await fs.exists(appPath))) { throw new Error(`The file at path ${appPath} does not exist or is not accessible`); } if (appPath.endsWith(APKS_EXTENSION)) { appPath = await this.extractBaseApk(appPath); } try { const {name, versionCode, versionName} = await readPackageManifest.bind(this)(appPath); return { name, versionCode, versionName, }; } catch (e) { const err = e as Error; log.warn(`Error '${err.message}' while getting badging info`); } return {}; } // #region Private functions /** * Formats the config marker, which is then passed to parse.. methods * to make it compatible with resource formats generated by aapt(2) tool * * @param configsGetter The function whose result is a list * of apk configs * @param desiredMarker The desired config marker value * @param defaultMarker The default config marker value * @returns The formatted config marker */ async function formatConfigMarker( configsGetter: () => Promise<string[]>, desiredMarker: string | null, defaultMarker: string, ): Promise<string> { let configMarker = desiredMarker || defaultMarker; if (configMarker.includes('-') && !configMarker.includes('-r')) { configMarker = configMarker.replace('-', '-r'); } const configs = await configsGetter(); log.debug(`Resource configurations: ${JSON.stringify(configs)}`); // Assume the 'en' configuration is the default one if ( configMarker.toLowerCase().startsWith('en') && !configs.some((x) => x.trim() === configMarker) ) { log.debug( `Resource configuration name '${configMarker}' is unknown. ` + `Replacing it with '${defaultMarker}'`, ); configMarker = defaultMarker; } else { log.debug(`Selected configuration: '${configMarker}'`); } return configMarker; } /** * Parses apk strings from aapt2 tool output * * @param rawOutput The actual tool output * @param configMarker The config marker. Usually * a language abbreviation or an empty string for the default one * @returns Strings ids to values mapping. Plural * values are represented as arrays. If no config found for the * given marker then an empty mapping is returned. */ export function parseAapt2Strings(rawOutput: string, configMarker: string): StringRecord { const allLines = rawOutput.split(os.EOL); function extractContent(startIdx: number): [string | null, number] { let idx = startIdx; const startCharPos = allLines[startIdx].indexOf('"'); if (startCharPos < 0) { return [null, idx]; } let result = ''; while (idx < allLines.length) { const terminationCharMatch = /"$/.exec(allLines[idx]); if (terminationCharMatch) { const terminationCharPos = terminationCharMatch.index; if (startIdx === idx) { return [allLines[idx].substring(startCharPos + 1, terminationCharPos), idx]; } return [`${result}\\n${_.trimStart(allLines[idx].substring(0, terminationCharPos))}`, idx]; } if (idx > startIdx) { result += `\\n${_.trimStart(allLines[idx])}`; } else { result += allLines[idx].substring(startCharPos + 1); } ++idx; } return [result, idx]; } const apkStrings: StringRecord = {}; let currentResourceId: string | null = null; let isInPluralGroup = false; let isInCurrentConfig = false; let lineIndex = 0; while (lineIndex < allLines.length) { const trimmedLine = allLines[lineIndex].trim(); if (_.isEmpty(trimmedLine)) { ++lineIndex; continue; } if (['type', 'Package'].some((x) => trimmedLine.startsWith(x))) { currentResourceId = null; isInPluralGroup = false; isInCurrentConfig = false; ++lineIndex; continue; } if (trimmedLine.startsWith('resource')) { isInPluralGroup = false; currentResourceId = null; isInCurrentConfig = false; if (trimmedLine.includes('string/')) { const match = /string\/(\S+)/.exec(trimmedLine); if (match) { currentResourceId = match[1]; } } else if (trimmedLine.includes('plurals/')) { const match = /plurals\/(\S+)/.exec(trimmedLine); if (match) { currentResourceId = match[1]; isInPluralGroup = true; } } ++lineIndex; continue; } if (currentResourceId) { if (isInPluralGroup) { if (trimmedLine.startsWith('(')) { isInCurrentConfig = trimmedLine.startsWith(`(${configMarker})`); ++lineIndex; continue; } if (isInCurrentConfig) { const [content, idx] = extractContent(lineIndex); lineIndex = idx; if (_.isString(content)) { apkStrings[currentResourceId] = [ ...(Array.isArray(apkStrings[currentResourceId]) ? apkStrings[currentResourceId] : []), content, ]; } } } else if (trimmedLine.startsWith(`(${configMarker})`)) { const [content, idx] = extractContent(lineIndex); lineIndex = idx; if (_.isString(content)) { apkStrings[currentResourceId] = content; } currentResourceId = null; } } ++lineIndex; } return apkStrings; } /** * Parses apk strings from aapt tool output * * @param rawOutput The actual tool output * @param configMarker The config marker. Usually * a language abbreviation or `(default)` * @returns Strings ids to values mapping. Plural * values are represented as arrays. If no config found for the * given marker then an empty mapping is returned. */ export function parseAaptStrings(rawOutput: string, configMarker: string): StringRecord { const normalizeStringMatch = function (s: string) { return s.replace(/"$/, '').replace(/^"/, '').replace(/\\"/g, '"'); }; const apkStrings: StringRecord = {}; let isInConfig = false; let currentResourceId: string | null = null; let isInPluralGroup = false; // The pattern matches any quoted content including escaped quotes const quotedStringPattern = /"[^"\\]*(?:\\.[^"\\]*)*"/; for (const line of rawOutput.split(os.EOL)) { const trimmedLine = line.trim(); if (_.isEmpty(trimmedLine)) { continue; } if (['config', 'type', 'spec', 'Package'].some((x) => trimmedLine.startsWith(x))) { isInConfig = trimmedLine.startsWith(`config ${configMarker}:`); currentResourceId = null; isInPluralGroup = false; continue; } if (!isInConfig) { continue; } if (trimmedLine.startsWith('resource')) { isInPluralGroup = false; currentResourceId = null; if (trimmedLine.includes(':string/')) { const match = /:string\/(\S+):/.exec(trimmedLine); if (match) { currentResourceId = match[1]; } } else if (trimmedLine.includes(':plurals/')) { const match = /:plurals\/(\S+):/.exec(trimmedLine); if (match) { currentResourceId = match[1]; isInPluralGroup = true; } } continue; } if (currentResourceId && trimmedLine.startsWith('(string')) { const match = quotedStringPattern.exec(trimmedLine); if (match) { apkStrings[currentResourceId] = normalizeStringMatch(match[0]); } currentResourceId = null; continue; } if (currentResourceId && isInPluralGroup && trimmedLine.includes(': (string')) { const match = quotedStringPattern.exec(trimmedLine); if (match) { apkStrings[currentResourceId] = [ ...(Array.isArray(apkStrings[currentResourceId]) ? apkStrings[currentResourceId] : []), normalizeStringMatch(match[0]), ]; } continue; } } return apkStrings; } // #endregion