UNPKG

appium-android-driver

Version:

Android UiAutomator and Chrome support for Appium

394 lines (372 loc) 12.1 kB
import {util} from '@appium/support'; import {sleep, waitForCondition} from 'asyncbox'; import _ from 'lodash'; export const PIN_UNLOCK = 'pin'; export const PIN_UNLOCK_KEY_EVENT = 'pinWithKeyEvent'; export const PASSWORD_UNLOCK = 'password'; export const PATTERN_UNLOCK = 'pattern'; export const FINGERPRINT_UNLOCK = 'fingerprint'; const UNLOCK_TYPES = /** @type {const} */ ([ PIN_UNLOCK, PIN_UNLOCK_KEY_EVENT, PASSWORD_UNLOCK, PATTERN_UNLOCK, FINGERPRINT_UNLOCK, ]); export const KEYCODE_NUMPAD_ENTER = 66; export const UNLOCK_WAIT_TIME = 100; export const INPUT_KEYS_WAIT_TIME = 100; const NUMBER_ZERO_KEYCODE = 7; const TOUCH_DELAY_MS = 1000; /** * * @param {any} value * @returns {value is string} */ function isNonEmptyString(value) { return typeof value === 'string' && value !== ''; } /** * Wait for the display to be unlocked. * Some devices automatically accept typed 'pin' and 'password' code * without pressing the Enter key. But some devices need it. * This method waits a few seconds first for such automatic acceptance case. * If the device is still locked, then this method will try to send * the enter key code. * * @param {import('appium-adb').ADB} adb The instance of ADB */ async function waitForUnlock(adb) { await sleep(UNLOCK_WAIT_TIME); if (!(await adb.isScreenLocked())) { return; } await adb.keyevent(KEYCODE_NUMPAD_ENTER); await sleep(UNLOCK_WAIT_TIME); } /** * * @param {import('../types').UnlockType} unlockType * @returns {string} */ export function toCredentialType(unlockType) { const result = { [PIN_UNLOCK]: 'pin', [PIN_UNLOCK_KEY_EVENT]: 'pin', [PASSWORD_UNLOCK]: 'password', [PATTERN_UNLOCK]: 'pattern', }[unlockType]; if (result) { return result; } throw new Error(`Unlock type '${unlockType}' is not known`); } /** * @template {AndroidDriverCaps} T * @param {T} caps * @returns {T} */ export function validateUnlockCapabilities(caps) { const {unlockKey, unlockType} = caps ?? {}; if (!isNonEmptyString(unlockType)) { throw new Error('A non-empty unlock key value must be provided'); } if ([PIN_UNLOCK, PIN_UNLOCK_KEY_EVENT, FINGERPRINT_UNLOCK].includes(unlockType)) { if (!/^[0-9]+$/.test(_.trim(unlockKey))) { throw new Error(`Unlock key value '${unlockKey}' must only consist of digits`); } } else if (unlockType === PATTERN_UNLOCK) { if (!/^[1-9]{2,9}$/.test(_.trim(unlockKey))) { throw new Error( `Unlock key value '${unlockKey}' must only include from two to nine digits in range 1..9`, ); } if (/([1-9]).*?\1/.test(_.trim(unlockKey))) { throw new Error( `Unlock key value '${unlockKey}' must define a valid pattern where repeats are not allowed`, ); } } else if (unlockType === PASSWORD_UNLOCK) { // Dont trim password key, you can use blank spaces in your android password // ¯\_(ツ)_/¯ if (!/.{4,}/g.test(String(unlockKey))) { throw new Error( `The minimum allowed length of unlock key value '${unlockKey}' is 4 characters`, ); } } else { throw new Error( `Invalid unlock type '${unlockType}'. ` + `Only the following unlock types are supported: ${UNLOCK_TYPES}`, ); } return caps; } /** * @this {AndroidDriver} * @param {import('../types').FastUnlockOptions} opts */ export async function fastUnlock(opts) { const {credential, credentialType} = opts; this.log.info(`Unlocking the device via ADB using ${credentialType} credential '${credential}'`); const wasLockEnabled = await this.adb.isLockEnabled(); if (wasLockEnabled) { await this.adb.clearLockCredential(credential); // not sure why, but the device's screen still remains locked // if a preliminary wake up cycle has not been performed await this.adb.cycleWakeUp(); } else { this.log.info('No active lock has been detected. Proceeding to the keyguard dismissal'); } try { await this.adb.dismissKeyguard(); } finally { if (wasLockEnabled) { await this.adb.setLockCredential(credentialType, credential); } } } /** * * @param {string} key * @returns {string} */ export function encodePassword(key) { return `${key}`.replace(/\s/gi, '%s'); } /** * * @param {string} key * @returns {string[]} */ export function stringKeyToArr(key) { return `${key}`.trim().replace(/\s+/g, '').split(/\s*/); } /** * @this {AndroidDriver} * @param {AndroidDriverCaps} capabilities * @returns {Promise<void>} */ export async function fingerprintUnlock(capabilities) { if ((await this.adb.getApiLevel()) < 23) { throw new Error('Fingerprint unlock only works for Android 6+ emulators'); } await this.adb.fingerprint(String(capabilities.unlockKey)); await sleep(UNLOCK_WAIT_TIME); } /** * @this {AndroidDriver} * @param {AndroidDriverCaps} capabilities * @returns {Promise<void>} */ export async function pinUnlock(capabilities) { this.log.info(`Trying to unlock device using pin ${capabilities.unlockKey}`); await this.adb.dismissKeyguard(); const keys = stringKeyToArr(String(capabilities.unlockKey)); if ((await this.adb.getApiLevel()) >= 21) { const els = await this.findElOrEls('id', 'com.android.systemui:id/digit_text', true); if (_.isEmpty(els)) { // fallback to pin with key event return await pinUnlockWithKeyEvent.bind(this)(capabilities); } const pins = {}; for (const el of els) { const text = await this.getAttribute('text', util.unwrapElement(el)); pins[text] = el; } for (const pin of keys) { const el = pins[pin]; await this.click(util.unwrapElement(el)); } } else { for (const pin of keys) { let el; try { el = await this.findElOrEls('id', `com.android.keyguard:id/key${pin}`, false); } catch { return await pinUnlockWithKeyEvent.bind(this)(capabilities); } await this.click(util.unwrapElement(el)); } } await waitForUnlock(this.adb); } /** * @this {AndroidDriver} * @param {AndroidDriverCaps} capabilities * @returns {Promise<void>} */ export async function pinUnlockWithKeyEvent(capabilities) { this.log.info(`Trying to unlock device using pin with keycode ${capabilities.unlockKey}`); await this.adb.dismissKeyguard(); const keys = stringKeyToArr(String(capabilities.unlockKey)); // Some device does not have system key ids like 'com.android.keyguard:id/key' // Then, sending keyevents are more reliable to unlock the screen. for (const pin of keys) { // 'pin' is number (0-9) in string. // Number '0' is keycode '7'. number '9' is keycode '16'. await this.adb.shell(['input', 'keyevent', String(parseInt(pin, 10) + NUMBER_ZERO_KEYCODE)]); } await waitForUnlock(this.adb); } /** * @this {AndroidDriver} * @param {AndroidDriverCaps} capabilities * @returns {Promise<void>} */ export async function passwordUnlock(capabilities) { const {unlockKey} = capabilities; this.log.info(`Trying to unlock device using password ${unlockKey}`); await this.adb.dismissKeyguard(); // Replace blank spaces with %s const key = encodePassword(String(unlockKey)); // Why adb ? It was less flaky await this.adb.shell(['input', 'text', key]); // Why sleeps ? Avoid some flakyness waiting for the input to receive the keys await sleep(INPUT_KEYS_WAIT_TIME); await this.adb.shell(['input', 'keyevent', String(KEYCODE_NUMPAD_ENTER)]); // Waits a bit for the device to be unlocked await waitForUnlock(this.adb); } /** * * @param {number} key * @param {import('@appium/types').Position} initPos * @param {number} piece * @returns {import('@appium/types').Position} */ export function getPatternKeyPosition(key, initPos, piece) { /* How the math works: We have 9 buttons divided in 3 columns and 3 rows inside the lockPatternView, every button has a position on the screen corresponding to the lockPatternView since it is the parent view right at the middle of each column or row. */ const cols = 3; const pins = 9; const xPos = (key, x, piece) => Math.round(x + (key % cols || cols) * piece - piece / 2); const yPos = (key, y, piece) => Math.round(y + (Math.ceil((key % pins || pins) / cols) * piece - piece / 2)); return { x: xPos(key, initPos.x, piece), y: yPos(key, initPos.y, piece), }; } /** * @param {string[]|number[]} keys * @param {import('@appium/types').Position} initPos * @param {number} piece * @returns {import('@appium/types').StringRecord[]} */ export function getPatternActions(keys, initPos, piece) { /** @type {import('@appium/types').StringRecord[]} */ // https://www.w3.org/TR/webdriver2/#actions const pointerActions = []; /** @type {number[]} */ const intKeys = keys.map((key) => (_.isString(key) ? _.parseInt(key) : key)); /** @type {import('@appium/types').Position|undefined} */ let lastPos; for (const key of intKeys) { const keyPos = getPatternKeyPosition(key, initPos, piece); if (!lastPos) { pointerActions.push( {type: 'pointerMove', duration: TOUCH_DELAY_MS, x: keyPos.x, y: keyPos.y}, {type: 'pointerDown', button: 0}, ); lastPos = keyPos; continue; } const moveTo = {x: 0, y: 0}; const diffX = keyPos.x - lastPos.x; if (diffX > 0) { moveTo.x = piece; if (Math.abs(diffX) > piece) { moveTo.x += piece; } } else if (diffX < 0) { moveTo.x = -1 * piece; if (Math.abs(diffX) > piece) { moveTo.x -= piece; } } const diffY = keyPos.y - lastPos.y; if (diffY > 0) { moveTo.y = piece; if (Math.abs(diffY) > piece) { moveTo.y += piece; } } else if (diffY < 0) { moveTo.y = -1 * piece; if (Math.abs(diffY) > piece) { moveTo.y -= piece; } } pointerActions.push({ type: 'pointerMove', duration: TOUCH_DELAY_MS, x: moveTo.x + lastPos.x, y: moveTo.y + lastPos.y }); lastPos = keyPos; } pointerActions.push({type: 'pointerUp', button: 0}); return [{ type: 'pointer', id: 'patternUnlock', parameters: { pointerType: 'touch', }, actions: pointerActions, }]; } /** * @this {AndroidDriver} * @param {number?} [timeoutMs=null] */ export async function verifyUnlock(timeoutMs = null) { try { await waitForCondition(async () => !(await this.adb.isScreenLocked()), { waitMs: timeoutMs ?? 2000, intervalMs: 500, }); } catch { throw new Error('The device has failed to be unlocked'); } this.log.info('The device has been successfully unlocked'); } /** * @this {AndroidDriver} * @param {AndroidDriverCaps} capabilities */ export async function patternUnlock(capabilities) { const {unlockKey} = capabilities; this.log.info(`Trying to unlock device using pattern ${unlockKey}`); await this.adb.dismissKeyguard(); const keys = stringKeyToArr(String(unlockKey)); /* We set the device pattern buttons as number of a regular phone * | • • • | | 1 2 3 | * | • • • | --> | 4 5 6 | * | • • • | | 7 8 9 | The pattern view buttons are not seeing by the uiautomator since they are included inside a FrameLayout, so we are going to try clicking on the buttons using the parent view bounds and math. */ const apiLevel = await this.adb.getApiLevel(); const el = await this.findElOrEls( 'id', `com.android.${apiLevel >= 21 ? 'systemui' : 'keyguard'}:id/lockPatternView`, false, ); const initPos = await this.getLocation(util.unwrapElement(el)); const size = await this.getSize(util.unwrapElement(el)); // Get actions to perform const actions = getPatternActions(keys, initPos, size.width / 3); // Perform gesture await this.performActions(actions); // Waits a bit for the device to be unlocked await sleep(UNLOCK_WAIT_TIME); } /** * @typedef {import('@appium/types').Capabilities<import('../../constraints').AndroidDriverConstraints>} AndroidDriverCaps * @typedef {import('../../driver').AndroidDriver} AndroidDriver */