UNPKG

appium-android-driver

Version:

Android UiAutomator and Chrome support for Appium

416 lines (393 loc) 13.3 kB
import {util} from '@appium/support'; import {sleep, waitForCondition} from 'asyncbox'; import type {ADB} from 'appium-adb'; import type {Position, StringRecord} from '@appium/types'; import type {AndroidDriver, AndroidDriverCaps} from '../../driver'; import type {UnlockType, FastUnlockOptions} from '../types'; 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 = [ PIN_UNLOCK, PIN_UNLOCK_KEY_EVENT, PASSWORD_UNLOCK, PATTERN_UNLOCK, FINGERPRINT_UNLOCK, ] as const; 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; /** * Converts an unlock type to a credential type string. * * @param unlockType - The unlock type * @returns The credential type string * @throws {Error} If the unlock type is not known */ export function toCredentialType(unlockType: UnlockType): string { 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`); } /** * Validates unlock capabilities and returns them if valid. * * @param caps - The capabilities to validate * @returns The validated capabilities * @throws {Error} If the capabilities are invalid */ export function validateUnlockCapabilities<T extends AndroidDriverCaps>(caps: T): T { 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(String(unlockKey)?.trim())) { throw new Error(`Unlock key value '${unlockKey}' must only consist of digits`); } } else if (unlockType === PATTERN_UNLOCK) { if (!/^[1-9]{2,9}$/.test(String(unlockKey)?.trim())) { throw new Error( `Unlock key value '${unlockKey}' must only include from two to nine digits in range 1..9`, ); } if (/([1-9]).*?\1/.test(String(unlockKey)?.trim())) { 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; } /** * Performs a fast unlock using ADB commands. * * @param opts - Fast unlock options with credential and credential type */ export async function fastUnlock(this: AndroidDriver, opts: FastUnlockOptions): Promise<void> { 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); } } } /** * Encodes a password by replacing spaces with %s. * * @param key - The password key * @returns The encoded password */ export function encodePassword(key: string): string { return `${key}`.replace(/\s/gi, '%s'); } /** * Converts a string key to an array of characters. * * @param key - The key string * @returns An array of characters */ export function stringKeyToArr(key: string): string[] { return `${key}`.trim().replace(/\s+/g, '').split(/\s*/); } /** * Unlocks the device using fingerprint. * * @param capabilities - Driver capabilities containing unlockKey */ export async function fingerprintUnlock( this: AndroidDriver, capabilities: AndroidDriverCaps, ): Promise<void> { await this.adb.fingerprint(String(capabilities.unlockKey)); await sleep(UNLOCK_WAIT_TIME); } /** * Unlocks the device using PIN by clicking on-screen buttons. * * @param capabilities - Driver capabilities containing unlockKey */ export async function pinUnlock( this: AndroidDriver, capabilities: AndroidDriverCaps, ): Promise<void> { this.log.info(`Trying to unlock device using pin ${capabilities.unlockKey}`); await this.adb.dismissKeyguard(); const keys = stringKeyToArr(String(capabilities.unlockKey)); const els = await this.findElOrEls('id', 'com.android.systemui:id/digit_text', true); if (util.isEmpty(els)) { // fallback to pin with key event return await pinUnlockWithKeyEvent.bind(this)(capabilities); } const pins: Record<string, any> = {}; for (const el of els) { const text = await this.getAttribute('text', util.unwrapElement(el)); if (text) { pins[text] = el; } } for (const pin of keys) { const el = pins[pin]; await this.click(util.unwrapElement(el)); } await waitForUnlock(this.adb); } /** * Unlocks the device using PIN by sending key events. * * @param capabilities - Driver capabilities containing unlockKey */ export async function pinUnlockWithKeyEvent( this: AndroidDriver, capabilities: AndroidDriverCaps, ): Promise<void> { 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); } /** * Unlocks the device using password. * * @param capabilities - Driver capabilities containing unlockKey */ export async function passwordUnlock( this: AndroidDriver, capabilities: AndroidDriverCaps, ): Promise<void> { 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); } /** * Calculates the position of a pattern key based on the initial position and piece size. * * @param key - The pattern key number (1-9) * @param initPos - The initial position of the pattern view * @param piece - The size of each pattern piece * @returns The calculated position for the key */ export function getPatternKeyPosition(key: number, initPos: Position, piece: number): Position { /* 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: number, x: number, piece: number) => Math.round(x + (key % cols || cols) * piece - piece / 2); const yPos = (key: number, y: number, piece: number) => 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), }; } /** * Generates pointer actions for pattern unlock gesture. * * @param keys - Array of pattern keys (string or number) * @param initPos - The initial position of the pattern view * @param piece - The size of each pattern piece * @returns An array of W3C action objects for pattern unlock */ export function getPatternActions( keys: string[] | number[], initPos: Position, piece: number, ): StringRecord[] { // https://www.w3.org/TR/webdriver2/#actions const pointerActions: StringRecord[] = []; const intKeys = keys.map((key) => (typeof key === 'string' ? parseInt(key, 10) : key)); let lastPos: Position | undefined; 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, }, ]; } /** * Verifies that the device has been unlocked. * * @param timeoutMs - Optional timeout in milliseconds (default: 2000) * @throws {Error} If the device fails to unlock within the timeout */ export async function verifyUnlock( this: AndroidDriver, timeoutMs: number | null = null, ): Promise<void> { 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'); } /** * Unlocks the device using pattern gesture. * * @param capabilities - Driver capabilities containing unlockKey */ export async function patternUnlock( this: AndroidDriver, capabilities: AndroidDriverCaps, ): Promise<void> { 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); } // #region Private Functions /** * Type guard to check if a value is a non-empty string. */ function isNonEmptyString(value: any): value is string { return typeof value === 'string' && value !== ''; } /** * Waits 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 adb - The instance of ADB */ async function waitForUnlock(adb: ADB): Promise<void> { await sleep(UNLOCK_WAIT_TIME); if (!(await adb.isScreenLocked())) { return; } await adb.keyevent(KEYCODE_NUMPAD_ENTER); await sleep(UNLOCK_WAIT_TIME); } // #endregion