mirror of
https://github.com/morluto/rea.git
synced 2026-11-08 15:56:35 +00:00
Recognize exact current and legacy ADB startup notices without treating stderr as device inventory or accepting error lookalikes. Fixes #3051. Reviewed-PR: #3073 Co-authored-by: Vinícius DS Andrade <95101505+viniciusdsandrade@users.noreply.github.com>
289 lines
8.6 KiB
TypeScript
289 lines
8.6 KiB
TypeScript
/**
|
|
* Parsers for the exact adb and Package Manager output formats REA consumes.
|
|
*
|
|
* Each parser preserves what the tool actually printed: unparseable lines are
|
|
* retained rather than silently dropped, and derived classifications carry
|
|
* their evidence basis.
|
|
*/
|
|
|
|
import {
|
|
adbHostFileName,
|
|
type AdbHostFileNameReason,
|
|
} from "./AdbHostFileName.js";
|
|
import {
|
|
ADB_DEVICE_STATES,
|
|
type AdbDeviceState,
|
|
} from "../domain/adb/adbDeviceAnalysis.js";
|
|
|
|
/** One device exactly as `adb devices -l` reports it. */
|
|
export interface AdbDeviceEntry {
|
|
readonly serial: string;
|
|
readonly state: AdbDeviceState;
|
|
readonly transport: "usb" | "tcp" | "unknown";
|
|
readonly product: string | null;
|
|
readonly model: string | null;
|
|
readonly device: string | null;
|
|
readonly transport_id: number | null;
|
|
readonly kind: "emulator" | "physical" | "unknown";
|
|
readonly kind_basis: "emulator_serial_prefix" | "usb_transport" | "none";
|
|
}
|
|
|
|
const DEVICE_LINE = /^(\S+)\s+(.*)$/u;
|
|
|
|
const normalizeAdbOutputLine = (rawLine: string): string => rawLine.trimEnd();
|
|
|
|
const isSuccessfulDaemonStartNotice = (line: string): boolean =>
|
|
line === "* daemon started successfully" ||
|
|
line === "* daemon started successfully *";
|
|
|
|
const metadataValue = (remainder: string, key: string): string | null => {
|
|
const match = new RegExp(`(?:^|\\s)${key}:(\\S+)`, "u").exec(remainder);
|
|
return match === null ? null : match[1]!;
|
|
};
|
|
|
|
/**
|
|
* Parse `adb devices -l` device lines. The banner, blank lines, and the
|
|
* daemon startup notices adb prints on first use are recognized separately;
|
|
* every other unrecognized line is returned for honest reporting.
|
|
*/
|
|
export const parseAdbDevicesOutput = (
|
|
stdout: string,
|
|
stderr = "",
|
|
): {
|
|
devices: readonly AdbDeviceEntry[];
|
|
unparsedLines: readonly string[];
|
|
daemonStarted: boolean;
|
|
} => {
|
|
const devices: AdbDeviceEntry[] = [];
|
|
const unparsedLines: string[] = [];
|
|
let daemonStarted = false;
|
|
for (const rawLine of stdout.split(/\r?\n/u)) {
|
|
const line = normalizeAdbOutputLine(rawLine);
|
|
if (isSuccessfulDaemonStartNotice(line)) {
|
|
daemonStarted = true;
|
|
continue;
|
|
}
|
|
if (line === "" || line.startsWith("List of devices attached")) continue;
|
|
if (line.startsWith("* daemon")) continue;
|
|
const match = DEVICE_LINE.exec(line);
|
|
const state = ADB_DEVICE_STATES.find((candidate) => {
|
|
const remainder = match?.[2];
|
|
if (remainder === undefined || !remainder.startsWith(candidate))
|
|
return false;
|
|
const next = remainder[candidate.length];
|
|
return next === undefined || /\s/u.test(next);
|
|
});
|
|
if (match === null || state === undefined) {
|
|
if (rawLine.trim() !== "") unparsedLines.push(rawLine);
|
|
continue;
|
|
}
|
|
const serial = match[1]!;
|
|
const remainder = match[2]!.slice(state.length);
|
|
const usb = metadataValue(remainder, "usb");
|
|
const transportIdText = metadataValue(remainder, "transport_id");
|
|
const parsedTransportId =
|
|
transportIdText === null || !/^\d+$/u.test(transportIdText)
|
|
? null
|
|
: Number(transportIdText);
|
|
const isEmulator = /^emulator-\d+$/u.test(serial);
|
|
const kind = isEmulator
|
|
? ("emulator" as const)
|
|
: usb !== null
|
|
? ("physical" as const)
|
|
: ("unknown" as const);
|
|
devices.push({
|
|
serial,
|
|
state,
|
|
transport:
|
|
usb !== null
|
|
? "usb"
|
|
: isEmulator || serial.includes(":")
|
|
? "tcp"
|
|
: "unknown",
|
|
product: metadataValue(remainder, "product"),
|
|
model: metadataValue(remainder, "model"),
|
|
device: metadataValue(remainder, "device"),
|
|
transport_id: parsedTransportId,
|
|
kind,
|
|
kind_basis:
|
|
kind === "emulator"
|
|
? "emulator_serial_prefix"
|
|
: kind === "physical"
|
|
? "usb_transport"
|
|
: "none",
|
|
});
|
|
}
|
|
if (!daemonStarted && stderr !== "") {
|
|
for (const rawLine of stderr.split(/\r?\n/u)) {
|
|
const line = normalizeAdbOutputLine(rawLine);
|
|
if (isSuccessfulDaemonStartNotice(line)) {
|
|
daemonStarted = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return { devices, unparsedLines, daemonStarted };
|
|
};
|
|
|
|
/**
|
|
* Properties projected from a `getprop` dump. The whitelist keeps device
|
|
* inspection to build identity facts; a full dump is out of scope.
|
|
*/
|
|
const DEVICE_PROPERTY_WHITELIST = [
|
|
"ro.build.version.release",
|
|
"ro.build.version.sdk",
|
|
"ro.build.version.security_patch",
|
|
"ro.build.id",
|
|
"ro.build.fingerprint",
|
|
"ro.build.characteristics",
|
|
"ro.debuggable",
|
|
"ro.kernel.qemu",
|
|
"ro.product.brand",
|
|
"ro.product.device",
|
|
"ro.product.manufacturer",
|
|
"ro.product.model",
|
|
"ro.product.cpu.abilist",
|
|
] as const;
|
|
|
|
export interface AdbDeviceProperty {
|
|
readonly name: string;
|
|
readonly value: string;
|
|
}
|
|
|
|
/** Parse a full `getprop` dump into `[name]: [value]` pairs. */
|
|
export const parseGetpropOutput = (
|
|
stdout: string,
|
|
): {
|
|
properties: readonly AdbDeviceProperty[];
|
|
emulatorObserved: boolean | null;
|
|
} => {
|
|
const values = new Map<string, string>();
|
|
for (const rawLine of stdout.split(/\r?\n/u)) {
|
|
const match = /^\[([^\]]+)\]:\s*\[(.*)\]$/u.exec(rawLine.trim());
|
|
if (match === null) continue;
|
|
values.set(match[1]!, match[2]!);
|
|
}
|
|
const properties = DEVICE_PROPERTY_WHITELIST.flatMap((name) => {
|
|
const value = values.get(name);
|
|
return value === undefined || value === ""
|
|
? []
|
|
: [{ name, value } satisfies AdbDeviceProperty];
|
|
});
|
|
const qemu = values.get("ro.kernel.qemu");
|
|
return {
|
|
properties,
|
|
emulatorObserved: qemu === undefined ? null : qemu === "1",
|
|
};
|
|
};
|
|
|
|
/** One installed package exactly as `pm list packages -f` reports it. */
|
|
export interface AdbPackageEntry {
|
|
readonly package_name: string;
|
|
readonly base_apk_device_path: string;
|
|
}
|
|
|
|
const PACKAGE_LINE = /^package:(.+)=([^\s=]+)$/u;
|
|
|
|
/** Parse `pm list packages -f` lines, retaining unrecognized lines. */
|
|
export const parsePackageListOutput = (
|
|
stdout: string,
|
|
): {
|
|
packages: readonly AdbPackageEntry[];
|
|
unparsedLines: readonly string[];
|
|
} => {
|
|
const packages: AdbPackageEntry[] = [];
|
|
const unparsedLines: string[] = [];
|
|
for (const rawLine of stdout.split(/\r?\n/u)) {
|
|
const line = rawLine.trim();
|
|
if (line === "") continue;
|
|
const match = PACKAGE_LINE.exec(line);
|
|
if (match === null) {
|
|
unparsedLines.push(rawLine);
|
|
continue;
|
|
}
|
|
packages.push({
|
|
package_name: match[2]!,
|
|
base_apk_device_path: match[1]!,
|
|
});
|
|
}
|
|
return { packages, unparsedLines };
|
|
};
|
|
|
|
/** Quote raw device paths for prose without allowing bidi or invisible text. */
|
|
export const displayDevicePath = (devicePath: string): string => {
|
|
let excerpt = "";
|
|
let count = 0;
|
|
for (const character of devicePath) {
|
|
if (count === 160) break;
|
|
excerpt += character;
|
|
count += 1;
|
|
}
|
|
const quoted = JSON.stringify(excerpt).replace(
|
|
/[\p{C}\p{Z}\p{Default_Ignorable_Code_Point}\u2800]/gu,
|
|
(character) =>
|
|
character === " "
|
|
? character
|
|
: `\\u{${character.codePointAt(0)!.toString(16).toUpperCase()}}`,
|
|
);
|
|
return excerpt.length === devicePath.length ? quoted : `${quoted} (excerpt)`;
|
|
};
|
|
|
|
/** One APK of an installed package exactly as `pm path` reports it. */
|
|
export interface AdbPackagePathEntry {
|
|
readonly device_path: string;
|
|
readonly file_name: string;
|
|
readonly role: "base" | "split" | "unknown";
|
|
}
|
|
|
|
/**
|
|
* Parse `pm path PACKAGE` lines. The base/split role is derived from the file
|
|
* name (`base.apk`, `split_*.apk`), which is the only signal the output
|
|
* carries; any other name reports an unknown role.
|
|
*/
|
|
export const parsePmPathOutput = (
|
|
stdout: string,
|
|
): {
|
|
paths: readonly AdbPackagePathEntry[];
|
|
rejectedPaths: readonly {
|
|
device_path: string;
|
|
reason: AdbHostFileNameReason;
|
|
}[];
|
|
unparsedLines: readonly string[];
|
|
} => {
|
|
const paths: AdbPackagePathEntry[] = [];
|
|
const rejectedPaths: {
|
|
device_path: string;
|
|
reason: AdbHostFileNameReason;
|
|
}[] = [];
|
|
const unparsedLines: string[] = [];
|
|
for (const rawLine of stdout.split(/\r?\n/u)) {
|
|
if (rawLine === "") continue;
|
|
const match = /^package:(.*)$/u.exec(rawLine);
|
|
if (match === null) {
|
|
unparsedLines.push(rawLine);
|
|
continue;
|
|
}
|
|
const devicePath = match[1]!;
|
|
const validated = adbHostFileName(devicePath);
|
|
if (!validated.ok) {
|
|
rejectedPaths.push({
|
|
device_path: devicePath,
|
|
reason: validated.reason,
|
|
});
|
|
continue;
|
|
}
|
|
const fileName = validated.fileName;
|
|
paths.push({
|
|
device_path: devicePath,
|
|
file_name: fileName,
|
|
role:
|
|
fileName === "base.apk"
|
|
? "base"
|
|
: fileName.startsWith("split_") && fileName.endsWith(".apk")
|
|
? "split"
|
|
: "unknown",
|
|
});
|
|
}
|
|
return { paths, rejectedPaths, unparsedLines };
|
|
};
|