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

509 lines
16 KiB
TypeScript

// cspell: ignore logcat
// cspell: ignore usec
import { AdbCommandBase, AdbSubprocessNoneProtocol } from "@yume-chan/adb";
import type { ReadableStream } from "@yume-chan/stream-extra";
import {
BufferedTransformStream,
SplitStringStream,
TextDecoderStream,
WrapReadableStream,
WritableStream,
} from "@yume-chan/stream-extra";
import type { AsyncExactReadable } from "@yume-chan/struct";
import Struct, { decodeUtf8 } from "@yume-chan/struct";
// `adb logcat` is an alias to `adb shell logcat`
// so instead of adding to core library, it's implemented here
// https://cs.android.com/android/platform/superproject/+/master:system/logging/liblog/include/android/log.h;l=141;drc=82b5738732161dbaafb2e2f25cce19cd26b9157d
export enum LogId {
All = -1,
Main,
Radio,
Events,
System,
Crash,
Stats,
Security,
Kernel,
}
// https://cs.android.com/android/platform/superproject/+/master:system/logging/liblog/include/android/log.h;l=73;drc=82b5738732161dbaafb2e2f25cce19cd26b9157d
export enum AndroidLogPriority {
Unknown,
Default,
Verbose,
Debug,
Info,
Warn,
Error,
Fatal,
Silent,
}
// https://cs.android.com/android/platform/superproject/+/master:system/logging/liblog/logprint.cpp;l=140;drc=8dbf3b2bb6b6d1652d9797e477b9abd03278bb79
export const AndroidLogPriorityToCharacter: Record<AndroidLogPriority, string> =
{
[AndroidLogPriority.Unknown]: "?",
[AndroidLogPriority.Default]: "?",
[AndroidLogPriority.Verbose]: "V",
[AndroidLogPriority.Debug]: "D",
[AndroidLogPriority.Info]: "I",
[AndroidLogPriority.Warn]: "W",
[AndroidLogPriority.Error]: "E",
[AndroidLogPriority.Fatal]: "F",
[AndroidLogPriority.Silent]: "S",
};
export enum LogcatFormat {
Brief,
Process,
Tag,
Thread,
Raw,
Time,
ThreadTime,
Long,
}
export interface LogcatFormatModifiers {
microseconds?: boolean;
nanoseconds?: boolean;
printable?: boolean;
year?: boolean;
timezone?: boolean;
epoch?: boolean;
monotonic?: boolean;
uid?: boolean;
descriptive?: boolean;
}
export interface LogcatOptions {
dump?: boolean;
pid?: number;
ids?: LogId[];
}
const NANOSECONDS_PER_SECOND = BigInt(1e9);
// https://cs.android.com/android/platform/superproject/+/master:system/logging/liblog/include/log/log_read.h;l=39;drc=82b5738732161dbaafb2e2f25cce19cd26b9157d
export const LoggerEntry = new Struct({ littleEndian: true })
.uint16("payloadSize")
.uint16("headerSize")
.int32("pid")
.uint32("tid")
.uint32("seconds")
.uint32("nanoseconds")
.uint32("logId")
.uint32("uid")
.extra({
get timestamp() {
return (
BigInt(this.seconds) * NANOSECONDS_PER_SECOND +
BigInt(this.nanoseconds)
);
},
});
export type LoggerEntry = (typeof LoggerEntry)["TDeserializeResult"];
// https://cs.android.com/android/platform/superproject/+/master:system/logging/liblog/logprint.cpp;drc=bbe77d66e7bee8bd1f0bc7e5492b5376b0207ef6;bpv=0
export interface AndroidLogEntry extends LoggerEntry {
priority: AndroidLogPriority;
tag: string;
message: string;
toString(format?: LogcatFormat, modifiers?: LogcatFormatModifiers): string;
}
function padZero(number: number, length: number) {
return number.toString().padStart(length, "0");
}
function formatSeconds(seconds: number, modifiers: LogcatFormatModifiers) {
if (modifiers.monotonic) {
return padZero(seconds, 6);
}
if (modifiers.epoch) {
return padZero(seconds, 19);
}
const date = new Date(seconds * 1000);
const month = padZero(date.getMonth() + 1, 2);
const day = padZero(date.getDate(), 2);
const hour = padZero(date.getHours(), 2);
const minute = padZero(date.getMinutes(), 2);
const second = padZero(date.getSeconds(), 2);
const result = `${month}-${day} ${hour}:${minute}:${second}`;
if (modifiers.year) {
const year = padZero(date.getFullYear(), 4);
return `${year}-${result}`;
}
return result;
}
function formatNanoseconds(
nanoseconds: number,
modifiers: LogcatFormatModifiers,
) {
if (modifiers.nanoseconds) {
return padZero(nanoseconds, 9);
}
if (modifiers.microseconds) {
return padZero(nanoseconds / 1000, 6);
}
return padZero(nanoseconds / 1000000, 3);
}
function formatTimezone(seconds: number, modifiers: LogcatFormatModifiers) {
if (!modifiers.timezone || modifiers.monotonic || modifiers.epoch) {
return "";
}
const date = new Date(seconds * 1000);
const offset = date.getTimezoneOffset();
const sign = offset <= 0 ? "+" : "-";
const absolute = Math.abs(offset);
const hours = (absolute / 60) | 0;
const minutes = absolute % 60;
// prettier-ignore
return ` ${
sign
}${
hours.toString().padStart(2, "0")
}:${
minutes.toString().padStart(2, "0")
}`;
}
function formatTime(
seconds: number,
nanoseconds: number,
modifiers: LogcatFormatModifiers,
) {
const secondsString = formatSeconds(seconds, modifiers);
const nanosecondsString = formatNanoseconds(nanoseconds, modifiers);
const zoneString = formatTimezone(seconds, modifiers);
return `${secondsString}.${nanosecondsString}${zoneString}`;
}
function formatUid(
uid: number,
modifiers: LogcatFormatModifiers,
suffix: string,
) {
return modifiers.uid ? `${uid.toString().padStart(5)}${suffix}` : "";
}
function getFormatPrefix(
entry: AndroidLogEntry,
format: LogcatFormat,
modifiers: LogcatFormatModifiers,
) {
// https://cs.android.com/android/platform/superproject/+/master:system/logging/liblog/logprint.cpp;l=1415;drc=8dbf3b2bb6b6d1652d9797e477b9abd03278bb79
switch (format) {
// TODO: implement other formats
case LogcatFormat.Tag:
// prettier-ignore
return `${
AndroidLogPriorityToCharacter[entry.priority]
}/${
entry.tag.padEnd(8)
}: `;
case LogcatFormat.Process:
// prettier-ignore
return `${
AndroidLogPriorityToCharacter[entry.priority]
}(${
formatUid(entry.uid, modifiers, ":")
}${
entry.pid.toString().padStart(5)
}) `;
case LogcatFormat.Thread:
// prettier-ignore
return `${
AndroidLogPriorityToCharacter[entry.priority]
}(${
formatUid(entry.uid, modifiers, ":")
}${
entry.pid.toString().padStart(5)
}:${
entry.tid.toString().padStart(5)
}) `;
case LogcatFormat.Raw:
return "";
case LogcatFormat.Time:
// prettier-ignore
return `${
formatTime(entry.seconds, entry.nanoseconds, modifiers)
} ${
AndroidLogPriorityToCharacter[entry.priority]
}/${
entry.tag.padEnd(8)
}(${
formatUid(entry.uid, modifiers, ":")
}${
entry.pid.toString().padStart(5)
}): `;
case LogcatFormat.ThreadTime:
// prettier-ignore
return `${
formatTime(entry.seconds, entry.nanoseconds, modifiers)
} ${
formatUid(entry.uid, modifiers, " ")
}${
entry.pid.toString().padStart(5)
} ${
entry.tid.toString().padStart(5)
} ${
AndroidLogPriorityToCharacter[entry.priority]
} ${
entry.tag.toString().padEnd(8)
}: `;
case LogcatFormat.Brief:
default:
// prettier-ignore
return `${
AndroidLogPriorityToCharacter[entry.priority]
}/${
entry.tag.padEnd(8)
}(${
formatUid(entry.uid, modifiers, ":")
}${
entry.pid.toString().padStart(5)
}): `;
}
}
function getFormatSuffix(entry: AndroidLogEntry, format: LogcatFormat) {
switch (format) {
case LogcatFormat.Process:
return ` (${entry.tag})`;
default:
return "";
}
}
function formatEntryWrapLine(
entry: AndroidLogEntry,
format: LogcatFormat,
modifiers: LogcatFormatModifiers,
) {
const prefix = getFormatPrefix(entry, format, modifiers);
const suffix = getFormatSuffix(entry, format);
return (
prefix + entry.message.replaceAll("\n", suffix + "\n" + prefix) + suffix
);
}
function AndroidLogEntryToString(
this: AndroidLogEntry,
format: LogcatFormat = LogcatFormat.ThreadTime,
modifiers: LogcatFormatModifiers = {},
) {
switch (format) {
case LogcatFormat.Long:
// prettier-ignore
return `[ ${
formatTime(this.seconds, this.nanoseconds, modifiers)
} ${
formatUid(this.uid, modifiers, ":")
}${
this.pid.toString().padStart(5)
}:${
this.tid.toString().padStart(5)
} ${
AndroidLogPriorityToCharacter[this.priority]
}/${
this.tag.padEnd(8)
} ]\n${
this.message
}\n`;
default:
return formatEntryWrapLine(this, format, modifiers);
}
}
function findTagEnd(payload: Uint8Array) {
for (const separator of [0, " ".charCodeAt(0), ":".charCodeAt(0)]) {
const index = payload.indexOf(separator);
if (index !== -1) {
return index;
}
}
const index = payload.findIndex((x) => x >= 0x7f);
if (index !== -1) {
return index;
}
return payload.length;
}
export async function deserializeAndroidLogEntry(
stream: AsyncExactReadable,
): Promise<AndroidLogEntry> {
const entry = (await LoggerEntry.deserialize(stream)) as AndroidLogEntry;
if (entry.headerSize !== LoggerEntry.size) {
// Skip unknown fields
await stream.readExactly(entry.headerSize - LoggerEntry.size);
}
let payload = await stream.readExactly(entry.payloadSize);
// https://cs.android.com/android/platform/superproject/+/master:system/logging/logcat/logcat.cpp;l=193-194;drc=bbe77d66e7bee8bd1f0bc7e5492b5376b0207ef6
// TODO: payload for some log IDs are in binary format.
entry.priority = payload[0] as AndroidLogPriority;
payload = payload.subarray(1);
const tagEnd = findTagEnd(payload);
entry.tag = decodeUtf8(payload.subarray(0, tagEnd));
entry.message =
tagEnd < payload.length - 1
? decodeUtf8(payload.subarray(tagEnd + 1))
: "";
entry.toString = AndroidLogEntryToString;
return entry;
}
export interface LogSize {
id: LogId;
size: number;
readable?: number;
consumed: number;
maxEntrySize: number;
maxPayloadSize: number;
}
export class Logcat extends AdbCommandBase {
static logIdToName(id: LogId): string {
return LogId[id];
}
static logNameToId(name: string): LogId {
const key = name[0]!.toUpperCase() + name.substring(1);
return LogId[key as keyof typeof LogId];
}
static joinLogId(ids: LogId[]): string {
return ids.map((id) => Logcat.logIdToName(id)).join(",");
}
static parseSize(value: number, multiplier: string): number {
const MULTIPLIERS = ["", "Ki", "Mi", "Gi"];
return value * 1024 ** (MULTIPLIERS.indexOf(multiplier) || 0);
}
// TODO: logcat: Support output format before Android 10
// ref https://android-review.googlesource.com/c/platform/system/core/+/748128
static readonly LOG_SIZE_REGEX_10: RegExp =
/(.*): ring buffer is (.*) (.*)B \((.*) (.*)B consumed\), max entry is (.*) B, max payload is (.*) B/;
// Android 11 added `readable` part
// ref https://android-review.googlesource.com/c/platform/system/core/+/1390940
static readonly LOG_SIZE_REGEX_11: RegExp =
/(.*): ring buffer is (.*) (.*)B \((.*) (.*)B consumed, (.*) (.*)B readable\), max entry is (.*) B, max payload is (.*) B/;
async getLogSize(ids?: LogId[]): Promise<LogSize[]> {
const { stdout } = await this.adb.subprocess.spawn([
"logcat",
"-g",
...(ids ? ["-b", Logcat.joinLogId(ids)] : []),
]);
const result: LogSize[] = [];
await stdout
.pipeThrough(new TextDecoderStream())
.pipeThrough(new SplitStringStream("\n"))
.pipeTo(
new WritableStream({
write(chunk) {
let match = chunk.match(Logcat.LOG_SIZE_REGEX_11);
if (match) {
result.push({
id: Logcat.logNameToId(match[1]!),
size: Logcat.parseSize(
Number.parseInt(match[2]!, 10),
match[3]!,
),
readable: Logcat.parseSize(
Number.parseInt(match[6]!, 10),
match[7]!,
),
consumed: Logcat.parseSize(
Number.parseInt(match[4]!, 10),
match[5]!,
),
maxEntrySize: parseInt(match[8]!, 10),
maxPayloadSize: parseInt(match[9]!, 10),
});
return;
}
match = chunk.match(Logcat.LOG_SIZE_REGEX_10);
if (match) {
result.push({
id: Logcat.logNameToId(match[1]!),
size: Logcat.parseSize(
Number.parseInt(match[2]!, 10),
match[3]!,
),
consumed: Logcat.parseSize(
Number.parseInt(match[4]!, 10),
match[5]!,
),
maxEntrySize: parseInt(match[6]!, 10),
maxPayloadSize: parseInt(match[7]!, 10),
});
}
},
}),
);
return result;
}
async clear(ids?: LogId[]): Promise<void> {
const args = ["logcat", "-c"];
if (ids && ids.length > 0) {
args.push("-b", Logcat.joinLogId(ids));
}
await this.adb.subprocess.spawnAndWait(args);
}
binary(options?: LogcatOptions): ReadableStream<AndroidLogEntry> {
return new WrapReadableStream(async () => {
const args = ["logcat", "-B"];
if (options?.dump) {
args.push("-d");
}
if (options?.pid) {
args.push("--pid", options.pid.toString());
}
if (options?.ids) {
args.push("-b", Logcat.joinLogId(options.ids));
}
// TODO: make `spawn` return synchronously with streams pending
// so it's easier to chain them.
const { stdout } = await this.adb.subprocess.spawn(args, {
// PERF: None protocol is 150% faster then Shell protocol
protocols: [AdbSubprocessNoneProtocol],
});
return stdout;
}).pipeThrough(
new BufferedTransformStream((stream) => {
return deserializeAndroidLogEntry(stream);
}),
);
}
}