mirror of
https://github.com/morluto/rea.git
synced 2026-11-05 06:15:59 +00:00
* 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>
243 lines
8.0 KiB
TypeScript
243 lines
8.0 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
parseAdbDevicesOutput,
|
|
parseGetpropOutput,
|
|
parsePackageListOutput,
|
|
parsePmPathOutput,
|
|
} from "./AdbDeviceOutput.js";
|
|
|
|
describe("parseAdbDevicesOutput", () => {
|
|
it("parses emulator, USB, and network devices with their metadata", () => {
|
|
const output = [
|
|
"List of devices attached",
|
|
"emulator-5554 device product:sdk_gphone64_x86_64 model:sdk_gphone64_x86_64 device:emu64xa transport_id:1",
|
|
"07ef9c2a device usb:1-1 product:raven model:Pixel_6_Pro device:raven transport_id:2",
|
|
"192.168.1.42:5555 device product:mock model:mock transport_id:3",
|
|
"",
|
|
].join("\n");
|
|
const { devices, unparsedLines, daemonStarted } =
|
|
parseAdbDevicesOutput(output);
|
|
expect(unparsedLines).toEqual([]);
|
|
expect(daemonStarted).toBe(false);
|
|
expect(devices).toEqual([
|
|
{
|
|
serial: "emulator-5554",
|
|
state: "device",
|
|
transport: "tcp",
|
|
product: "sdk_gphone64_x86_64",
|
|
model: "sdk_gphone64_x86_64",
|
|
device: "emu64xa",
|
|
transport_id: 1,
|
|
kind: "emulator",
|
|
kind_basis: "emulator_serial_prefix",
|
|
},
|
|
{
|
|
serial: "07ef9c2a",
|
|
state: "device",
|
|
transport: "usb",
|
|
product: "raven",
|
|
model: "Pixel_6_Pro",
|
|
device: "raven",
|
|
transport_id: 2,
|
|
kind: "physical",
|
|
kind_basis: "usb_transport",
|
|
},
|
|
{
|
|
serial: "192.168.1.42:5555",
|
|
state: "device",
|
|
transport: "tcp",
|
|
product: "mock",
|
|
model: "mock",
|
|
device: null,
|
|
transport_id: 3,
|
|
kind: "unknown",
|
|
kind_basis: "none",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("recognizes daemon startup notices instead of failing the listing", () => {
|
|
const output = [
|
|
"* daemon not running; starting now at tcp:5037",
|
|
"* daemon started successfully",
|
|
"List of devices attached",
|
|
"emulator-5554 device transport_id:1",
|
|
"",
|
|
].join("\n");
|
|
const { devices, daemonStarted } = parseAdbDevicesOutput(output);
|
|
expect(daemonStarted).toBe(true);
|
|
expect(devices).toHaveLength(1);
|
|
});
|
|
|
|
it("keeps non-device states such as unauthorized and offline", () => {
|
|
const output = [
|
|
"List of devices attached",
|
|
"emulator-5556 offline transport_id:2",
|
|
"HT84L0240512 unauthorized usb:1-2 transport_id:3",
|
|
"",
|
|
].join("\n");
|
|
const { devices } = parseAdbDevicesOutput(output);
|
|
expect(devices.map((device) => [device.serial, device.state])).toEqual([
|
|
["emulator-5556", "offline"],
|
|
["HT84L0240512", "unauthorized"],
|
|
]);
|
|
});
|
|
|
|
it("returns unrecognized lines instead of dropping them", () => {
|
|
const output = [
|
|
"List of devices attached",
|
|
"this is not a device line",
|
|
"",
|
|
].join("\n");
|
|
const { unparsedLines } = parseAdbDevicesOutput(output);
|
|
expect(unparsedLines).toEqual(["this is not a device line"]);
|
|
});
|
|
|
|
it("reports a non-numeric transport_id as unknown", () => {
|
|
const output = [
|
|
"List of devices attached",
|
|
"emulator-5554 device transport_id:not-a-number",
|
|
"",
|
|
].join("\n");
|
|
const { devices } = parseAdbDevicesOutput(output);
|
|
expect(devices[0]?.transport_id).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("parseGetpropOutput", () => {
|
|
it("projects the whitelisted build identity from a real dump", () => {
|
|
const dump = [
|
|
"[ro.build.version.release]: [14]",
|
|
"[ro.build.version.sdk]: [34]",
|
|
"[ro.build.version.security_patch]: [2024-06-05]",
|
|
"[ro.build.id]: [AP21.240617.014]",
|
|
"[ro.build.fingerprint]: [google/sdk_gphone64_x86_64/emu64xa:14/AP21.240617.014/12094027:userdebug/dev-keys]",
|
|
"[ro.product.brand]: [google]",
|
|
"[ro.product.device]: [emu64xa]",
|
|
"[ro.product.manufacturer]: [Google]",
|
|
"[ro.product.model]: [sdk_gphone64_x86_64]",
|
|
"[ro.product.cpu.abilist]: [x86_64,x86,arm64-v8a,armeabi-v7a,armeabi]",
|
|
"[ro.kernel.qemu]: [1]",
|
|
"[ro.debuggable]: [1]",
|
|
"[sys.unlisted.property]: [dropped]",
|
|
"",
|
|
].join("\n");
|
|
const { properties, emulatorObserved } = parseGetpropOutput(dump);
|
|
expect(emulatorObserved).toBe(true);
|
|
expect(properties).not.toContainEqual({
|
|
name: "sys.unlisted.property",
|
|
value: "dropped",
|
|
});
|
|
expect(properties).toEqual([
|
|
{ name: "ro.build.version.release", value: "14" },
|
|
{ name: "ro.build.version.sdk", value: "34" },
|
|
{ name: "ro.build.version.security_patch", value: "2024-06-05" },
|
|
{ name: "ro.build.id", value: "AP21.240617.014" },
|
|
{
|
|
name: "ro.build.fingerprint",
|
|
value:
|
|
"google/sdk_gphone64_x86_64/emu64xa:14/AP21.240617.014/12094027:userdebug/dev-keys",
|
|
},
|
|
{ name: "ro.debuggable", value: "1" },
|
|
{ name: "ro.kernel.qemu", value: "1" },
|
|
{ name: "ro.product.brand", value: "google" },
|
|
{ name: "ro.product.device", value: "emu64xa" },
|
|
{ name: "ro.product.manufacturer", value: "Google" },
|
|
{ name: "ro.product.model", value: "sdk_gphone64_x86_64" },
|
|
{
|
|
name: "ro.product.cpu.abilist",
|
|
value: "x86_64,x86,arm64-v8a,armeabi-v7a,armeabi",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("reports an explicit non-emulator build without inventing a value", () => {
|
|
const dump = [
|
|
"[ro.build.version.release]: [13]",
|
|
"[ro.kernel.qemu]: [0]",
|
|
"",
|
|
].join("\n");
|
|
const { emulatorObserved } = parseGetpropOutput(dump);
|
|
expect(emulatorObserved).toBe(false);
|
|
});
|
|
|
|
it("reports the emulator indicator unknown when the build omits it", () => {
|
|
const { emulatorObserved } = parseGetpropOutput(
|
|
"[ro.build.version.sdk]: [33]\n",
|
|
);
|
|
expect(emulatorObserved).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("parsePackageListOutput", () => {
|
|
it("parses package lines with their base APK device paths", () => {
|
|
const output = [
|
|
"package:/data/app/~~xy==/com.example.npzyCvVb1I-YzKIsGFvVYw==/base.apk=com.example",
|
|
"package:/system/app/WebView/WebView.apk=com.android.webview",
|
|
"",
|
|
].join("\n");
|
|
const { packages, unparsedLines } = parsePackageListOutput(output);
|
|
expect(unparsedLines).toEqual([]);
|
|
expect(packages).toEqual([
|
|
{
|
|
package_name: "com.example",
|
|
base_apk_device_path:
|
|
"/data/app/~~xy==/com.example.npzyCvVb1I-YzKIsGFvVYw==/base.apk",
|
|
},
|
|
{
|
|
package_name: "com.android.webview",
|
|
base_apk_device_path: "/system/app/WebView/WebView.apk",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("keeps unrecognized lines for partial coverage reporting", () => {
|
|
const { packages, unparsedLines } = parsePackageListOutput(
|
|
"package:/data/app/x/base.apk=com.ok\ngarbage line\n",
|
|
);
|
|
expect(packages).toHaveLength(1);
|
|
expect(unparsedLines).toEqual(["garbage line"]);
|
|
});
|
|
});
|
|
|
|
describe("parsePmPathOutput", () => {
|
|
it("classifies base and split APKs from their file names", () => {
|
|
const output = [
|
|
"package:/data/app/~~a==/com.example.b==/base.apk",
|
|
"package:/data/app/~~a==/com.example.b==/split_config.arm64_v8a.apk",
|
|
"package:/data/app/~~a==/com.example.b==/split_config.en.apk",
|
|
"",
|
|
].join("\n");
|
|
const { paths, unparsedLines } = parsePmPathOutput(output);
|
|
expect(unparsedLines).toEqual([]);
|
|
expect(paths).toEqual([
|
|
{
|
|
device_path: "/data/app/~~a==/com.example.b==/base.apk",
|
|
file_name: "base.apk",
|
|
role: "base",
|
|
},
|
|
{
|
|
device_path:
|
|
"/data/app/~~a==/com.example.b==/split_config.arm64_v8a.apk",
|
|
file_name: "split_config.arm64_v8a.apk",
|
|
role: "split",
|
|
},
|
|
{
|
|
device_path: "/data/app/~~a==/com.example.b==/split_config.en.apk",
|
|
file_name: "split_config.en.apk",
|
|
role: "split",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("reports an unknown role for names that follow neither convention", () => {
|
|
const { paths } = parsePmPathOutput("package:/system/app/X/Y.apk\n");
|
|
expect(paths[0]).toEqual({
|
|
device_path: "/system/app/X/Y.apk",
|
|
file_name: "Y.apk",
|
|
role: "unknown",
|
|
});
|
|
});
|
|
});
|