Summary: Design doc: https://docs.google.com/document/d/1HLCFl46RfqG0o1mSt8SWrwf_HMfOCRg_oENioc1rkvQ/edit# We'll make the fetching more resilient to errors in subsequent diffs Reviewed By: passy Differential Revision: D40512932 fbshipit-source-id: df3d2b14c033ec4434b2bf408f05734d71dee092
417 lines
12 KiB
TypeScript
417 lines
12 KiB
TypeScript
/**
|
|
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
*
|
|
* This source code is licensed under the MIT license found in the
|
|
* LICENSE file in the root directory of this source tree.
|
|
*
|
|
* @format
|
|
*/
|
|
|
|
import adb, {util, Client as ADBClient, PullTransfer} from 'adbkit';
|
|
import {Reader} from 'adbkit-logcat';
|
|
import {createWriteStream} from 'fs';
|
|
import type {DeviceDebugData, DeviceType} from 'flipper-common';
|
|
import {spawn} from 'child_process';
|
|
import {dirname, join} from 'path';
|
|
import {DeviceSpec} from 'flipper-common';
|
|
import {ServerDevice} from '../ServerDevice';
|
|
import {FlipperServerImpl} from '../../FlipperServerImpl';
|
|
import {AndroidCrashWatcher} from './AndroidCrashUtils';
|
|
import {AndroidLogListener} from './AndroidLogListener';
|
|
import {DebuggableDevice} from '../DebuggableDevice';
|
|
import {executeCommandAsApp, pull} from './androidContainerUtility';
|
|
|
|
const DEVICE_RECORDING_DIR = '/sdcard/flipper_recorder';
|
|
|
|
export default class AndroidDevice
|
|
extends ServerDevice
|
|
implements DebuggableDevice
|
|
{
|
|
adb: ADBClient;
|
|
pidAppMapping: {[key: number]: string} = {};
|
|
private recordingProcess?: Promise<string>;
|
|
reader?: Reader;
|
|
readonly logListener: AndroidLogListener;
|
|
readonly crashWatcher: AndroidCrashWatcher;
|
|
|
|
constructor(
|
|
flipperServer: FlipperServerImpl,
|
|
serial: string,
|
|
deviceType: DeviceType,
|
|
title: string,
|
|
adb: ADBClient,
|
|
abiList: Array<string>,
|
|
sdkVersion: string,
|
|
specs: DeviceSpec[] = [],
|
|
) {
|
|
super(flipperServer, {
|
|
serial,
|
|
deviceType,
|
|
title,
|
|
os: 'Android',
|
|
icon: 'mobile',
|
|
specs,
|
|
abiList,
|
|
sdkVersion,
|
|
features: {
|
|
screenCaptureAvailable: false,
|
|
screenshotAvailable: false,
|
|
},
|
|
});
|
|
this.adb = adb;
|
|
|
|
this.logListener = new AndroidLogListener(
|
|
() => this.connected,
|
|
(logEntry) => this.addLogEntry(logEntry),
|
|
this.adb,
|
|
this.serial,
|
|
);
|
|
// It is OK not to await the start of the log listener. We just spawn it and handle errors internally.
|
|
this.logListener
|
|
.start()
|
|
.catch((e) =>
|
|
console.error('AndroidDevice.logListener.start -> unexpected error', e),
|
|
);
|
|
this.crashWatcher = new AndroidCrashWatcher(this);
|
|
// It is OK not to await the start of the crash watcher. We just spawn it and handle errors internally.
|
|
// Crash watcher depends on functioning log listener. It waits for its start internally.
|
|
this.crashWatcher
|
|
.start()
|
|
.catch((e) =>
|
|
console.error(
|
|
'AndroidDevice.crashWatcher.start -> unexpected error',
|
|
e,
|
|
),
|
|
);
|
|
}
|
|
|
|
reverse(ports: number[]): Promise<void> {
|
|
return Promise.all(
|
|
ports.map((port) =>
|
|
this.adb.reverse(this.serial, `tcp:${port}`, `tcp:${port}`),
|
|
),
|
|
).then(() => {
|
|
return;
|
|
});
|
|
}
|
|
|
|
clearLogs(): Promise<void> {
|
|
return this.executeShellOrDie(['logcat', '-c']).catch((e) => {
|
|
console.warn('Failed to clear logs:', e);
|
|
});
|
|
}
|
|
|
|
async navigateToLocation(location: string) {
|
|
const shellCommand = `am start ${encodeURI(location)}`;
|
|
this.adb.shell(this.serial, shellCommand);
|
|
}
|
|
|
|
async screenshot(): Promise<Buffer> {
|
|
return new Promise((resolve, reject) => {
|
|
this.adb
|
|
.screencap(this.serial)
|
|
.then((stream) => {
|
|
const chunks: Array<Buffer> = [];
|
|
stream
|
|
.on('data', (chunk: Buffer) => chunks.push(chunk))
|
|
.once('end', () => {
|
|
resolve(Buffer.concat(chunks));
|
|
})
|
|
.once('error', reject);
|
|
})
|
|
.catch((e) => {
|
|
reject(e);
|
|
});
|
|
});
|
|
}
|
|
|
|
async setIntoPermissiveMode(): Promise<void> {
|
|
console.debug('AndroidDevice.setIntoPermissiveMode', this.serial);
|
|
try {
|
|
try {
|
|
await this.adb.root(this.serial);
|
|
} catch (e) {
|
|
if (
|
|
!(e instanceof Error) ||
|
|
e.message !== 'adbd is already running as root'
|
|
) {
|
|
throw e;
|
|
}
|
|
}
|
|
console.debug(
|
|
'AndroidDevice.setIntoPermissiveMode -> enabled root',
|
|
this.serial,
|
|
);
|
|
await this.executeShellOrDie('setenforce 0');
|
|
console.info(
|
|
'AndroidDevice.setIntoPermissiveMode -> success',
|
|
this.serial,
|
|
);
|
|
} catch (e) {
|
|
console.info(
|
|
'AndroidDevice.setIntoPermissiveMode -> failed',
|
|
this.serial,
|
|
e,
|
|
);
|
|
}
|
|
}
|
|
|
|
async screenRecordAvailable(): Promise<boolean> {
|
|
try {
|
|
await this.executeShellOrDie(
|
|
`[ ! -f /system/bin/screenrecord ] && echo "File does not exist"`,
|
|
);
|
|
return true;
|
|
} catch (_e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async executeShell(command: string): Promise<string> {
|
|
return await this.adb
|
|
.shell(this.serial, command)
|
|
.then(adb.util.readAll)
|
|
.then((output: Buffer) => output.toString().trim());
|
|
}
|
|
|
|
private async executeShellOrDie(command: string | string[]): Promise<void> {
|
|
const output = await this.adb
|
|
.shell(this.serial, command)
|
|
.then(adb.util.readAll)
|
|
.then((output: Buffer) => output.toString().trim());
|
|
if (output) {
|
|
throw new Error(output);
|
|
}
|
|
}
|
|
|
|
private async getSdkVersion(): Promise<number> {
|
|
return await this.adb
|
|
.shell(this.serial, 'getprop ro.build.version.sdk')
|
|
.then(adb.util.readAll)
|
|
.then((output) => Number(output.toString().trim()));
|
|
}
|
|
|
|
private async isValidFile(filePath: string): Promise<boolean> {
|
|
const sdkVersion = await this.getSdkVersion();
|
|
const fileSize = await this.adb
|
|
.shell(this.serial, `ls -l "${filePath}"`)
|
|
.then(adb.util.readAll)
|
|
.then((output: Buffer) => output.toString().trim().split(' '))
|
|
.then((x) => x.filter(Boolean))
|
|
.then((x) => (sdkVersion > 23 ? Number(x[4]) : Number(x[3])));
|
|
|
|
return fileSize > 0;
|
|
}
|
|
|
|
async startScreenCapture(destination: string) {
|
|
await this.executeShellOrDie(
|
|
`mkdir -p "${DEVICE_RECORDING_DIR}" && echo -n > "${DEVICE_RECORDING_DIR}/.nomedia"`,
|
|
);
|
|
const recordingLocation = `${DEVICE_RECORDING_DIR}/video.mp4`;
|
|
let newSize: string | undefined;
|
|
try {
|
|
const sizeString = (
|
|
await adb.util.readAll(await this.adb.shell(this.serial, 'wm size'))
|
|
).toString();
|
|
const size = sizeString.split(' ').slice(-1).pop()?.split('x');
|
|
if (size && size.length === 2) {
|
|
const width = parseInt(size[0], 10);
|
|
const height = parseInt(size[1], 10);
|
|
if (width > height) {
|
|
newSize = '1280x720';
|
|
} else {
|
|
newSize = '720x1280';
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('Error while getting device size', err);
|
|
}
|
|
const sizeArg = newSize ? `--size ${newSize}` : '';
|
|
const cmd = `screenrecord ${sizeArg} "${recordingLocation}"`;
|
|
this.recordingProcess = this.adb
|
|
.shell(this.serial, cmd)
|
|
.then(adb.util.readAll)
|
|
.then(async (output) => {
|
|
const isValid = await this.isValidFile(recordingLocation);
|
|
if (!isValid) {
|
|
const outputMessage = output.toString().trim();
|
|
throw new Error(
|
|
'Recording was not properly started: \n' + outputMessage,
|
|
);
|
|
}
|
|
})
|
|
.then(
|
|
(_) =>
|
|
new Promise(async (resolve, reject) => {
|
|
const stream: PullTransfer = await this.adb.pull(
|
|
this.serial,
|
|
recordingLocation,
|
|
);
|
|
stream.on('end', resolve as () => void);
|
|
stream.on('error', reject);
|
|
stream.pipe(createWriteStream(destination, {autoClose: true}), {
|
|
end: true,
|
|
});
|
|
}),
|
|
)
|
|
.then((_) => destination);
|
|
// Intentionally not return a promise, this just kicks off the recording!
|
|
}
|
|
|
|
async stopScreenCapture(): Promise<string> {
|
|
const {recordingProcess} = this;
|
|
if (!recordingProcess) {
|
|
return Promise.reject(new Error('Recording was not properly started'));
|
|
}
|
|
await this.adb.shell(this.serial, `pkill -l2 screenrecord`);
|
|
const destination = await recordingProcess;
|
|
this.recordingProcess = undefined;
|
|
return destination;
|
|
}
|
|
|
|
async forwardPort(local: string, remote: string): Promise<boolean> {
|
|
return this.adb.forward(this.serial, local, remote);
|
|
}
|
|
|
|
disconnect() {
|
|
if (this.recordingProcess) {
|
|
this.stopScreenCapture();
|
|
}
|
|
super.disconnect();
|
|
}
|
|
|
|
async installApp(apkPath: string) {
|
|
console.log(`Installing app with adb ${apkPath}`);
|
|
await this.adb.install(this.serial, apkPath);
|
|
}
|
|
|
|
async readFlipperFolderForAllApps(): Promise<DeviceDebugData[]> {
|
|
console.debug(
|
|
'AndroidDevice.readFlipperFolderForAllApps',
|
|
this.info.serial,
|
|
);
|
|
const output = await this.adb
|
|
.shell(this.info.serial, 'pm list packages -3 -e')
|
|
.then(util.readAll)
|
|
.then((buffer) => buffer.toString());
|
|
|
|
const appIds = output
|
|
.split('\n')
|
|
// Each appId has \n at the end. The last appId also has it.
|
|
// As a result, there is an "" (empty string) item at the end after the split.
|
|
.filter((appId) => appId !== '')
|
|
// Cut off the "package:" prefix
|
|
.map((appIdRaw) => appIdRaw.substring('package:'.length));
|
|
console.debug(
|
|
'AndroidDevice.readFlipperFolderForAllApps -> found apps',
|
|
this.info.serial,
|
|
appIds,
|
|
);
|
|
|
|
const appsCommandsResults = await Promise.all(
|
|
appIds.map(async (appId): Promise<DeviceDebugData | undefined> => {
|
|
const sonarDirFileNames = await executeCommandAsApp(
|
|
this.adb,
|
|
this.info.serial,
|
|
appId,
|
|
`find /data/data/${appId}/files/sonar -type f -printf "%f\n"`,
|
|
).then((output) => {
|
|
if (output.includes('No such file or directory')) {
|
|
console.debug(
|
|
'AndroidDevice.readFlipperFolderForAllApps -> skipping app because sonar dir does not exist',
|
|
this.info.serial,
|
|
appId,
|
|
);
|
|
return;
|
|
}
|
|
|
|
return (
|
|
output
|
|
.split('\n')
|
|
// Each entry has \n at the end. The last one also has it.
|
|
// As a result, there is an "" (empty string) item at the end after the split.
|
|
.filter((appId) => appId !== '')
|
|
);
|
|
});
|
|
|
|
if (!sonarDirFileNames) {
|
|
return;
|
|
}
|
|
|
|
const sonarDirContentPromises = sonarDirFileNames.map(
|
|
async (fileName) => {
|
|
const filePath = `/data/data/${appId}/files/sonar/${fileName}`;
|
|
if (fileName.endsWith('pem')) {
|
|
return {
|
|
path: filePath,
|
|
data: '===SECURE_CONTENT===',
|
|
};
|
|
}
|
|
return {
|
|
path: filePath,
|
|
data: await pull(this.adb, this.info.serial, appId, filePath),
|
|
};
|
|
},
|
|
);
|
|
|
|
const sonarDirContentWithStatsCommandPromise = executeCommandAsApp(
|
|
this.adb,
|
|
this.info.serial,
|
|
appId,
|
|
`ls -al /data/data/${appId}/files/sonar`,
|
|
).then((output): DeviceDebugData['data'][0] => ({
|
|
command: `ls -al /data/data/${appId}/files/sonar`,
|
|
result: output,
|
|
}));
|
|
|
|
const singleAppCommandResults = await Promise.all([
|
|
sonarDirContentWithStatsCommandPromise,
|
|
...sonarDirContentPromises,
|
|
]);
|
|
|
|
return {
|
|
serial: this.info.serial,
|
|
appId,
|
|
data: singleAppCommandResults,
|
|
};
|
|
}),
|
|
);
|
|
|
|
return (
|
|
appsCommandsResults
|
|
// Filter out apps without Flipper integration
|
|
.filter((res): res is DeviceDebugData => !!res)
|
|
);
|
|
}
|
|
}
|
|
|
|
export async function launchEmulator(
|
|
androidHome: string,
|
|
name: string,
|
|
coldBoot: boolean = false,
|
|
) {
|
|
try {
|
|
// On Linux, you must run the emulator from the directory it's in because
|
|
// reasons ...
|
|
const emulatorPath = join(androidHome, 'emulator', 'emulator');
|
|
const child = spawn(
|
|
emulatorPath,
|
|
[`@${name}`, ...(coldBoot ? ['-no-snapshot-load'] : [])],
|
|
{
|
|
detached: true,
|
|
cwd: dirname(emulatorPath),
|
|
env: {
|
|
...process.env,
|
|
ANDROID_SDK_ROOT: androidHome,
|
|
},
|
|
},
|
|
);
|
|
child.stderr.on('data', (data) => {
|
|
console.warn(`Android emulator stderr: ${data}`);
|
|
});
|
|
child.on('error', (e) => console.warn('Android emulator error:', e));
|
|
} catch (e) {
|
|
console.warn('Android emulator startup failed:', e);
|
|
}
|
|
}
|