diff --git a/lib/helpers.js b/lib/helpers.js index 2159c80b..5c7e6430 100644 --- a/lib/helpers.js +++ b/lib/helpers.js @@ -1,19 +1,13 @@ import path from 'path'; -import { system, fs, zip, util, tempDir } from '@appium/support'; +import { system, fs, zip, util } from '@appium/support'; import { log } from './logger.js'; import _ from 'lodash'; -import B from 'bluebird'; -import * as semver from 'semver'; -import os from 'os'; import { exec } from 'teen_process'; export const APKS_EXTENSION = '.apks'; export const APK_EXTENSION = '.apk'; export const APK_INSTALL_TIMEOUT = 60000; -export const APKS_INSTALL_TIMEOUT = APK_INSTALL_TIMEOUT * 2; export const DEFAULT_ADB_EXEC_TIMEOUT = 20000; // in milliseconds -const MAIN_ACTION = 'android.intent.action.MAIN'; -const LAUNCHER_CATEGORY = 'android.intent.category.LAUNCHER'; const MODULE_NAME = 'appium-adb'; /** @@ -93,48 +87,6 @@ export async function requireSdkRoot (customRoot = null) { return /** @type {string} */ (sdkRoot); } -/** - * Retrieve the path to the recent installed Android platform. - * - * @param {string} sdkRoot - * @return {Promise} The resulting path to the newest installed platform. - */ -export async function getAndroidPlatformAndPath (sdkRoot) { - const propsPaths = await fs.glob('*/build.prop', { - cwd: path.resolve(sdkRoot, 'platforms'), - absolute: true, - }); - /** @type {Record} */ - const platformsMapping = {}; - for (const propsPath of propsPaths) { - const propsContent = await fs.readFile(propsPath, 'utf-8'); - const platformPath = path.dirname(propsPath); - const platform = path.basename(platformPath); - const match = /ro\.build\.version\.sdk=(\d+)/.exec(propsContent); - if (!match) { - log.warn(`Cannot read the SDK version from '${propsPath}'. Skipping '${platform}'`); - continue; - } - platformsMapping[parseInt(match[1], 10)] = { - platform, - platformPath, - }; - } - if (_.isEmpty(platformsMapping)) { - log.warn(`Found zero platform folders at '${path.resolve(sdkRoot, 'platforms')}'. ` + - `Do you have any Android SDKs installed?`); - return { - platform: null, - platformPath: null, - }; - } - - const recentSdkVersion = _.keys(platformsMapping).sort().reverse()[0]; - const result = platformsMapping[recentSdkVersion]; - log.debug(`Found the most recent Android platform: ${JSON.stringify(result)}`); - return result; -} - /** * @param {string} zipPath * @param {string} dstRoot @@ -146,59 +98,6 @@ export async function unzipFile (zipPath, dstRoot = path.dirname(zipPath)) { log.debug('Unzip successful'); } -/** - * Unsigns the given apk by removing the - * META-INF folder recursively from the archive. - * !!! The function overwrites the given apk after successful unsigning !!! - * - * @param {string} apkPath The path to the apk - * @returns {Promise} `true` if the apk has been successfully - * unsigned and overwritten - * @throws {Error} if there was an error during the unsign operation - */ -export async function unsignApk (apkPath) { - const tmpRoot = await tempDir.openDir(); - const metaInfFolderName = 'META-INF'; - try { - let hasMetaInf = false; - await zip.readEntries(apkPath, ({entry}) => { - hasMetaInf = entry.fileName.startsWith(`${metaInfFolderName}/`); - // entries iteration stops after `false` is returned - return !hasMetaInf; - }); - if (!hasMetaInf) { - return false; - } - const tmpZipRoot = path.resolve(tmpRoot, 'apk'); - await zip.extractAllTo(apkPath, tmpZipRoot); - await fs.rimraf(path.resolve(tmpZipRoot, metaInfFolderName)); - const tmpResultPath = path.resolve(tmpRoot, path.basename(apkPath)); - await zip.toArchive(tmpResultPath, { - cwd: tmpZipRoot, - }); - await fs.unlink(apkPath); - await fs.mv(tmpResultPath, apkPath); - return true; - } finally { - await fs.rimraf(tmpRoot); - } -} - -/** - * @param {string} stdout - * @returns {string[]} - */ -export function getIMEListFromOutput (stdout) { - let engines = []; - for (let line of stdout.split('\n')) { - if (line.length > 0 && line[0] !== ' ') { - // remove newline and trailing colon, and add to the list - engines.push(line.trim().replace(/:$/, '')); - } - } - return engines; -} - /** @type {() => Promise} */ export const getJavaHome = _.memoize(async function getJavaHome () { const result = process.env.JAVA_HOME; @@ -238,274 +137,6 @@ export const getJavaForOs = _.memoize(async function getJavaForOs () { `neither in PATH nor under JAVA_HOME (${javaHome ? path.resolve(javaHome, 'bin') : errMsg})`); }); -/** @type {() => Promise} */ -export const getOpenSslForOs = async function () { - const binaryName = `openssl${system.isWindows() ? '.exe' : ''}`; - try { - return await fs.which(binaryName); - } catch { - throw new Error('The openssl tool must be installed on the system and available on the path'); - } -}; - -/** - * Get the absolute path to apksigner tool - * - * @param {Object} sysHelpers - An instance containing systemCallMethods helper methods - * @returns {Promise} An absolute path to apksigner tool. - * @throws {Error} If the tool is not present on the local file system. - */ -export async function getApksignerForOs (sysHelpers) { - return await sysHelpers.getBinaryFromSdkRoot('apksigner.jar'); -} - -/** - * Get the absolute path to apkanalyzer tool. - * https://developer.android.com/studio/command-line/apkanalyzer.html - * - * @param {Object} sysHelpers - An instance containing systemCallMethods helper methods - * @returns {Promise} An absolute path to apkanalyzer tool. - * @throws {Error} If the tool is not present on the local file system. - */ -export async function getApkanalyzerForOs (sysHelpers) { - return await sysHelpers.getBinaryFromSdkRoot('apkanalyzer'); -} - -/** - * Checks screenState has SCREEN_STATE_OFF in dumpsys output to determine - * possible lock screen. - * - * @param {string} dumpsys - The output of dumpsys window command. - * @return {boolean} True if lock screen is showing. - */ -export function isScreenStateOff(dumpsys) { - return /\s+screenState=SCREEN_STATE_OFF/i.test(dumpsys); -} - -/** - * Checks mShowingLockscreen or mDreamingLockscreen in dumpsys output to determine - * if lock screen is showing - * - * A note: `adb shell dumpsys trust` performs better while detecting the locked screen state - * in comparison to `adb dumpsys window` output parsing. - * But the trust command does not work for `Swipe` unlock pattern. - * - * In some Android devices (Probably around Android 10+), `mShowingLockscreen` and `mDreamingLockscreen` - * do not work to detect lock status. Instead, keyguard preferences helps to detect the lock condition. - * Some devices such as Android TV do not have keyguard, so we should keep - * screen condition as this primary method. - * - * @param {string} dumpsys - The output of dumpsys window command. - * @return {boolean} True if lock screen is showing. - */ -export function isShowingLockscreen (dumpsys) { - return _.some(['mShowingLockscreen=true', 'mDreamingLockscreen=true'], (x) => dumpsys.includes(x)) - // `mIsShowing` and `mInputRestricted` are `true` in lock condition. `false` is unlock condition. - || _.every([/KeyguardStateMonitor[\n\s]+mIsShowing=true/, /\s+mInputRestricted=true/], (x) => x.test(dumpsys)); -} - -/** - * Check the current device power state to determine if it is locked - * - * @param {string} dumpsys The `adb shell dumpsys power` output - * @returns {boolean} True if lock screen is shown - */ -export function isInDozingMode(dumpsys) { - // On some phones/tablets we were observing mWakefulness=Dozing - // while on others it was getWakefulnessLocked()=Dozing - return /^[\s\w]+wakefulness[^=]*=Dozing$/im.test(dumpsys); -} - -/* - * Checks mCurrentFocus in dumpsys output to determine if Keyguard is activated - */ -export function isCurrentFocusOnKeyguard (dumpsys) { - let m = /mCurrentFocus.+Keyguard/gi.exec(dumpsys); - return (m && m.length && m[0]) ? true : false; -} - -/* - * Reads SurfaceOrientation in dumpsys output - */ -export function getSurfaceOrientation (dumpsys) { - let m = /SurfaceOrientation: \d/gi.exec(dumpsys); - return m && parseInt(m[0].split(':')[1], 10); -} - -/* - * Checks mScreenOnFully in dumpsys output to determine if screen is showing - * Default is true. - * Note: this key - */ -export function isScreenOnFully (dumpsys) { - let m = /mScreenOnFully=\w+/gi.exec(dumpsys); - return !m || // if information is missing we assume screen is fully on - (m && m.length > 0 && m[0].split('=')[1] === 'true') || false; -} - -/** - * Builds command line representation for the given - * application startup options - * - * @param {StartCmdOptions} startAppOptions - Application options mapping - * @param {number} apiLevel - The actual OS API level - * @returns {string[]} The actual command line array - */ -export function buildStartCmd (startAppOptions, apiLevel) { - 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; -} - -/** @type {() => Promise<{major: number, minor: number, build: number}?>} */ -export const getSdkToolsVersion = _.memoize(async function getSdkToolsVersion () { - const androidHome = process.env.ANDROID_HOME; - if (!androidHome) { - throw new Error('ANDROID_HOME environment variable is expected to be set'); - } - const propertiesPath = path.resolve(androidHome, 'tools', 'source.properties'); - if (!await fs.exists(propertiesPath)) { - log.warn(`Cannot find ${propertiesPath} file to read SDK version from`); - return null; - } - const propertiesContent = await fs.readFile(propertiesPath, 'utf8'); - const versionMatcher = new RegExp(/Pkg\.Revision=(\d+)\.?(\d+)?\.?(\d+)?/); - const match = versionMatcher.exec(propertiesContent); - if (match) { - return { - major: parseInt(match[1], 10), - minor: match[2] ? parseInt(match[2], 10) : 0, - build: match[3] ? parseInt(match[3], 10) : 0 - }; - } - log.warn(`Cannot parse "Pkg.Revision" value from ${propertiesPath}`); - return null; -}); - -/** - * Retrieves full paths to all 'build-tools' subfolders under the particular - * SDK root folder - * - * @type {(sdkRoot: string) => Promise} - */ -export const getBuildToolsDirs = _.memoize(async function getBuildToolsDirs (sdkRoot) { - let buildToolsDirs = await fs.glob('*/', { - cwd: path.resolve(sdkRoot, 'build-tools'), - absolute: true, - }); - try { - buildToolsDirs = buildToolsDirs - .map((dir) => [path.basename(dir), dir]) - .sort((a, b) => semver.rcompare(a[0], b[0])) - .map((pair) => pair[1]); - } catch (err) { - log.warn(`Cannot sort build-tools folders ${JSON.stringify(buildToolsDirs.map((dir) => path.basename(dir)))} ` + - `by semantic version names.`); - log.warn(`Falling back to sorting by modification date. Original error: ${err.message}`); - /** @type {[number, string][]} */ - const pairs = await B.map(buildToolsDirs, async (dir) => [(await fs.stat(dir)).mtime.valueOf(), dir]); - buildToolsDirs = pairs - // @ts-ignore This sorting works - .sort((a, b) => a[0] < b[0]) - .map((pair) => pair[1]); - } - log.info(`Found ${buildToolsDirs.length} 'build-tools' folders under '${sdkRoot}' (newest first):`); - for (let dir of buildToolsDirs) { - log.info(` ${dir}`); - } - return buildToolsDirs; -}); - -/** - * Retrieves the list of permission names encoded in `dumpsys package` command output. - * - * @param {string} dumpsysOutput - The actual command output. - * @param {string[]} groupNames - The list of group names to list permissions for. - * @param {boolean?} [grantedState=null] - The expected state of `granted` attribute to filter with. - * No filtering is done if the parameter is not set. - * @returns {string[]} The list of matched permission names or an empty list if no matches were found. - */ -export const extractMatchingPermissions = function (dumpsysOutput, groupNames, grantedState = null) { - const groupPatternByName = (groupName) => new RegExp(`^(\\s*${_.escapeRegExp(groupName)} permissions:[\\s\\S]+)`, 'm'); - const indentPattern = /\S|$/; - const permissionNamePattern = /android\.\w*\.?permission\.\w+/; - const grantedStatePattern = /\bgranted=(\w+)/; - const result = []; - 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: 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; -}; - /** * Transforms given options into the list of `adb install.install-multiple` command arguments * @@ -541,412 +172,6 @@ export function buildInstallArgs (apiLevel, options = {}) { return result; } -/** - * Parses apk strings from aapt tool output - * - * @param {string} rawOutput The actual tool output - * @param {string} configMarker The config marker. Usually - * a language abbreviation or `(default)` - * @returns {Object} 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, configMarker) { - const normalizeStringMatch = function (s) { - return s.replace(/"$/, '').replace(/^"/, '').replace(/\\"/g, '"'); - }; - - const apkStrings = {}; - let isInConfig = false; - let currentResourceId = 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] = [ - ...(apkStrings[currentResourceId] || []), - normalizeStringMatch(match[0]), - ]; - } - continue; - } - } - return apkStrings; -} - -/** - * Parses apk strings from aapt2 tool output - * - * @param {string} rawOutput The actual tool output - * @param {string} configMarker The config marker. Usually - * a language abbreviation or an empty string for the default one - * @returns {Object} 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, configMarker) { - const allLines = rawOutput.split(os.EOL); - function extractContent (startIdx) { - 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 = {}; - let currentResourceId = 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] = [ - ...(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; -} - -/** - * Formats the config marker, which is then passed to parse.. methods - * to make it compatible with resource formats generated by aapt(2) tool - * - * @param {Function} configsGetter The function whose result is a list - * of apk configs - * @param {string?} desiredMarker The desired config marker value - * @param {string} defaultMarker The default config marker value - * @return {Promise} The formatted config marker - */ -export async function formatConfigMarker (configsGetter, desiredMarker, defaultMarker) { - 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; -} - -/** - * Transforms the given language and country abbreviations - * to AVD arguments array - * - * @param {?string} language Language name, for example 'fr' - * @param {?string} country Country name, for example 'CA' - * @returns {Array} The generated arguments. The - * resulting array might be empty if both arguments are empty - */ -export function toAvdLocaleArgs (language, country) { - const result = []; - if (language && _.isString(language)) { - result.push('-prop', `persist.sys.language=${language.toLowerCase()}`); - } - if (country && _.isString(country)) { - result.push('-prop', `persist.sys.country=${country.toUpperCase()}`); - } - let locale; - if (_.isString(language) && _.isString(country) && language && country) { - locale = language.toLowerCase() + '-' + country.toUpperCase(); - } else if (language && _.isString(language)) { - locale = language.toLowerCase(); - } else if (country && _.isString(country)) { - locale = country; - } - if (locale) { - result.push('-prop', `persist.sys.locale=${locale}`); - } - return result; -} - -/** - * Retrieves the full path to the Android preferences root - * - * @returns {Promise} The full path to the folder or `null` if the folder cannot be found - */ -export async function getAndroidPrefsRoot () { - let location = process.env.ANDROID_EMULATOR_HOME; - if (await dirExists(location ?? '')) { - return location ?? null; - } - - if (location) { - log.warn(`The value of the ANDROID_EMULATOR_HOME environment variable '${location}' is not an existing directory`); - } - - const home = process.env.HOME || process.env.USERPROFILE; - if (home) { - location = path.resolve(home, '.android'); - } - - if (!await dirExists(location ?? '')) { - log.debug(`Android config root '${location}' is not an existing directory`); - return null; - } - - return location ?? null; -} - -/** - * Check if a path exists on the filesystem and is a directory - * - * @param {string} location The full path to the directory - * @returns {Promise} - */ -export async function dirExists (location) { - return await fs.exists(location) && (await fs.stat(location)).isDirectory(); -} - -/** - * 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 {string} arg Non-escaped argument string - * @returns The escaped argument - */ -export function escapeShellArg (arg) { - arg = `${arg}`; - if (system.isWindows()) { - return /[&|^\s]/.test(arg) ? `"${arg.replace(/"/g, '""')}"` : arg; - } - return arg.replace(/&/g, '\\&'); -} - -/** - * Parses the name of launchable package activity - * from dumpsys output. - * - * @param {string} dumpsys the actual dumpsys output - * @returns {string[]} Either the fully qualified - * activity name as a single list item or an empty list if nothing could be parsed. - * In Android 6 and older there is no reliable way to determine - * the category name for the given activity, so this API just - * returns all activity names belonging to 'android.intent.action.MAIN' - * with the expectation that the app manifest could be parsed next - * in order to determine category names for these. - */ -export function parseLaunchableActivityNames (dumpsys) { - const mainActivityNameRe = new RegExp(`^\\s*${_.escapeRegExp(MAIN_ACTION)}:$`); - const categoryNameRe = /^\s*Category:\s+"([a-zA-Z0-9._/-]+)"$/; - const blocks = []; - let blockStartIndent; - let block = []; - for (const line of dumpsys.split('\n').map(_.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 = []; - 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 {string} classString The string to verify - * @return {RegExpExecArray?} The result of Regexp.exec operation - * or _null_ if no matches are found - */ -export function matchComponentName (classString) { - // some.package/some.package.Activity - return /^[\p{L}0-9./_]+$/u.exec(classString); -} /** * Extracts various package manifest details @@ -1071,64 +296,6 @@ export async function readPackageManifest(apkPath) { return result; } -/** - * - * @param {string} value expect optionalIntentArguments to be a single string of the form: - * "-flag key" - * "-flag key value" - * or a combination of these (e.g., "-flag1 key1 -flag2 key2 value2") - * @returns {string[]} - */ -function parseOptionalIntentArguments(value) { - // take a string and parse out the part before any spaces, and anything after - // the first space - /** @type {(str: string) => string[]} */ - const parseKeyValue = (str) => { - str = str.trim(); - const spacePos = str.indexOf(' '); - if (spacePos < 0) { - return str.length ? [str] : []; - } else { - return [str.substring(0, spacePos).trim(), str.substring(spacePos + 1).trim()]; - } - }; - - // cycle through the optionalIntentArguments and pull out the arguments - // add a space initially so flags can be distinguished from arguments that - // have internal hyphens - let optionalIntentArguments = ` ${value}`; - const re = / (-[^\s]+) (.+)/; - /** @type {string[]} */ - const result = []; - while (true) { - const args = re.exec(optionalIntentArguments); - if (!args) { - if (optionalIntentArguments.length) { - // no more flags, so the remainder can be treated as 'key' or 'key value' - result.push(...parseKeyValue(optionalIntentArguments)); - } - // we are done - return result; - } - - // take the flag and see if it is at the beginning of the string - // if it is not, then it means we have been through already, and - // what is before the flag is the argument for the previous flag - const flag = args[1]; - const flagPos = optionalIntentArguments.indexOf(flag); - if (flagPos !== 0) { - const prevArgs = optionalIntentArguments.substring(0, flagPos); - result.push(...parseKeyValue(prevArgs)); - } - - // add the flag, as there are no more earlier arguments - result.push(flag); - - // make optionalIntentArguments hold the remainder - optionalIntentArguments = args[2]; - } -} - /** * @typedef {Object} InstallOptions * @property {boolean} [allowTestPackages=false] - Set to true in order to allow test @@ -1146,15 +313,3 @@ function parseOptionalIntentArguments(value) { * https://android.stackexchange.com/questions/111064/what-is-a-partial-application-install-via-adb */ -/** - * @typedef {Object} StartCmdOptions - * @property {number|string} [user] - * @property {boolean} [waitForLaunch] - * @property {string} [pkg] - * @property {string} [activity] - * @property {string} [action] - * @property {string} [category] - * @property {boolean} [stopApp] - * @property {string} [flags] - * @property {string} [optionalIntentArguments] - */ diff --git a/lib/tools/android-manifest.js b/lib/tools/android-manifest.js index 9c52cb6b..6eaee99a 100644 --- a/lib/tools/android-manifest.js +++ b/lib/tools/android-manifest.js @@ -1,8 +1,10 @@ +import _ from 'lodash'; import { exec } from 'teen_process'; import { log } from '../logger.js'; import { - getAndroidPlatformAndPath, unzipFile, - APKS_EXTENSION, readPackageManifest, + unzipFile, + APKS_EXTENSION, + readPackageManifest, } from '../helpers.js'; import { fs, zip, tempDir, util } from '@appium/support'; import path from 'path'; @@ -208,3 +210,49 @@ export async function hasInternetPermissionFromManifest (appPath) { const {usesPermissions} = await readPackageManifest.bind(this)(appPath); return usesPermissions.some((/** @type {string} */ name) => name === 'android.permission.INTERNET'); } + +// #region Private functions + +/** + * Retrieve the path to the recent installed Android platform. + * + * @param {string} sdkRoot + * @return {Promise} The resulting path to the newest installed platform. + */ +export async function getAndroidPlatformAndPath (sdkRoot) { + const propsPaths = await fs.glob('*/build.prop', { + cwd: path.resolve(sdkRoot, 'platforms'), + absolute: true, + }); + /** @type {Record} */ + const platformsMapping = {}; + for (const propsPath of propsPaths) { + const propsContent = await fs.readFile(propsPath, 'utf-8'); + const platformPath = path.dirname(propsPath); + const platform = path.basename(platformPath); + const match = /ro\.build\.version\.sdk=(\d+)/.exec(propsContent); + if (!match) { + log.warn(`Cannot read the SDK version from '${propsPath}'. Skipping '${platform}'`); + continue; + } + platformsMapping[parseInt(match[1], 10)] = { + platform, + platformPath, + }; + } + if (_.isEmpty(platformsMapping)) { + log.warn(`Found zero platform folders at '${path.resolve(sdkRoot, 'platforms')}'. ` + + `Do you have any Android SDKs installed?`); + return { + platform: null, + platformPath: null, + }; + } + + const recentSdkVersion = _.keys(platformsMapping).sort().reverse()[0]; + const result = platformsMapping[recentSdkVersion]; + log.debug(`Found the most recent Android platform: ${JSON.stringify(result)}`); + return result; +} + +// #endregion diff --git a/lib/tools/apk-signing.js b/lib/tools/apk-signing.js index 4a78da0e..ac8e2666 100644 --- a/lib/tools/apk-signing.js +++ b/lib/tools/apk-signing.js @@ -3,10 +3,13 @@ import _fs from 'fs'; import { exec } from 'teen_process'; import path from 'path'; import { log } from '../logger.js'; -import { tempDir, system, mkdirp, fs, util } from '@appium/support'; +import { tempDir, system, mkdirp, fs, util, zip } from '@appium/support'; import { LRUCache } from 'lru-cache'; import { - getJavaForOs, getApksignerForOs, getJavaHome, getResourcePath, APKS_EXTENSION, unsignApk, + getJavaForOs, + getJavaHome, + APKS_EXTENSION, + getResourcePath, } from '../helpers.js'; const DEFAULT_PRIVATE_KEY = path.join('keys', 'testkey.pk8'); @@ -36,7 +39,7 @@ const SIGNED_APPS_CACHE = new LRUCache({ * or the return code is not equal to zero. */ export async function executeApksigner (args) { - const apkSignerJar = await getApksignerForOs(this); + const apkSignerJar = await getApksignerForOs.bind(this)(); const fullCmd = [ await getJavaForOs(), '-Xmx1024M', '-Xss1m', '-jar', apkSignerJar, @@ -269,7 +272,7 @@ export async function checkApkCert (appPath, pkg, opts = {}) { const expected = this.useKeystore ? await this.getKeystoreHash() : DEFAULT_CERT_HASH; try { - await getApksignerForOs(this); + await getApksignerForOs.bind(this)(); const output = await this.executeApksigner(['verify', '--print-certs', appPath]); const hasMatch = hashMatches(output, expected); if (hasMatch) { @@ -360,3 +363,56 @@ export async function getKeystoreHash () { `Original error: ${e.stderr || e.message}`); } } + +// #region Private functions + +/** + * Get the absolute path to apksigner tool + * + * @this {import('../adb').ADB} + * @returns {Promise} An absolute path to apksigner tool. + * @throws {Error} If the tool is not present on the local file system. + */ +export async function getApksignerForOs () { + return await this.getBinaryFromSdkRoot('apksigner.jar'); +} + +/** + * Unsigns the given apk by removing the + * META-INF folder recursively from the archive. + * !!! The function overwrites the given apk after successful unsigning !!! + * + * @param {string} apkPath The path to the apk + * @returns {Promise} `true` if the apk has been successfully + * unsigned and overwritten + * @throws {Error} if there was an error during the unsign operation + */ +export async function unsignApk (apkPath) { + const tmpRoot = await tempDir.openDir(); + const metaInfFolderName = 'META-INF'; + try { + let hasMetaInf = false; + await zip.readEntries(apkPath, ({entry}) => { + hasMetaInf = entry.fileName.startsWith(`${metaInfFolderName}/`); + // entries iteration stops after `false` is returned + return !hasMetaInf; + }); + if (!hasMetaInf) { + return false; + } + const tmpZipRoot = path.resolve(tmpRoot, 'apk'); + await zip.extractAllTo(apkPath, tmpZipRoot); + await fs.rimraf(path.resolve(tmpZipRoot, metaInfFolderName)); + const tmpResultPath = path.resolve(tmpRoot, path.basename(apkPath)); + await zip.toArchive(tmpResultPath, { + cwd: tmpZipRoot, + }); + await fs.unlink(apkPath); + await fs.mv(tmpResultPath, apkPath); + return true; + } finally { + await fs.rimraf(tmpRoot); + } +} + +// #endregion diff --git a/lib/tools/apk-utils.js b/lib/tools/apk-utils.js index dbf41483..5adf7067 100644 --- a/lib/tools/apk-utils.js +++ b/lib/tools/apk-utils.js @@ -1,7 +1,6 @@ import { APKS_EXTENSION, buildInstallArgs, APK_INSTALL_TIMEOUT, DEFAULT_ADB_EXEC_TIMEOUT, - parseAaptStrings, parseAapt2Strings, formatConfigMarker, readPackageManifest } from '../helpers.js'; import { exec } from 'teen_process'; @@ -530,3 +529,232 @@ export async function getApkInfo (appPath) { } 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 {Function} configsGetter The function whose result is a list + * of apk configs + * @param {string?} desiredMarker The desired config marker value + * @param {string} defaultMarker The default config marker value + * @return {Promise} The formatted config marker + */ +async function formatConfigMarker (configsGetter, desiredMarker, defaultMarker) { + 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 {string} rawOutput The actual tool output + * @param {string} configMarker The config marker. Usually + * a language abbreviation or an empty string for the default one + * @returns {Object} 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, configMarker) { + const allLines = rawOutput.split(os.EOL); + function extractContent (startIdx) { + 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 = {}; + let currentResourceId = 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] = [ + ...(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 {string} rawOutput The actual tool output + * @param {string} configMarker The config marker. Usually + * a language abbreviation or `(default)` + * @returns {Object} 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, configMarker) { + const normalizeStringMatch = function (s) { + return s.replace(/"$/, '').replace(/^"/, '').replace(/\\"/g, '"'); + }; + + const apkStrings = {}; + let isInConfig = false; + let currentResourceId = 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] = [ + ...(apkStrings[currentResourceId] || []), + normalizeStringMatch(match[0]), + ]; + } + continue; + } + } + return apkStrings; +} + +// #endregion diff --git a/lib/tools/apks-utils.js b/lib/tools/apks-utils.js index 633c3d40..65f18ce3 100644 --- a/lib/tools/apks-utils.js +++ b/lib/tools/apks-utils.js @@ -5,7 +5,7 @@ import _ from 'lodash'; import { fs, tempDir, util } from '@appium/support'; import { LRUCache } from 'lru-cache'; import { - getJavaForOs, unzipFile, buildInstallArgs, APKS_INSTALL_TIMEOUT + getJavaForOs, unzipFile, buildInstallArgs, APK_INSTALL_TIMEOUT } from '../helpers.js'; import AsyncLock from 'async-lock'; import B from 'bluebird'; @@ -19,6 +19,7 @@ const APKS_CACHE = new LRUCache({ }); const APKS_CACHE_GUARD = new AsyncLock(); const BUNDLETOOL_TIMEOUT_MS = 4 * 60 * 1000; +const APKS_INSTALL_TIMEOUT = APK_INSTALL_TIMEOUT * 2; process.on('exit', () => { if (!APKS_CACHE.size) { diff --git a/lib/tools/app-commands.js b/lib/tools/app-commands.js index d16d33dd..8326d1ea 100644 --- a/lib/tools/app-commands.js +++ b/lib/tools/app-commands.js @@ -1,9 +1,5 @@ import _ from 'lodash'; -import { - extractMatchingPermissions, parseLaunchableActivityNames, matchComponentName, - buildStartCmd, escapeShellArg, -} from '../helpers.js'; -import { fs, tempDir, util } from '@appium/support'; +import { fs, tempDir, util, system } from '@appium/support'; import { log } from '../logger.js'; import { sleep, waitForCondition } from 'asyncbox'; import B from 'bluebird'; @@ -28,6 +24,8 @@ const PID_COLUMN_TITLE = 'PID'; const PROCESS_NAME_COLUMN_TITLE = 'NAME'; const PS_TITLE_PATTERN = new RegExp(`^(.*\\b${PID_COLUMN_TITLE}\\b.*\\b${PROCESS_NAME_COLUMN_TITLE}\\b.*)$`, 'm'); const RESOLVER_ACTIVITY_NAME = 'android/com.android.internal.app.ResolverActivity'; +const MAIN_ACTION = 'android.intent.action.MAIN'; +const LAUNCHER_CATEGORY = 'android.intent.category.LAUNCHER'; /** @@ -1055,3 +1053,295 @@ export async function waitForActivity (pkg, act, waitMs = 20000) { export async function waitForNotActivity (pkg, act, waitMs = 20000) { await this.waitForActivityOrNot(pkg, act, true, waitMs); } + +// #region Private functions + +/** + * Builds command line representation for the given + * application startup options + * + * @param {StartCmdOptions} startAppOptions - Application options mapping + * @param {number} apiLevel - The actual OS API level + * @returns {string[]} The actual command line array + */ +export function buildStartCmd (startAppOptions, apiLevel) { + 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; +} + +/** + * + * @param {string} value expect optionalIntentArguments to be a single string of the form: + * "-flag key" + * "-flag key value" + * or a combination of these (e.g., "-flag1 key1 -flag2 key2 value2") + * @returns {string[]} + */ +function parseOptionalIntentArguments(value) { + // take a string and parse out the part before any spaces, and anything after + // the first space + /** @type {(str: string) => string[]} */ + const parseKeyValue = (str) => { + str = str.trim(); + const spacePos = str.indexOf(' '); + if (spacePos < 0) { + return str.length ? [str] : []; + } else { + return [str.substring(0, spacePos).trim(), str.substring(spacePos + 1).trim()]; + } + }; + + // cycle through the optionalIntentArguments and pull out the arguments + // add a space initially so flags can be distinguished from arguments that + // have internal hyphens + let optionalIntentArguments = ` ${value}`; + const re = / (-[^\s]+) (.+)/; + /** @type {string[]} */ + const result = []; + while (true) { + const args = re.exec(optionalIntentArguments); + if (!args) { + if (optionalIntentArguments.length) { + // no more flags, so the remainder can be treated as 'key' or 'key value' + result.push(...parseKeyValue(optionalIntentArguments)); + } + // we are done + return result; + } + + // take the flag and see if it is at the beginning of the string + // if it is not, then it means we have been through already, and + // what is before the flag is the argument for the previous flag + const flag = args[1]; + const flagPos = optionalIntentArguments.indexOf(flag); + if (flagPos !== 0) { + const prevArgs = optionalIntentArguments.substring(0, flagPos); + result.push(...parseKeyValue(prevArgs)); + } + + // add the flag, as there are no more earlier arguments + result.push(flag); + + // make optionalIntentArguments hold the remainder + optionalIntentArguments = args[2]; + } +} + +/** + * Parses the name of launchable package activity + * from dumpsys output. + * + * @param {string} dumpsys the actual dumpsys output + * @returns {string[]} Either the fully qualified + * activity name as a single list item or an empty list if nothing could be parsed. + * In Android 6 and older there is no reliable way to determine + * the category name for the given activity, so this API just + * returns all activity names belonging to 'android.intent.action.MAIN' + * with the expectation that the app manifest could be parsed next + * in order to determine category names for these. + */ +export function parseLaunchableActivityNames (dumpsys) { + const mainActivityNameRe = new RegExp(`^\\s*${_.escapeRegExp(MAIN_ACTION)}:$`); + const categoryNameRe = /^\s*Category:\s+"([a-zA-Z0-9._/-]+)"$/; + const blocks = []; + let blockStartIndent; + let block = []; + for (const line of dumpsys.split('\n').map(_.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 = []; + 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 {string} classString The string to verify + * @return {RegExpExecArray?} The result of Regexp.exec operation + * or _null_ if no matches are found + */ +export function matchComponentName (classString) { + // some.package/some.package.Activity + return /^[\p{L}0-9./_]+$/u.exec(classString); +} + +/** + * 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 {string} arg Non-escaped argument string + * @returns The escaped argument + */ +function escapeShellArg (arg) { + arg = `${arg}`; + if (system.isWindows()) { + return /[&|^\s]/.test(arg) ? `"${arg.replace(/"/g, '""')}"` : arg; + } + return arg.replace(/&/g, '\\&'); +} + +/** + * Retrieves the list of permission names encoded in `dumpsys package` command output. + * + * @param {string} dumpsysOutput - The actual command output. + * @param {string[]} groupNames - The list of group names to list permissions for. + * @param {boolean?} [grantedState=null] - The expected state of `granted` attribute to filter with. + * No filtering is done if the parameter is not set. + * @returns {string[]} The list of matched permission names or an empty list if no matches were found. + */ +export function extractMatchingPermissions (dumpsysOutput, groupNames, grantedState = null) { + const groupPatternByName = (groupName) => new RegExp(`^(\\s*${_.escapeRegExp(groupName)} permissions:[\\s\\S]+)`, 'm'); + const indentPattern = /\S|$/; + const permissionNamePattern = /android\.\w*\.?permission\.\w+/; + const grantedStatePattern = /\bgranted=(\w+)/; + const result = []; + 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: 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; +}; + +/** + * @typedef {Object} StartCmdOptions + * @property {number|string} [user] + * @property {boolean} [waitForLaunch] + * @property {string} [pkg] + * @property {string} [activity] + * @property {string} [action] + * @property {string} [category] + * @property {boolean} [stopApp] + * @property {string} [flags] + * @property {string} [optionalIntentArguments] + */ + +// #endregion diff --git a/lib/tools/device-settings.js b/lib/tools/device-settings.js index ece0a2c0..a6ba4305 100644 --- a/lib/tools/device-settings.js +++ b/lib/tools/device-settings.js @@ -3,7 +3,6 @@ import _ from 'lodash'; import { retryInterval } from 'asyncbox'; import { util } from '@appium/support'; import B from 'bluebird'; -import { getSurfaceOrientation } from '../helpers'; const ANIMATION_SCALE_KEYS = [ 'animator_duration_scale', @@ -718,3 +717,18 @@ export async function getScreenOrientation () { let stdout = await this.shell(['dumpsys', 'input']); return getSurfaceOrientation(stdout); } + +// #region Private functions + +/** + * Reads SurfaceOrientation in dumpsys output + * + * @param {string} dumpsys + * @returns {number | null} + */ +function getSurfaceOrientation (dumpsys) { + const m = /SurfaceOrientation: \d/gi.exec(dumpsys); + return m && parseInt(m[0].split(':')[1], 10); +} + +// #endregion diff --git a/lib/tools/emulator-commands.js b/lib/tools/emulator-commands.js index 553c8460..8673bdb6 100644 --- a/lib/tools/emulator-commands.js +++ b/lib/tools/emulator-commands.js @@ -1,5 +1,4 @@ import { log } from '../logger.js'; -import { getAndroidPrefsRoot, dirExists } from '../helpers'; import _ from 'lodash'; import net from 'net'; import { util, fs } from '@appium/support'; @@ -434,3 +433,46 @@ export async function checkAvdExist (avdName) { export async function sendTelnetCommand (command) { return await this.execEmuConsoleCommand(command, {port: await this.getEmulatorPort()}); } + +// #region Private functions + + +/** + * Retrieves the full path to the Android preferences root + * + * @returns {Promise} The full path to the folder or `null` if the folder cannot be found + */ +async function getAndroidPrefsRoot () { + let location = process.env.ANDROID_EMULATOR_HOME; + if (await dirExists(location ?? '')) { + return location ?? null; + } + + if (location) { + log.warn(`The value of the ANDROID_EMULATOR_HOME environment variable '${location}' is not an existing directory`); + } + + const home = process.env.HOME || process.env.USERPROFILE; + if (home) { + location = path.resolve(home, '.android'); + } + + if (!await dirExists(location ?? '')) { + log.debug(`Android config root '${location}' is not an existing directory`); + return null; + } + + return location ?? null; +} + +/** + * Check if a path exists on the filesystem and is a directory + * + * @param {string} location The full path to the directory + * @returns {Promise} + */ +async function dirExists (location) { + return await fs.exists(location) && (await fs.stat(location)).isDirectory(); +} + +// #endregion diff --git a/lib/tools/keyboard-commands.js b/lib/tools/keyboard-commands.js index 4bce44ed..0642d17f 100644 --- a/lib/tools/keyboard-commands.js +++ b/lib/tools/keyboard-commands.js @@ -1,7 +1,6 @@ import { log } from '../logger.js'; import _ from 'lodash'; import { waitForCondition } from 'asyncbox'; -import { getIMEListFromOutput } from '../helpers.js'; import B from 'bluebird'; const KEYCODE_ESC = 111; @@ -208,3 +207,23 @@ export async function runInImeContext (ime, fn) { } } } + +// #region Private function + +/** + * @param {string} stdout + * @returns {string[]} + */ +function getIMEListFromOutput (stdout) { + /** @type {string[]} */ + const engines = []; + for (const line of stdout.split('\n')) { + if (line.length > 0 && line[0] !== ' ') { + // remove newline and trailing colon, and add to the list + engines.push(line.trim().replace(/:$/, '')); + } + } + return engines; +} + +// #endregion diff --git a/lib/tools/lockmgmt.js b/lib/tools/lockmgmt.js index af944227..18dec746 100644 --- a/lib/tools/lockmgmt.js +++ b/lib/tools/lockmgmt.js @@ -1,9 +1,5 @@ import { log } from '../logger.js'; import _ from 'lodash'; -import { - isShowingLockscreen, isCurrentFocusOnKeyguard, isScreenOnFully, - isInDozingMode, isScreenStateOff, -} from '../helpers.js'; import B from 'bluebird'; import { waitForCondition } from 'asyncbox'; @@ -284,3 +280,77 @@ export async function lock () { throw new Error(`The device screen is still not locked after ${timeoutMs}ms timeout`); } } + +// #region Private functions + +/** + * Checks mScreenOnFully in dumpsys output to determine if screen is showing + * Default is true. + * Note: this key + * + * @param {string} dumpsys + * @returns {boolean} + */ +function isScreenOnFully (dumpsys) { + const m = /mScreenOnFully=\w+/gi.exec(dumpsys); + return !m || // if information is missing we assume screen is fully on + (m && m.length > 0 && m[0].split('=')[1] === 'true') || false; +} + +/** + * Checks mCurrentFocus in dumpsys output to determine if Keyguard is activated + * + * @param {string} dumpsys + * @returns {boolean} + */ +function isCurrentFocusOnKeyguard (dumpsys) { + const m = /mCurrentFocus.+Keyguard/gi.exec(dumpsys); + return Boolean(m && m.length && m[0]); +} + +/** + * Check the current device power state to determine if it is locked + * + * @param {string} dumpsys The `adb shell dumpsys power` output + * @returns {boolean} True if lock screen is shown + */ +function isInDozingMode(dumpsys) { + // On some phones/tablets we were observing mWakefulness=Dozing + // while on others it was getWakefulnessLocked()=Dozing + return /^[\s\w]+wakefulness[^=]*=Dozing$/im.test(dumpsys); +} + +/** + * Checks mShowingLockscreen or mDreamingLockscreen in dumpsys output to determine + * if lock screen is showing + * + * A note: `adb shell dumpsys trust` performs better while detecting the locked screen state + * in comparison to `adb dumpsys window` output parsing. + * But the trust command does not work for `Swipe` unlock pattern. + * + * In some Android devices (Probably around Android 10+), `mShowingLockscreen` and `mDreamingLockscreen` + * do not work to detect lock status. Instead, keyguard preferences helps to detect the lock condition. + * Some devices such as Android TV do not have keyguard, so we should keep + * screen condition as this primary method. + * + * @param {string} dumpsys - The output of dumpsys window command. + * @return {boolean} True if lock screen is showing. + */ +export function isShowingLockscreen (dumpsys) { + return _.some(['mShowingLockscreen=true', 'mDreamingLockscreen=true'], (x) => dumpsys.includes(x)) + // `mIsShowing` and `mInputRestricted` are `true` in lock condition. `false` is unlock condition. + || _.every([/KeyguardStateMonitor[\n\s]+mIsShowing=true/, /\s+mInputRestricted=true/], (x) => x.test(dumpsys)); +} + +/** + * Checks screenState has SCREEN_STATE_OFF in dumpsys output to determine + * possible lock screen. + * + * @param {string} dumpsys - The output of dumpsys window command. + * @return {boolean} True if lock screen is showing. + */ +export function isScreenStateOff(dumpsys) { + return /\s+screenState=SCREEN_STATE_OFF/i.test(dumpsys); +} + +// #endregion diff --git a/lib/tools/system-calls.js b/lib/tools/system-calls.js index ca6d4738..fc046812 100644 --- a/lib/tools/system-calls.js +++ b/lib/tools/system-calls.js @@ -3,8 +3,7 @@ import { log } from '../logger.js'; import B from 'bluebird'; import { system, fs, util, tempDir, timing } from '@appium/support'; import { - getBuildToolsDirs, toAvdLocaleArgs, - getOpenSslForOs, DEFAULT_ADB_EXEC_TIMEOUT, getSdkRootFromEnv + DEFAULT_ADB_EXEC_TIMEOUT, getSdkRootFromEnv } from '../helpers'; import { exec, SubProcess } from 'teen_process'; import { sleep, retry, retryInterval, waitForCondition } from 'asyncbox'; @@ -1284,3 +1283,86 @@ export async function shellChunks (argTransformer, args) { throw lastError; } } + +// #region Private functions + +/** + * Transforms the given language and country abbreviations + * to AVD arguments array + * + * @param {?string} language Language name, for example 'fr' + * @param {?string} country Country name, for example 'CA' + * @returns {Array} The generated arguments. The + * resulting array might be empty if both arguments are empty + */ +export function toAvdLocaleArgs (language, country) { + const result = []; + if (language && _.isString(language)) { + result.push('-prop', `persist.sys.language=${language.toLowerCase()}`); + } + if (country && _.isString(country)) { + result.push('-prop', `persist.sys.country=${country.toUpperCase()}`); + } + let locale; + if (_.isString(language) && _.isString(country) && language && country) { + locale = language.toLowerCase() + '-' + country.toUpperCase(); + } else if (language && _.isString(language)) { + locale = language.toLowerCase(); + } else if (country && _.isString(country)) { + locale = country; + } + if (locale) { + result.push('-prop', `persist.sys.locale=${locale}`); + } + return result; +} + + +/** + * Retrieves full paths to all 'build-tools' subfolders under the particular + * SDK root folder + * + * @type {(sdkRoot: string) => Promise} + */ +export const getBuildToolsDirs = _.memoize(async function getBuildToolsDirs (sdkRoot) { + let buildToolsDirs = await fs.glob('*/', { + cwd: path.resolve(sdkRoot, 'build-tools'), + absolute: true, + }); + try { + buildToolsDirs = buildToolsDirs + .map((dir) => [path.basename(dir), dir]) + .sort((a, b) => semver.rcompare(a[0], b[0])) + .map((pair) => pair[1]); + } catch (err) { + log.warn(`Cannot sort build-tools folders ${JSON.stringify(buildToolsDirs.map((dir) => path.basename(dir)))} ` + + `by semantic version names.`); + log.warn(`Falling back to sorting by modification date. Original error: ${err.message}`); + /** @type {[number, string][]} */ + const pairs = await B.map(buildToolsDirs, async (dir) => [(await fs.stat(dir)).mtime.valueOf(), dir]); + buildToolsDirs = pairs + // @ts-ignore This sorting works + .sort((a, b) => a[0] < b[0]) + .map((pair) => pair[1]); + } + log.info(`Found ${buildToolsDirs.length} 'build-tools' folders under '${sdkRoot}' (newest first):`); + for (let dir of buildToolsDirs) { + log.info(` ${dir}`); + } + return buildToolsDirs; +}); + +/** + * + * @returns {Promise} + */ +async function getOpenSslForOs () { + const binaryName = `openssl${system.isWindows() ? '.exe' : ''}`; + try { + return await fs.which(binaryName); + } catch { + throw new Error('The openssl tool must be installed on the system and available on the path'); + } +} + +// #endregion diff --git a/test/fixtures/selendroid-test-app.apk b/test/fixtures/selendroid-test-app.apk index e5cec779..7e21b886 100644 Binary files a/test/fixtures/selendroid-test-app.apk and b/test/fixtures/selendroid-test-app.apk differ diff --git a/test/functional/apk-signing-e2e-specs.js b/test/functional/apk-signing-e2e-specs.js index 8697bc76..7928c6e2 100644 --- a/test/functional/apk-signing-e2e-specs.js +++ b/test/functional/apk-signing-e2e-specs.js @@ -1,7 +1,7 @@ import {ADB} from '../../lib/adb'; import path from 'path'; import os from 'os'; -import { unsignApk } from '../../lib/helpers.js'; +import { unsignApk } from '../../lib/tools/apk-signing'; const fixturesRoot = path.resolve(__dirname, '..', 'fixtures'); diff --git a/test/functional/helpers-specs-e2e-specs.js b/test/functional/helpers-specs-e2e-specs.js index 489a435d..23e537d3 100644 --- a/test/functional/helpers-specs-e2e-specs.js +++ b/test/functional/helpers-specs-e2e-specs.js @@ -1,10 +1,10 @@ import { - getAndroidPlatformAndPath, requireSdkRoot, readPackageManifest, } from '../../lib/helpers.js'; import {ADB} from '../../lib/adb'; import path from 'node:path'; +import { getAndroidPlatformAndPath } from '../../lib/tools/android-manifest'; describe('Helpers', function () { let chai; diff --git a/test/unit/apk-signing-specs.js b/test/unit/apk-signing-specs.js index d59d86b7..2b3ce397 100644 --- a/test/unit/apk-signing-specs.js +++ b/test/unit/apk-signing-specs.js @@ -4,6 +4,7 @@ import path from 'path'; import * as teen_process from 'teen_process'; import * as appiumSupport from '@appium/support'; import { withMocks } from '@appium/test-support'; +import * as apkSigningHelpers from '../../lib/tools/apk-signing'; const selendroidTestApp = path.resolve(__dirname, '..', 'fixtures', 'selendroid-test-app.apk'); const keystorePath = path.resolve(__dirname, '..', 'fixtures', 'appiumtest.keystore'); @@ -24,7 +25,15 @@ adb.keyAlias = keyAlias; adb.keystorePassword = password; adb.keyPassword = password; -describe('signing', withMocks({teen_process, helpers, adb, appiumSupport, fs, tempDir}, function (mocks) { +describe('signing', withMocks({ + teen_process, + helpers, + adb, + appiumSupport, + fs, + tempDir, + apkSigningHelpers, +}, function (mocks) { let chai; before(async function () { @@ -41,7 +50,7 @@ describe('signing', withMocks({teen_process, helpers, adb, appiumSupport, fs, te describe('signWithDefaultCert', function () { it('should call exec with correct args', async function () { - mocks.helpers.expects('getApksignerForOs') + mocks.apkSigningHelpers.expects('getApksignerForOs') .returns(apksignerDummyPath); mocks.adb.expects('executeApksigner') .once().withExactArgs(['sign', @@ -53,7 +62,7 @@ describe('signing', withMocks({teen_process, helpers, adb, appiumSupport, fs, te }); it('should fail if apksigner fails', async function () { - mocks.helpers.expects('getApksignerForOs') + mocks.apkSigningHelpers.expects('getApksignerForOs') .returns(apksignerDummyPath); mocks.adb.expects('executeApksigner') .once().withExactArgs(['sign', @@ -76,7 +85,7 @@ describe('signing', withMocks({teen_process, helpers, adb, appiumSupport, fs, te it('should call exec with correct args', async function () { adb.useKeystore = true; - mocks.helpers.expects('getApksignerForOs') + mocks.apkSigningHelpers.expects('getApksignerForOs') .returns(apksignerDummyPath); mocks.adb.expects('executeApksigner') .withExactArgs(['sign', @@ -96,7 +105,7 @@ describe('signing', withMocks({teen_process, helpers, adb, appiumSupport, fs, te } adb.useKeystore = true; - mocks.helpers.expects('getApksignerForOs') + mocks.apkSigningHelpers.expects('getApksignerForOs') .returns(apksignerDummyPath); mocks.adb.expects('executeApksigner') .once().withExactArgs(['sign', @@ -118,7 +127,7 @@ describe('signing', withMocks({teen_process, helpers, adb, appiumSupport, fs, te .returns({}); mocks.helpers.expects('getJavaHome') .returns(javaHome); - mocks.helpers.expects('unsignApk') + mocks.apkSigningHelpers.expects('unsignApk') .withExactArgs(selendroidTestApp) .returns(true); await adb.signWithCustomCert(selendroidTestApp); @@ -161,7 +170,7 @@ describe('signing', withMocks({teen_process, helpers, adb, appiumSupport, fs, te it('should check default signature when not using keystore', async function () { adb.useKeystore = false; - mocks.helpers.expects('getApksignerForOs') + mocks.apkSigningHelpers.expects('getApksignerForOs') .once().returns(apksignerDummyPath); mocks.adb.expects('executeApksigner') .once().withExactArgs(['verify', '--print-certs', selendroidTestApp]) @@ -176,7 +185,7 @@ describe('signing', withMocks({teen_process, helpers, adb, appiumSupport, fs, te it('should check non default signature when not using keystore', async function () { adb.useKeystore = false; - mocks.helpers.expects('getApksignerForOs') + mocks.apkSigningHelpers.expects('getApksignerForOs') .once().returns(apksignerDummyPath); mocks.adb.expects('executeApksigner') .once().withExactArgs(['verify', '--print-certs', selendroidTestApp]) @@ -193,7 +202,7 @@ describe('signing', withMocks({teen_process, helpers, adb, appiumSupport, fs, te it('should fail if apksigner is not found', async function () { adb.useKeystore = false; - mocks.helpers.expects('getApksignerForOs') + mocks.apkSigningHelpers.expects('getApksignerForOs') .throws(); mocks.helpers.expects('getJavaForOs') .returns(javaDummyPath); @@ -210,7 +219,7 @@ describe('signing', withMocks({teen_process, helpers, adb, appiumSupport, fs, te 'sha1': '61ed377e85d386a8dfee6b864bdcccccfaa5af81', 'sha256': 'a40da80a59d170caa950cf15cccccc4d47a39b26989d8b640ecd745ba71bf5dc', }); - mocks.helpers.expects('getApksignerForOs') + mocks.apkSigningHelpers.expects('getApksignerForOs') .once().returns(apksignerDummyPath); mocks.adb.expects('executeApksigner') .once().withExactArgs(['verify', '--print-certs', selendroidTestApp]) diff --git a/test/unit/helper-specs.js b/test/unit/helper-specs.js index b8872fea..6dc3d4f5 100644 --- a/test/unit/helper-specs.js +++ b/test/unit/helper-specs.js @@ -1,14 +1,17 @@ import { - getAndroidPlatformAndPath, - buildStartCmd, isShowingLockscreen, getBuildToolsDirs, - parseAaptStrings, parseAapt2Strings, - extractMatchingPermissions, parseLaunchableActivityNames, matchComponentName, - isScreenStateOff, -} from '../../lib/helpers'; + parseLaunchableActivityNames, + matchComponentName, + buildStartCmd, + extractMatchingPermissions, +} from '../../lib/tools/app-commands'; +import { isShowingLockscreen, isScreenStateOff } from '../../lib/tools/lockmgmt'; +import { getBuildToolsDirs } from '../../lib/tools/system-calls'; +import { parseAapt2Strings, parseAaptStrings } from '../../lib/tools/apk-utils'; import { withMocks } from '@appium/test-support'; import { fs } from '@appium/support'; import path from 'path'; import _ from 'lodash'; +import { getAndroidPlatformAndPath } from '../../lib/tools/android-manifest'; describe('helpers', withMocks({fs}, function (mocks) {