/** * Parsers for fixed observation commands REA runs through `adb shell`. * * Formats follow toybox output on Android 8+ (verified against an Android 14 * emulator). Unparseable lines are retained rather than dropped so callers * can see the format drift instead of silently losing rows. */ export interface AdbProcessEntry { readonly user: string; readonly pid: number; readonly ppid: number | null; readonly rss: number | null; readonly state: string | null; readonly name: string; } const PROCESS_LINE = /^(\S+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(.+)$/u; /** Parse `ps -A` output; the header line is preserved verbatim as `columns`. */ export const parseProcessListOutput = ( stdout: string, ): { columns: string | null; processes: readonly AdbProcessEntry[]; unparsedLines: readonly string[]; } => { const processes: AdbProcessEntry[] = []; const unparsedLines: string[] = []; let columns: string | null = null; for (const rawLine of stdout.split(/\r?\n/u)) { const line = rawLine.trimEnd(); if (line === "") continue; if (/^USER\s+PID\s+PPID\b/u.test(line)) { columns = line.trim(); continue; } const match = PROCESS_LINE.exec(line); if (match === null) { unparsedLines.push(line); continue; } processes.push({ user: match[1]!, pid: Number(match[2]!), ppid: Number.isSafeInteger(Number(match[3]!)) ? Number(match[3]!) : null, rss: Number.isSafeInteger(Number(match[5]!)) ? Number(match[5]!) : null, state: match[8]!, name: match[9]!.trim(), }); } return { columns, processes, unparsedLines }; }; export interface AdbFeatureEntry { readonly name: string; readonly version: number | null; } const FEATURE_LINE = /^feature:([^=\s]+)(?:=(0x[0-9a-fA-F]+|\d+))?$/u; const parseFeatureVersion = (text: string): number => text.startsWith("0x") ? Number.parseInt(text.slice(2), 16) : Number(text); /** Parse `pm list features` output, including hex GL versions. */ export const parseFeatureListOutput = ( stdout: string, ): { features: readonly AdbFeatureEntry[]; unparsedLines: readonly string[]; } => { const features: AdbFeatureEntry[] = []; const unparsedLines: string[] = []; for (const rawLine of stdout.split(/\r?\n/u)) { const line = rawLine.trim(); if (line === "") continue; const match = FEATURE_LINE.exec(line); if (match === null) { unparsedLines.push(line); continue; } features.push({ name: match[1]!, version: match[2] === undefined ? null : parseFeatureVersion(match[2]!), }); } return { features, unparsedLines }; }; export interface AdbServiceEntry { readonly name: string; readonly interface: string | null; } const SERVICE_LINE = /^\s*(?:\d+\s+)?([A-Za-z0-9._$/-]+):\s*\[([^\]]*)\]\s*$/u; /** Parse `service list` output, tolerating numbered and plain formats. */ export const parseServiceListOutput = ( stdout: string, ): { services: readonly AdbServiceEntry[]; unparsedLines: readonly string[]; } => { const services: AdbServiceEntry[] = []; const unparsedLines: string[] = []; for (const rawLine of stdout.split(/\r?\n/u)) { const line = rawLine.trimEnd(); if (line.trim() === "" || /^Found \d+ services:/u.test(line.trim())) continue; const match = SERVICE_LINE.exec(line); if (match === null) { if (line.trim() !== "") unparsedLines.push(line.trim()); continue; } services.push({ name: match[1]!, interface: match[2] === "" ? null : match[2]!, }); } return { services, unparsedLines }; }; const SIZE_PAIR = /^Physical size:\s+(\d+)x(\d+)$/u; const SIZE_OVERRIDE = /^Override size:\s+(\d+)x(\d+)$/u; const DENSITY_PHYSICAL = /^Physical density:\s+(\d+)$/u; const DENSITY_OVERRIDE = /^Override density:\s+(\d+)$/u; /** Parse `wm size` output into physical and optional override sizes. */ export const parseWmSizeOutput = ( stdout: string, ): { physical: { readonly width: number; readonly height: number } | null; override: { readonly width: number; readonly height: number } | null; } => { let physical = null; let override = null; for (const line of stdout.split(/\r?\n/u)) { const trimmed = line.trim(); const physicalMatch = SIZE_PAIR.exec(trimmed); if (physicalMatch !== null) physical = { width: Number(physicalMatch[1]!), height: Number(physicalMatch[2]!), }; const overrideMatch = SIZE_OVERRIDE.exec(trimmed); if (overrideMatch !== null) override = { width: Number(overrideMatch[1]!), height: Number(overrideMatch[2]!), }; } return { physical, override }; }; /** Parse `wm density` output. Older builds print a bare number. */ export const parseWmDensityOutput = ( stdout: string, ): { physical: number | null; override: number | null; } => { let physical = null; let override = null; for (const line of stdout.split(/\r?\n/u)) { const trimmed = line.trim(); const physicalMatch = DENSITY_PHYSICAL.exec(trimmed); if (physicalMatch !== null) physical = Number(physicalMatch[1]!); const overrideMatch = DENSITY_OVERRIDE.exec(trimmed); if (overrideMatch !== null) override = Number(overrideMatch[1]!); } return { physical, override }; }; /** Parse the focus declarations from `dumpsys window windows`. */ export const parseWindowFocusOutput = ( stdout: string, ): { focusedWindow: string | null; focusedApp: string | null; } => { let focusedWindow = null; let focusedApp = null; for (const line of stdout.split(/\r?\n/u)) { const trimmed = line.trim(); if (focusedWindow === null && trimmed.startsWith("mCurrentFocus=")) focusedWindow = trimmed.slice("mCurrentFocus=".length).trim(); if (focusedApp === null && trimmed.startsWith("mFocusedApp=")) focusedApp = trimmed.slice("mFocusedApp=".length).trim(); } return { focusedWindow, focusedApp }; }; export interface AdbPackageDetails { readonly versionName: string | null; readonly versionCode: number | null; readonly firstInstallTime: string | null; readonly lastUpdateTime: string | null; readonly installerPackageName: string | null; readonly userId: number | null; readonly pkgFlags: string | null; readonly requestedPermissions: number | null; } const DETAIL_LINE = /^\s+([A-Za-z][A-Za-z0-9_]*)=(.*)$/u; /** * Project `dumpsys package PACKAGE` output. Only the listed fields are * extracted; the input is version-dependent and everything else is ignored * by design rather than guessed. */ export const parseDumpsysPackageOutput = ( stdout: string, ): AdbPackageDetails => { const details: { versionName: string | null; versionCode: number | null; firstInstallTime: string | null; lastUpdateTime: string | null; installerPackageName: string | null; userId: number | null; pkgFlags: string | null; } = { versionName: null, versionCode: null, firstInstallTime: null, lastUpdateTime: null, installerPackageName: null, userId: null, pkgFlags: null, }; let inPermissions = false; let permissions = 0; for (const line of stdout.split(/\r?\n/u)) { if (line.trim().startsWith("requested permissions:")) { inPermissions = true; continue; } // Android 10 and 11 mark restricted entries as "NAME: restricted=true". if ( inPermissions && /^\s{2,}[A-Za-z0-9._]+(?:: restricted=true)?$/u.test(line) ) { permissions += 1; continue; } inPermissions = false; const match = DETAIL_LINE.exec(line); if (match === null) continue; const key = match[1]!; const value = match[2]!.trim(); if (key === "versionName" && details.versionName === null) details.versionName = value === "" ? null : value; else if (key === "versionCode" && details.versionCode === null) { const parsed = /^(\d+)/u.exec(value); if (parsed !== null) details.versionCode = Number(parsed[1]!); } else if (key === "firstInstallTime" && details.firstInstallTime === null) details.firstInstallTime = value === "" ? null : value; else if (key === "lastUpdateTime" && details.lastUpdateTime === null) details.lastUpdateTime = value === "" ? null : value; else if ( key === "installerPackageName" && details.installerPackageName === null ) details.installerPackageName = value === "" ? null : value; else if (key === "userId" && details.userId === null) { const parsed = /^(\d+)/u.exec(value); if (parsed !== null) details.userId = Number(parsed[1]!); } else if (key === "pkgFlags" && details.pkgFlags === null) details.pkgFlags = value === "" ? null : value; } return { ...details, requestedPermissions: permissions }; }; /** * Parse `settings get` output. Android prints the literal string `null` for * absent keys; that is reported as a device-side null observation. */ export const parseSettingsGetOutput = ( stdout: string, ): { value: string | null; deviceReportedNull: boolean; } => { const trimmed = stdout.replace(/\r?\n$/u, ""); if (trimmed === "null") return { value: null, deviceReportedNull: true }; return { value: trimmed, deviceReportedNull: false }; }; /** Parse the host-side completion line from `adb bugreport`. */ export const parseBugreportCopiedPath = (stdout: string): string | null => { const match = /^Bug report copied to (.+)\r?$/mu.exec(stdout.trim()); return match === null ? null : match[1]!; }; /** * Parse `adb install`/`adb uninstall` output. adb can print `Failure [...]` * with a zero exit code, so the text decides, not the exit status. */ export const parsePackageChangeOutput = ( stdout: string, ): { readonly status: "success" | "failure"; readonly reason: string | null; } => { const text = stdout.trim(); if (/^Success$/mu.test(text)) return { status: "success", reason: null }; const failure = /Failure \[([^\]]+)\]/u.exec(text); return { status: "failure", reason: failure?.[1] ?? (text === "" ? null : text.slice(0, 512)), }; }; /** * Parse `cmd package resolve-activity --brief` output: the component is the * last non-empty line. */ export const parseResolveActivityOutput = (stdout: string): string | null => { const lines = stdout .split(/\r?\n/u) .map((line) => line.trim()) .filter((line) => line !== "" && !/^No activity found$/iu.test(line)); return lines.length === 0 ? null : lines[lines.length - 1]!; }; /** Parsed facts and verdict from the full `am start -W` report. */ export interface AmStartOutput { readonly status: "started" | "refused"; readonly startedActivity: string | null; readonly activity: string | null; readonly reportedStatus: string | null; readonly reason: string | null; } /** * Parse the complete `am start -W` report. `Starting:` records an attempt; * hard errors and any non-ok Activity Manager status determine the verdict. */ export const parseAmStartOutput = ( stdout: string, stderr = "", ): AmStartOutput => { const normalizedStdout = stdout.replaceAll("\r\n", "\n"); const starting = /^Starting:[ \t]+Intent[ \t]*\{(.*)\}[ \t]*$/mu.exec( normalizedStdout, ); const startedActivity = starting?.[1]?.trim() ?? null; const activityMatch = /^Activity:[ \t]*(.*)$/mu.exec(normalizedStdout); const activity = activityMatch?.[1]?.trim() || null; const statuses = [...normalizedStdout.matchAll(/^Status:[ \t]*(.*)$/gmu)].map( (match) => match[1]!.trim(), ); const nonOkStatus = statuses.find((status) => status.toLowerCase() !== "ok"); const reportedStatus = nonOkStatus ?? statuses[0] ?? null; const error = [normalizedStdout, stderr.replaceAll("\r\n", "\n")] .flatMap((stream) => stream.split("\n")) .map((line) => line.trim()) .map((line) => ({ line, match: /^(?:Error|Exception)(?::|\b)(.*)$/iu.exec(line), })) .find(({ match }) => match !== null); const errorReason = error?.match?.[1]?.trim() || error?.line || null; const statusReason = nonOkStatus === "" ? "am start reported an empty status" : nonOkStatus === undefined ? null : `am start reported status: ${nonOkStatus}`; const reason = errorReason ?? statusReason ?? (/^Warning:/mu.test(normalizedStdout.trim()) ? normalizedStdout.trim().slice(0, 512) : null); if (errorReason !== null || nonOkStatus !== undefined) return { status: "refused", startedActivity, activity, reportedStatus, reason: reason ?? "am start did not report a successful launch", }; if (startedActivity !== null) return { status: "started", startedActivity, activity, reportedStatus, reason: null, }; const text = `${normalizedStdout}\n${stderr.replaceAll("\r\n", "\n")}`.trim(); return { status: "refused", startedActivity, activity, reportedStatus, reason: reason ?? (text === "" ? "am start returned no activity" : text.slice(0, 512)), }; }; const PNG_SIGNATURE = Buffer.from([ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, ]); /** Read image dimensions from a PNG header, or null for other payloads. */ export const parsePngDimensions = ( bytes: Uint8Array, ): { width: number | null; height: number | null; } => { if ( bytes.length < 24 || !Buffer.from(bytes.subarray(0, 8)).equals(PNG_SIGNATURE) ) return { width: null, height: null }; const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); return { width: view.getUint32(16), height: view.getUint32(20), }; };