* feat(adb): acquire installed APK sets from connected Android devices Add a read-only ADB device-acquisition family over the caller's adb binary: inspect_adb_client, list_adb_devices, inspect_adb_device, list_adb_packages, and pull_adb_package, exposed through the CLI and MCP with shared admission, Evidence provenance (binary, version, serial, transport), and fixed argv vectors that never reach a shell. pull_adb_package acquires the complete split set reported by pm path into a package-named directory that must not exist, digesting every file with SHA-256 and keeping per-file failure reasons and partial coverage. Device kind is reported as an inference with its basis; getprop whitelisting keeps build identity bounded. REA installs nothing and never manages devices or emulators; the first device query may start the caller's local adb server, recorded when observed. Verified against adb 34.0.5 on Linux with an Android 14 (API 34) x86_64 emulator: full 224-package inventory, a real two-APK split set pulled with byte-exact digests, and a 73.9 MB system-package pull. npm run verify:adb records the lane; parser goldens come from the same device. READMEs carry the new row in every supported language. * feat(adb): complete the adb surface with observation, transfer, and lifecycle tools Extend the ADB family from five to twenty-four operations, all through fixed adb argument vectors with no shell exposure: Observation: read_adb_logcat (bounded dump, count/buffer/pid), inspect_adb_package (dumpsys package projection), list_adb_processes (ps -A), list_adb_directory (ls -la with kinds and symlink targets), list_adb_features (hex GL versions), list_adb_services (AIDL slash-suffixed names), inspect_adb_display (wm size and density), inspect_adb_window (focus declarations), read_adb_setting (device-side nulls reported as such), capture_adb_screen (exec-out screencap with PNG dimensions), collect_adb_bugreport (digested device archive). Transfer and search: pull_adb_file and push_adb_file (device-side sha256sum verification, explicit overwrite authority), resolve_adb_packages (substring package search with exact-match flag — the find step of an easy get). Lifecycle for dynamic analysis, each declaring mutatesTarget: install_adb_package and uninstall_adb_package preserving the device's Success/Failure verdict, start_adb_app (resolve-activity then am start -W), start_adb_activity (typed extras as --es/--ez/--ei/--el/--ef), and stop_adb_app (am force-stop). Verified end-to-end against adb 34.0.5 on Linux with an Android 14 (API 34) x86_64 emulator: 287 services, 92 features, split-set pulls with byte-exact digests, verified push/pull roundtrip, sized screen capture, and the full install → resolve → start → observe-in-ps → force-stop → uninstall lifecycle. Fixed a v-mode regex incompatibility in the settings key pattern and added argument descriptions required by the CLI catalog audit. * fix(adb): quote remote shell operands and protect captured output * test(adb): cover bugreport destinations containing spaces --------- Co-authored-by: REA Contributor <contributor@rea.tools> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
434 lines
13 KiB
TypeScript
434 lines
13 KiB
TypeScript
/**
|
|
* 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 AdbDirectoryEntry {
|
|
readonly name: string;
|
|
readonly kind: "directory" | "file" | "symlink" | "other";
|
|
readonly permissions: string | null;
|
|
readonly owner: string | null;
|
|
readonly group: string | null;
|
|
readonly bytes: number | null;
|
|
readonly date: string | null;
|
|
readonly link_target: string | null;
|
|
}
|
|
|
|
const LS_LINE =
|
|
/^([bcdlps-][rwxstST-]{9})\s+(?:(\d+)\s+)?(\S+)\s+(\S+)\s+(?:(\d+)\s+)?([\d-]{10})\s+([\d:]{4,8})\s+(.+)$/u;
|
|
|
|
/** Parse `ls -la` output, including `total` headers and symlink targets. */
|
|
export const parseLsOutput = (
|
|
stdout: string,
|
|
): {
|
|
entries: readonly AdbDirectoryEntry[];
|
|
unparsedLines: readonly string[];
|
|
} => {
|
|
const entries: AdbDirectoryEntry[] = [];
|
|
const unparsedLines: string[] = [];
|
|
for (const rawLine of stdout.split(/\r?\n/u)) {
|
|
const line = rawLine.trimEnd();
|
|
if (line === "" || line.startsWith("total ")) continue;
|
|
const match = LS_LINE.exec(line);
|
|
if (match === null) {
|
|
if (line.trim() !== "") unparsedLines.push(line);
|
|
continue;
|
|
}
|
|
const permissions = match[1]!;
|
|
let name = match[8]!.trim();
|
|
let linkTarget: string | null = null;
|
|
const linkArrow = name.indexOf(" -> ");
|
|
if (linkArrow !== -1 && permissions.startsWith("l")) {
|
|
linkTarget = name.slice(linkArrow + 4);
|
|
name = name.slice(0, linkArrow);
|
|
}
|
|
entries.push({
|
|
name,
|
|
kind: permissions.startsWith("d")
|
|
? "directory"
|
|
: permissions.startsWith("l")
|
|
? "symlink"
|
|
: permissions.startsWith("-")
|
|
? "file"
|
|
: "other",
|
|
permissions,
|
|
owner: match[3] ?? null,
|
|
group: match[4] ?? null,
|
|
bytes: match[5] === undefined ? null : Number(match[5]),
|
|
date: `${match[6] ?? ""} ${match[7] ?? ""}`.trim() || null,
|
|
link_target: linkTarget,
|
|
});
|
|
}
|
|
return { entries, 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;
|
|
}
|
|
if (inPermissions && /^\s{2,}[A-Za-z0-9._]+$/.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]!;
|
|
};
|
|
|
|
/**
|
|
* Parse `am start` output. Success prints `Starting: Intent { ... }`;
|
|
* refusals print lines starting with `Error:` or `Warning:` and activity
|
|
* classes that do not exist name the component.
|
|
*/
|
|
export const parseAmStartOutput = (
|
|
stdout: string,
|
|
): {
|
|
readonly status: "started" | "refused";
|
|
readonly startedActivity: string | null;
|
|
readonly reason: string | null;
|
|
} => {
|
|
const text = stdout.trim();
|
|
const starting = /^Starting:\s+Intent\s*\{(.*)\}\s*$/mu.exec(text);
|
|
if (starting !== null)
|
|
return {
|
|
status: "started",
|
|
startedActivity: starting[1]!.trim(),
|
|
reason: null,
|
|
};
|
|
const error = /^(?:Error|Warning|Exception)(?::|\b)(.*)$/mu.exec(text);
|
|
return {
|
|
status: "refused",
|
|
startedActivity: null,
|
|
reason:
|
|
error?.[1]?.trim() ??
|
|
(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),
|
|
};
|
|
};
|