UNPKG

appium-adb

Version:

Android Debug Bridge interface

729 lines (674 loc) 25.2 kB
import {log} from '../logger'; import _ from 'lodash'; import {retryInterval} from 'asyncbox'; import {util} from '@appium/support'; import B from 'bluebird'; import type {ADB} from '../adb'; import type {SetPropOpts} from './types'; import type {ExecError} from 'teen_process'; const ANIMATION_SCALE_KEYS = [ 'animator_duration_scale', 'transition_animation_scale', 'window_animation_scale', ] as const; const HIDDEN_API_POLICY_KEYS = [ 'hidden_api_policy_pre_p_apps', 'hidden_api_policy_p_apps', 'hidden_api_policy', ] as const; /** * Get the particular property of the device under test. * * @param property - The name of the property. This name should * be known to _adb shell getprop_ tool. * * @returns The value of the given property. */ export async function getDeviceProperty(this: ADB, property: string): Promise<string> { const stdout = await this.shell(['getprop', property]); const val = stdout.trim(); log.debug(`Current device property '${property}': ${val}`); return val; } /** * Set the particular property of the device under test. * * @param prop - The name of the property. This name should * be known to _adb shell setprop_ tool. * @param val - The new property value. * @param opts * * @throws If _setprop_ utility fails to change property value. */ export async function setDeviceProperty( this: ADB, prop: string, val: string, opts: SetPropOpts = {}, ): Promise<void> { const {privileged = true} = opts; log.debug(`Setting device property '${prop}' to '${val}'`); await this.shell(['setprop', prop, val], { privileged, }); } /** * @returns Current system language on the device under test. */ export async function getDeviceSysLanguage(this: ADB): Promise<string> { return await this.getDeviceProperty('persist.sys.language'); } /** * @returns Current country name on the device under test. */ export async function getDeviceSysCountry(this: ADB): Promise<string> { return await this.getDeviceProperty('persist.sys.country'); } /** * @returns Current system locale name on the device under test. */ export async function getDeviceSysLocale(this: ADB): Promise<string> { return await this.getDeviceProperty('persist.sys.locale'); } /** * @returns Current product language name on the device under test. */ export async function getDeviceProductLanguage(this: ADB): Promise<string> { return await this.getDeviceProperty('ro.product.locale.language'); } /** * @returns Current product country name on the device under test. */ export async function getDeviceProductCountry(this: ADB): Promise<string> { return await this.getDeviceProperty('ro.product.locale.region'); } /** * @returns Current product locale name on the device under test. */ export async function getDeviceProductLocale(this: ADB): Promise<string> { return await this.getDeviceProperty('ro.product.locale'); } /** * @returns The model name of the device under test. */ export async function getModel(this: ADB): Promise<string> { return await this.getDeviceProperty('ro.product.model'); } /** * @returns The manufacturer name of the device under test. */ export async function getManufacturer(this: ADB): Promise<string> { return await this.getDeviceProperty('ro.product.manufacturer'); } /** * Get the current screen size. * * @returns Device screen size as string in format 'WxH' or * _null_ if it cannot be determined. */ export async function getScreenSize(this: ADB): Promise<string | null> { const stdout = await this.shell(['wm', 'size']); const size = new RegExp(/Physical size: ([^\r?\n]+)*/g).exec(stdout); if (size && size.length >= 2) { return size[1].trim(); } return null; } /** * Get the current screen density in dpi * * @returns Device screen density as a number or _null_ if it * cannot be determined */ export async function getScreenDensity(this: ADB): Promise<number | null> { const stdout = await this.shell(['wm', 'density']); const density = new RegExp(/Physical density: ([^\r?\n]+)*/g).exec(stdout); if (density && density.length >= 2) { const densityNumber = parseInt(density[1].trim(), 10); return isNaN(densityNumber) ? null : densityNumber; } return null; } /** * Setup HTTP proxy in device global settings. * Read https://android.googlesource.com/platform/frameworks/base/+/android-9.0.0_r21/core/java/android/provider/Settings.java for each property * * @param proxyHost - The host name of the proxy. * @param proxyPort - The port number to be set. */ export async function setHttpProxy( this: ADB, proxyHost: string, proxyPort: string | number, ): Promise<void> { const proxy = `${proxyHost}:${proxyPort}`; if (_.isUndefined(proxyHost)) { throw new Error(`Call to setHttpProxy method with undefined proxy_host: ${proxy}`); } if (_.isUndefined(proxyPort)) { throw new Error(`Call to setHttpProxy method with undefined proxy_port ${proxy}`); } /** @type {[string, string][]} */ const httpProxySettins: [string, string][] = [ ['http_proxy', proxy], ['global_http_proxy_host', proxyHost], ['global_http_proxy_port', `${proxyPort}`], ]; for (const [settingKey, settingValue] of httpProxySettins) { await this.setSetting('global', settingKey, settingValue); } } /** * Delete HTTP proxy in device global settings. * Rebooting the test device is necessary to apply the change. */ export async function deleteHttpProxy(this: ADB): Promise<void> { const httpProxySettins = [ 'http_proxy', 'global_http_proxy_host', 'global_http_proxy_port', 'global_http_proxy_exclusion_list', // `global_http_proxy_exclusion_list=` was generated by `settings global htto_proxy xxxx` ]; for (const setting of httpProxySettins) { await this.shell(['settings', 'delete', 'global', setting]); } } /** * Set device property. * [android.provider.Settings]{@link https://developer.android.com/reference/android/provider/Settings.html} * * @param namespace - one of {system, secure, global}, case-insensitive. * @param setting - property name. * @param value - property value. * @returns command output. */ export async function setSetting( this: ADB, namespace: string, setting: string, value: string | number, ): Promise<string> { return await this.shell(['settings', 'put', namespace, setting, `${value}`]); } /** * Get device property. * [android.provider.Settings]{@link https://developer.android.com/reference/android/provider/Settings.html} * * @param namespace - one of {system, secure, global}, case-insensitive. * @param setting - property name. * @returns property value. */ export async function getSetting(this: ADB, namespace: string, setting: string): Promise<string> { return await this.shell(['settings', 'get', namespace, setting]); } /** * Get tz database time zone formatted timezone * * @returns TZ database Time Zones format * @throws If any exception is reported by adb shell. */ export async function getTimeZone(this: ADB): Promise<string> { log.debug('Getting current timezone'); try { return await this.getDeviceProperty('persist.sys.timezone'); } catch (e) { const err = e as Error; throw new Error(`Error getting timezone. Original error: ${err.message}`); } } /** * Retrieve the platform version of the device under test. * * @returns The platform version as a string, for example '5.0' for * Android Lollipop. */ export async function getPlatformVersion(this: ADB): Promise<string> { log.info('Getting device platform version'); try { return await this.getDeviceProperty('ro.build.version.release'); } catch (e) { const err = e as Error; throw new Error(`Error getting device platform version. ` + `Original error: ${err.message}`); } } /** * Retrieve the list of location providers for the device under test. * * @returns The list of available location providers or an empty list. */ export async function getLocationProviders(this: ADB): Promise<string[]> { if ((await this.getApiLevel()) < 31) { // https://stackoverflow.com/questions/70939503/settings-secure-location-providers-allowed-returns-null-in-android-12 const stdout = await this.getSetting('secure', 'location_providers_allowed'); return stdout .trim() .split(',') .map((p) => p.trim()) .filter(Boolean); } // To emulate the legacy behavior return _.includes(await this.shell(['cmd', 'location', 'is-location-enabled']), 'true') ? ['gps'] : []; } /** * Toggle the state of GPS location provider. * * @param enabled - Whether to enable (true) or disable (false) the GPS provider. */ export async function toggleGPSLocationProvider(this: ADB, enabled: boolean): Promise<void> { if ((await this.getApiLevel()) < 31) { // https://stackoverflow.com/questions/70939503/settings-secure-location-providers-allowed-returns-null-in-android-12 await this.setSetting('secure', 'location_providers_allowed', `${enabled ? '+' : '-'}gps`); return; } await this.shell(['cmd', 'location', 'set-location-enabled', enabled ? 'true' : 'false']); } /** * Decorates an exception message with a solution link * * @param e The error object to be decorated * @returns Either the same error or the decorated one */ function decorateWriteSecureSettingsException(e: Error): Error { if (_.includes(e.message, 'requires:android.permission.WRITE_SECURE_SETTINGS')) { e.message = `Check https://github.com/appium/appium/issues/13802 for throubleshooting. ${e.message}`; } return e; } /** * Set hidden api policy to manage access to non-SDK APIs. * https://developer.android.com/preview/restrictions-non-sdk-interfaces * * @param value - The API enforcement policy. * For Android P * 0: Disable non-SDK API usage detection. This will also disable logging, and also break the strict mode API, * detectNonSdkApiUsage(). Not recommended. * 1: "Just warn" - permit access to all non-SDK APIs, but keep warnings in the log. * The strict mode API will keep working. * 2: Disallow usage of dark grey and black listed APIs. * 3: Disallow usage of blacklisted APIs, but allow usage of dark grey listed APIs. * * For Android Q * https://developer.android.com/preview/non-sdk-q#enable-non-sdk-access * 0: Disable all detection of non-SDK interfaces. Using this setting disables all log messages for non-SDK interface usage * and prevents you from testing your app using the StrictMode API. This setting is not recommended. * 1: Enable access to all non-SDK interfaces, but print log messages with warnings for any non-SDK interface usage. * Using this setting also allows you to test your app using the StrictMode API. * 2: Disallow usage of non-SDK interfaces that belong to either the black list * or to a restricted greylist for your target API level. * * @param ignoreError - Whether to ignore an exception in 'adb shell settings put global' command * @throws If there was an error and ignoreError was true while executing 'adb shell settings put global' * command on the device under test. */ export async function setHiddenApiPolicy( this: ADB, value: number | string, ignoreError = false, ): Promise<void> { try { await this.shell( HIDDEN_API_POLICY_KEYS.map((k) => `settings put global ${k} ${value}`).join(';'), ); } catch (e) { const err = e as Error; if (!ignoreError) { throw decorateWriteSecureSettingsException(err); } log.info( `Failed to set setting keys '${HIDDEN_API_POLICY_KEYS}' to '${value}'. ` + `Original error: ${err.message}`, ); } } /** * Reset access to non-SDK APIs to its default setting. * https://developer.android.com/preview/restrictions-non-sdk-interfaces * * @param ignoreError - Whether to ignore an exception in 'adb shell settings delete global' command * @throws If there was an error and ignoreError was true while executing 'adb shell settings delete global' * command on the device under test. */ export async function setDefaultHiddenApiPolicy(this: ADB, ignoreError = false): Promise<void> { try { await this.shell(HIDDEN_API_POLICY_KEYS.map((k) => `settings delete global ${k}`).join(';')); } catch (e) { const err = e as Error; if (!ignoreError) { throw decorateWriteSecureSettingsException(err); } log.info(`Failed to delete keys '${HIDDEN_API_POLICY_KEYS}'. Original error: ${err.message}`); } } /** * Get the language name of the device under test. * * @returns The name of device language. */ export async function getDeviceLanguage(this: ADB): Promise<string> { return (await this.getApiLevel()) < 23 ? (await this.getDeviceSysLanguage()) || (await this.getDeviceProductLanguage()) : (await this.getDeviceLocale()).split('-')[0]; } /** * Get the country name of the device under test. * * @summary Could only be used for Android API < 23 * @returns The name of device country. */ export async function getDeviceCountry(this: ADB): Promise<string> { return (await this.getDeviceSysCountry()) || (await this.getDeviceProductCountry()); } /** * Get the locale name of the device under test. * * @summary Could only be used for Android API >= 23 * @returns The name of device locale. */ export async function getDeviceLocale(this: ADB): Promise<string> { return (await this.getDeviceSysLocale()) || (await this.getDeviceProductLocale()); } /** * Make sure current device locale is expected or not. * * @privateRemarks FIXME: language or country is required * @param language - Language. The language field is case insensitive, but Locale always canonicalizes to lower case. * @param country - Country. The language field is case insensitive, but Locale always canonicalizes to lower case. * @param script - Script. The script field is case insensitive but Locale always canonicalizes to title case. * * @returns If current locale is language and country as arguments, return true. */ export async function ensureCurrentLocale( this: ADB, language?: string, country?: string, script?: string, ): Promise<boolean> { const hasLanguage = _.isString(language); const hasCountry = _.isString(country); if (!hasLanguage && !hasCountry) { log.warn('ensureCurrentLocale requires language or country'); return false; } const lcLanguage = (language || '').toLowerCase(); const lcCountry = (country || '').toLowerCase(); const apiLevel = await this.getApiLevel(); return ( (await retryInterval(5, 1000, async () => { if (apiLevel < 23) { log.debug(`Requested locale: ${lcLanguage}-${lcCountry}`); let actualLanguage: string | undefined; if (hasLanguage) { actualLanguage = (await this.getDeviceLanguage()).toLowerCase(); log.debug(`Actual language: ${actualLanguage}`); if (!hasCountry && lcLanguage === actualLanguage) { return true; } } let actualCountry: string | undefined; if (hasCountry) { actualCountry = (await this.getDeviceCountry()).toLowerCase(); log.debug(`Actual country: ${actualCountry}`); if (!hasLanguage && lcCountry === actualCountry) { return true; } } return lcLanguage === actualLanguage && lcCountry === actualCountry; } const actualLocale = (await this.getDeviceLocale()).toLowerCase(); // zh-hans-cn : zh-cn const expectedLocale = script ? `${lcLanguage}-${script.toLowerCase()}-${lcCountry}` : `${lcLanguage}-${lcCountry}`; log.debug(`Requested locale: ${expectedLocale}. Actual locale: '${actualLocale}'`); const languagePattern = `^${_.escapeRegExp(lcLanguage)}-${script ? _.escapeRegExp(script) + '-' : ''}`; const checkLocalePattern = (p: string) => new RegExp(p, 'i').test(actualLocale); if (hasLanguage && !hasCountry) { return checkLocalePattern(languagePattern); } const countryPattern = `${script ? '-' + _.escapeRegExp(script) : ''}-${_.escapeRegExp(lcCountry)}$`; if (!hasLanguage && hasCountry) { return checkLocalePattern(countryPattern); } return [languagePattern, countryPattern].every(checkLocalePattern); })) ?? false ); } /** * Change the state of WiFi on the device under test. * Only works for real devices since API 30 * * @param on - True to enable and false to disable it. * @param isEmulator - Set it to true if the device under test * is an emulator rather than a real device. */ export async function setWifiState(this: ADB, on: boolean, isEmulator = false): Promise<void> { if (isEmulator) { // The svc command does not require to be root since API 26 await this.shell(['svc', 'wifi', on ? 'enable' : 'disable'], { privileged: (await this.getApiLevel()) < 26, }); return; } await this.shell(['cmd', '-w', 'wifi', 'set-wifi-enabled', on ? 'enabled' : 'disabled']); } /** * Change the state of Data transfer on the device under test. * Only works for real devices since API 30 * * @param on - True to enable and false to disable it. * @param isEmulator - Set it to true if the device under test * is an emulator rather than a real device. */ export async function setDataState(this: ADB, on: boolean, isEmulator = false): Promise<void> { if (isEmulator) { // The svc command does not require to be root since API 26 await this.shell(['svc', 'data', on ? 'enable' : 'disable'], { privileged: (await this.getApiLevel()) < 26, }); return; } await this.shell(['cmd', 'phone', 'data', on ? 'enable' : 'disable']); } /** * Retrieves the list of packages from Doze whitelist on Android 8+ * * @returns The list of whitelisted packages. An example output: * system,com.android.shell,2000 * system,com.google.android.cellbroadcastreceiver,10143 * user,io.appium.settings,10157 */ export async function getDeviceIdleWhitelist(this: ADB): Promise<string[]> { if ((await this.getApiLevel()) < 23) { // Doze mode has only been added since Android 6 return []; } log.info('Listing packages in Doze whitelist'); const output = await this.shell(['dumpsys', 'deviceidle', 'whitelist']); return _.trim(output) .split(/\n/) .map((line) => _.trim(line)) .filter(Boolean); } /** * Adds an existing package(s) into the Doze whitelist on Android 8+ * * @param packages One or more packages to add. If the package * already exists in the whitelist then it is only going to be added once. * If the package with the given name is not installed/not known then an error * will be thrown. * @returns `true` if the command to add package(s) has been executed */ export async function addToDeviceIdleWhitelist(this: ADB, ...packages: string[]): Promise<boolean> { if (_.isEmpty(packages) || (await this.getApiLevel()) < 23) { // Doze mode has only been added since Android 6 return false; } log.info( `Adding ${util.pluralize('package', packages.length)} ${JSON.stringify(packages)} to Doze whitelist`, ); await this.shellChunks((pkg) => ['dumpsys', 'deviceidle', 'whitelist', `+${pkg}`], packages); return true; } /** * Check the state of Airplane mode on the device under test. * * @returns True if Airplane mode is enabled. */ export async function isAirplaneModeOn(this: ADB): Promise<boolean> { const stdout = await this.getSetting('global', 'airplane_mode_on'); return parseInt(stdout, 10) !== 0; // Alternatively for Android 11+: // return (await this.shell(['cmd', 'connectivity', 'airplane-mode'])).stdout.trim() === 'enabled'; } /** * Change the state of Airplane mode in Settings on the device under test. * * @param on - True to enable the Airplane mode in Settings and false to disable it. */ export async function setAirplaneMode(this: ADB, on: boolean): Promise<void> { if ((await this.getApiLevel()) < 30) { // This requires to call broadcastAirplaneMode afterwards to apply await this.setSetting('global', 'airplane_mode_on', on ? 1 : 0); return; } await this.shell(['cmd', 'connectivity', 'airplane-mode', on ? 'enable' : 'disable']); } /** * Change the state of the bluetooth service on the device under test. * * @param on - True to enable bluetooth service and false to disable it. */ export async function setBluetoothOn(this: ADB, on: boolean): Promise<void> { if ((await this.getApiLevel()) < 30) { throw new Error('Changing of the bluetooth state is not supported on your device'); } await this.shell(['cmd', 'bluetooth_manager', on ? 'enable' : 'disable']); } /** * Change the state of the NFC service on the device under test. * * @param on - True to enable NFC service and false to disable it. * @throws If there was an error while changing the service state */ export async function setNfcOn(this: ADB, on: boolean): Promise<void> { const {stdout, stderr} = (await this.shell(['svc', 'nfc', on ? 'enable' : 'disable'], { outputFormat: 'full', })) as {stdout: string; stderr: string}; const output = stderr || stdout; log.debug(output); if (output.includes('null NfcAdapter')) { throw new Error( `Cannot turn ${on ? 'on' : 'off'} the NFC adapter. Does the device under test have it?`, ); } } /** * Broadcast the state of Airplane mode on the device under test. * This method should be called after {@link #setAirplaneMode}, otherwise * the mode change is not going to be applied for the device. * ! This API requires root since Android API 24. Since API 30 * there is a dedicated adb command to change airplane mode state, which * does not require to call this one afterwards. * * @param on - True to broadcast enable and false to broadcast disable. */ export async function broadcastAirplaneMode(this: ADB, on: boolean): Promise<void> { const args = [ 'am', 'broadcast', '-a', 'android.intent.action.AIRPLANE_MODE', '--ez', 'state', on ? 'true' : 'false', ]; try { await this.shell(args); } catch (e) { const err = e as ExecError; // https://github.com/appium/appium/issues/17422 if (_.includes(err.stderr, 'SecurityException')) { try { await this.shell(args, {privileged: true}); return; } catch {} } throw err; } } /** * Check the state of WiFi on the device under test. * * @returns True if WiFi is enabled. */ export async function isWifiOn(this: ADB): Promise<boolean> { const stdout = await this.getSetting('global', 'wifi_on'); return parseInt(stdout, 10) !== 0; // Alternative for Android 11+: // return (await this.shell(['cmd', 'wifi', 'status']).stdout.includes('Wifi is enabled')); } /** * Check the state of Data transfer on the device under test. * * @returns True if Data transfer is enabled. */ export async function isDataOn(this: ADB): Promise<boolean> { const stdout = await this.getSetting('global', 'mobile_data'); return parseInt(stdout, 10) !== 0; } /** * Check the state of animation on the device under test below: * - animator_duration_scale * - transition_animation_scale * - window_animation_scale * * @returns True if at least one of animation scale settings * is not equal to '0.0'. */ export async function isAnimationOn(this: ADB): Promise<boolean> { return ( await B.all( ANIMATION_SCALE_KEYS.map(async (k) => (await this.getSetting('global', k)) !== '0.0'), ) ).includes(true); } /** * Set animation scale with the given value via adb shell settings command. * - animator_duration_scale * - transition_animation_scale * - window_animation_scale * API level 24 and newer OS versions may change the animation, at least emulators are so. * API level 28+ real devices checked this worked, but we haven't checked older ones * with real devices. * * @param value Animation scale value (int or float) to set. * The minimum value of zero disables animations. * By increasing the value, animations become slower. * '1' is the system default animation scale. * @throws If the adb setting command raises an exception. */ export async function setAnimationScale(this: ADB, value: number): Promise<void> { await B.all(ANIMATION_SCALE_KEYS.map((k) => this.setSetting('global', k, value))); } /** * Retrieve current screen orientation of the device under test. * * @returns The current orientation encoded as an integer number. */ export async function getScreenOrientation(this: ADB): Promise<number | null> { const stdout = await this.shell(['dumpsys', 'input']); return getSurfaceOrientation(stdout); } // #region Private functions /** * Reads SurfaceOrientation in dumpsys output * * @param dumpsys */ function getSurfaceOrientation(dumpsys: string): number | null { const m = /SurfaceOrientation: \d/gi.exec(dumpsys); return m ? parseInt(m[0].split(':')[1], 10) : null; } // #endregion