Files
flipper/desktop/flipper-server-core/src/devices/android/AndroidDevice.tsx
Andrey Goncharov e886427003 Make device debug info fetching fault tolerant
Summary:
Design doc: https://docs.google.com/document/d/1HLCFl46RfqG0o1mSt8SWrwf_HMfOCRg_oENioc1rkvQ/edit#

Some files on the devices could be unavailable due to lack of permissions (hello SELinux and enterprise builds of iOS apps). Instead of failing the export, we should fetch what we can.

Reviewed By: passy

Differential Revision: D40551931

fbshipit-source-id: 698e157b1283b9e959909b6439cd09d2dc8dc8d6
2022-10-25 05:31:48 -07:00

431 lines
13 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 !== '')
);
})
.catch((e) => {
console.debug(
'AndroidDevice.readFlipperFolderForAllApps -> failed to fetch sonar dir',
this.info.serial,
appId,
e,
);
});
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,
).catch((e) => `Couldn't pull the file: ${e}`),
};
},
);
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);
}
}