ya-webadb/libraries/android-bin/src/bug-report.ts
2024-06-23 05:31:04 +08:00

348 lines
12 KiB
TypeScript

// cspell: ignore bugreport
// cspell: ignore bugreportz
import type { Adb, AdbSync } from "@yume-chan/adb";
import { AdbCommandBase, AdbSubprocessShellProtocol } from "@yume-chan/adb";
import type { AbortSignal, ReadableStream } from "@yume-chan/stream-extra";
import {
AbortController,
PushReadableStream,
SplitStringStream,
TextDecoderStream,
WrapReadableStream,
WritableStream,
} from "@yume-chan/stream-extra";
export interface BugReportCapabilities {
supportsBugReport: boolean;
bugReportZVersion?: string | undefined;
supportsBugReportZ: boolean;
supportsBugReportZProgress: boolean;
supportsBugReportZStream: boolean;
}
export interface BugReportZOptions {
signal?: AbortSignal;
/**
* A callback that will be called when progress is updated.
*
* Specify `onProgress` when `supportsBugReportZProgress` is `false` will throw an error.
*/
onProgress?: ((completed: string, total: string) => void) | undefined;
}
export class BugReport extends AdbCommandBase {
static VERSION_REGEX: RegExp = /(\d+)\.(\d+)/;
static BEGIN_REGEX: RegExp = /BEGIN:(.*)/;
static PROGRESS_REGEX: RegExp = /PROGRESS:(.*)\/(.*)/;
static OK_REGEX: RegExp = /OK:(.*)/;
static FAIL_REGEX: RegExp = /FAIL:(.*)/;
/**
* Queries the device's bugreport capabilities.
*/
static async queryCapabilities(adb: Adb): Promise<BugReport> {
// bugreportz requires shell protocol
if (!AdbSubprocessShellProtocol.isSupported(adb)) {
return new BugReport(adb, {
supportsBugReport: true,
bugReportZVersion: undefined,
supportsBugReportZ: false,
supportsBugReportZProgress: false,
supportsBugReportZStream: false,
});
}
const { stderr, exitCode } = await adb.subprocess.spawnAndWait([
"bugreportz",
"-v",
]);
if (exitCode !== 0 || stderr === "") {
return new BugReport(adb, {
supportsBugReport: true,
bugReportZVersion: undefined,
supportsBugReportZ: false,
supportsBugReportZProgress: false,
supportsBugReportZStream: false,
});
}
const match = stderr.match(BugReport.VERSION_REGEX);
if (!match) {
return new BugReport(adb, {
supportsBugReport: true,
bugReportZVersion: undefined,
supportsBugReportZ: false,
supportsBugReportZProgress: false,
supportsBugReportZStream: false,
});
}
const [major, minor] = match[0]
.split(".")
.map((x) => parseInt(x, 10)) as [number, number];
return new BugReport(adb, {
// Before BugReportZ version 1.2 (Android 12), BugReport was deprecated but still works.
supportsBugReport: major === 1 && minor <= 1,
bugReportZVersion: match[0],
supportsBugReportZ: true,
supportsBugReportZProgress: major > 1 || minor >= 1,
supportsBugReportZStream: major > 1 || minor >= 2,
});
}
#supportsBugReport: boolean;
/**
* Gets whether the device supports flat (text file, non-zipped) bugreport.
*
* Should be `true` for Android version <= 11.
*/
get supportsBugReport(): boolean {
return this.#supportsBugReport;
}
#bugReportZVersion: string | undefined;
/**
* Gets the version of BugReportZ.
*
* Will be `undefined` if BugReportZ is not supported.
*/
get bugReportZVersion(): string | undefined {
return this.#bugReportZVersion;
}
#supportsBugReportZ: boolean;
/**
* Gets whether the device supports zipped bugreport.
*
* Should be `true` for Android version >= 7.
*/
get supportsBugReportZ(): boolean {
return this.#supportsBugReportZ;
}
#supportsBugReportZProgress: boolean;
/**
* Gets whether the device supports progress report for zipped bugreport.
*
* Should be `true` for Android version >= 8.
*/
get supportsBugReportZProgress(): boolean {
return this.#supportsBugReportZProgress;
}
#supportsBugReportZStream: boolean;
/**
* Gets whether the device supports streaming zipped bugreport.
*
* Should be `true` for Android version >= 12.
*/
get supportsBugReportZStream(): boolean {
return this.#supportsBugReportZStream;
}
constructor(adb: Adb, capabilities: BugReportCapabilities) {
super(adb);
this.#supportsBugReport = capabilities.supportsBugReport;
this.#bugReportZVersion = capabilities.bugReportZVersion;
this.#supportsBugReportZ = capabilities.supportsBugReportZ;
this.#supportsBugReportZProgress =
capabilities.supportsBugReportZProgress;
this.#supportsBugReportZStream = capabilities.supportsBugReportZStream;
}
/**
* Creates a legacy, non-zipped bugreport file, or throws an error if `supportsBugReport` is `false`.
*
* @returns A flat (text file, non-zipped) bugreport.
*/
bugReport(): ReadableStream<Uint8Array> {
if (!this.#supportsBugReport) {
throw new Error(
"Flat (text file, non-zipped) bugreport is not supported.",
);
}
return new WrapReadableStream(async () => {
// https://cs.android.com/android/platform/superproject/+/master:frameworks/native/cmds/bugreport/bugreport.cpp;drc=9b73bf07d73dbab5b792632e1e233edbad77f5fd;bpv=0;bpt=0
const process = await this.adb.subprocess.spawn(["bugreport"]);
return process.stdout;
});
}
/**
* Creates a zipped bugreport file, or throws an error if `supportsBugReportZ` is `false`.
*
* Compare to `bugReportZStream`, this method will write the output to a file on device.
* You can pull it later using sync protocol.
*
* @returns The path to the generated bugreport file on device filesystem.
*/
async bugReportZ(options?: BugReportZOptions): Promise<string> {
if (options?.signal?.aborted) {
throw options?.signal.reason ?? new Error("Aborted");
}
if (!this.#supportsBugReportZ) {
throw new Error("bugreportz is not supported");
}
const args = ["bugreportz"];
if (options?.onProgress) {
if (!this.#supportsBugReportZProgress) {
throw new Error("bugreportz progress is not supported");
}
args.push("-p");
}
const process = await this.adb.subprocess.spawn(args, {
protocols: [AdbSubprocessShellProtocol],
});
options?.signal?.addEventListener("abort", () => {
void process.kill();
});
let filename: string | undefined;
let error: string | undefined;
await process.stdout
.pipeThrough(new TextDecoderStream())
.pipeThrough(new SplitStringStream("\n"))
.pipeTo(
new WritableStream<string>({
write(line) {
// `BEGIN:` and `PROGRESS:` only appear when `-p` is specified.
let match = line.match(BugReport.PROGRESS_REGEX);
if (match) {
options?.onProgress?.(match[1]!, match[2]!);
}
match = line.match(BugReport.BEGIN_REGEX);
if (match) {
filename = match[1]!;
}
match = line.match(BugReport.OK_REGEX);
if (match) {
filename = match[1];
}
match = line.match(BugReport.FAIL_REGEX);
if (match) {
// Don't report error now
// We want to gather all output.
error = match[1];
}
},
}),
);
if (error) {
throw new Error(error);
}
if (!filename) {
throw new Error("bugreportz did not return file name");
}
// Design choice: we don't automatically pull the file to avoid more dependency on `@yume-chan/adb`
return filename;
}
/**
* Creates a zipped bugreport file, or throws an error if `supportsBugReportZStream` is `false`.
*
* @returns The content of the generated bugreport file.
*/
bugReportZStream(): ReadableStream<Uint8Array> {
return new PushReadableStream(async (controller) => {
const process = await this.adb.subprocess.spawn(
["bugreportz", "-s"],
{ protocols: [AdbSubprocessShellProtocol] },
);
process.stdout
.pipeTo(
new WritableStream({
async write(chunk) {
await controller.enqueue(chunk);
},
}),
)
.catch((e) => {
controller.error(e);
});
process.stderr
.pipeThrough(new TextDecoderStream())
.pipeTo(
new WritableStream({
write(chunk) {
controller.error(new Error(chunk));
},
}),
)
.catch((e) => {
controller.error(e);
});
await process.exit;
});
}
/**
* Automatically choose the best bugreport method.
*
* * If `supportsBugReportZStream` is `true`, this method will return a stream of zipped bugreport.
* * If `supportsBugReportZ` is `true`, this method will return a stream of zipped bugreport, and will delete the file on device after the stream is closed.
* * If `supportsBugReport` is `true`, this method will return a stream of flat bugreport.
*
* @param onProgress
* If `supportsBugReportZStream` is `false` and `supportsBugReportZProgress` is `true`,
* this callback will be called when progress is updated.
*/
automatic(onProgress?: (completed: string, total: string) => void): {
type: "bugreport" | "bugreportz";
stream: ReadableStream<Uint8Array>;
} {
if (this.#supportsBugReportZStream) {
return { type: "bugreportz", stream: this.bugReportZStream() };
}
if (this.#supportsBugReportZ) {
let path: string | undefined;
let sync: AdbSync | undefined;
const controller = new AbortController();
const cleanup = async () => {
controller.abort();
await sync?.dispose();
if (path) {
await this.adb.rm(path);
}
};
return {
type: "bugreportz",
stream: new WrapReadableStream({
start: async () => {
path = await this.bugReportZ({
signal: controller.signal,
onProgress: this.#supportsBugReportZProgress
? onProgress
: undefined,
});
sync = await this.adb.sync();
return sync.read(path);
},
cancel: cleanup,
close: cleanup,
}),
};
}
return { type: "bugreport", stream: this.bugReport() };
}
}