fix(adb): distinguish absent focus from missing declarations

Parse window focus into declaration records, normalize explicit null references once, and preserve original named lines in Evidence. Keep fallback based on declaration presence and retain unknown focus when both sections omit fields.

Validation: 86 focused tests including public CLI/MCP with advertised schemas, plus docs checks and five conformance tests. Fake ADB only; no physical device verified. Refs #4608
This commit is contained in:
morluto
2026-10-17 08:38:33 +08:00
parent bfb7ed0bd4
commit 3d028dbc74
7 changed files with 214 additions and 17 deletions
+6
View File
@@ -103,6 +103,12 @@ existing analysis families directly, for example
| `start_adb_activity` | **mutates device** | Starts an intent (`am start -a` with optional URI, component, and up to 16 typed extras); preserves the activity manager's verdict |
| `stop_adb_app` | **mutates device** | Force-stops every process of a package, ending a dynamic-analysis run |
For `inspect_adb_window`, an explicitly printed null focus reference becomes
JSON `null`. `observed_lines` retains the original focus declarations, including
their field names, so explicit absence can be distinguished from omitted
declarations. REA reads the displays section only when the windows section
declares neither focus field. If both sections omit them, focus remains unknown.
For `install_adb_package`, `replace_requested` records whether `-r` was sent.
The `replaced` result is `null`: the successful install response does not say
whether the package was installed fresh or replaced, and REA does not query the
+37 -2
View File
@@ -1316,8 +1316,8 @@ it("reads Android 16 focus declarations from displays when windows omits them",
focused_window: "Window{abc u0 com.example/.Main}",
focused_app: "ActivityRecord{def u0 com.example/.Main t1}",
observed_lines: [
"Window{abc u0 com.example/.Main}",
"ActivityRecord{def u0 com.example/.Main t1}",
"mCurrentFocus=Window{abc u0 com.example/.Main}",
"mFocusedApp=ActivityRecord{def u0 com.example/.Main t1}",
],
});
const execution = await providerOver(binary).execute({
@@ -1354,6 +1354,41 @@ it("describes missing window focus as unknown after both sections omit declarati
);
});
it.each(["windows", "displays"])(
"preserves explicitly absent focus from %s without another fallback",
async (section) => {
const declarations = " mCurrentFocus=null\n mFocusedApp=null\n";
const { binary } = await writeAdbStub(
shellStub({
"dumpsys:window:windows":
section === "windows" ? declarations : "no focus\n",
...(section === "displays"
? { "dumpsys:window:displays": declarations }
: {}),
}),
);
const execution = await providerOver(binary).execute({
operation: "inspect_adb_window",
input: { serial: "emulator-5554" },
});
expect(execution.ok).toBe(true);
if (!execution.ok) throw execution.error;
expect(execution.value.result).toMatchObject({
focused_window: null,
focused_app: null,
observed_lines: [" mCurrentFocus=null", " mFocusedApp=null"],
});
expect(execution.value.limitations).toContain(
`Focus declarations were read from \`dumpsys window ${section}\``,
);
expect(
execution.value.limitations.some((line) =>
line.includes("focus is unknown"),
),
).toBe(false);
},
);
it("reads one settings value and reports device-side nulls", async () => {
const { binary } = await writeAdbStub(shellStub({ "settings:get": "1\n" }));
const enabled = await evidenceResult(providerOver(binary), {
+4 -4
View File
@@ -1762,11 +1762,11 @@ export class AdbProvider {
{
client,
serial: request.input.serial,
focused_window: focusedWindow,
focused_app: focusedApp,
focused_window: focusedWindow?.value ?? null,
focused_app: focusedApp?.value ?? null,
observed_lines: [
...(focusedWindow === null ? [] : [focusedWindow]),
...(focusedApp === null ? [] : [focusedApp]),
...(focusedWindow === null ? [] : [focusedWindow.rawLine]),
...(focusedApp === null ? [] : [focusedApp.rawLine]),
],
},
provider,
+34 -1
View File
@@ -1,6 +1,39 @@
import { describe, expect, it } from "vitest";
import { parseAmStartOutput, parseWmDensityOutput } from "./AdbShellOutput.js";
import {
parseAmStartOutput,
parseWmDensityOutput,
parseWindowFocusOutput,
} from "./AdbShellOutput.js";
describe("parseWindowFocusOutput", () => {
it("separates missing declarations from explicit absence and retains source lines", () => {
expect(parseWindowFocusOutput("no declarations\n")).toEqual({
focusedWindow: null,
focusedApp: null,
});
expect(
parseWindowFocusOutput(" mCurrentFocus=null\nmFocusedApp=null\n"),
).toEqual({
focusedWindow: { value: null, rawLine: " mCurrentFocus=null" },
focusedApp: { value: null, rawLine: "mFocusedApp=null" },
});
});
it("does not reinterpret later declarations after the first explicitly absent one", () => {
expect(
parseWindowFocusOutput(
"mCurrentFocus=null\nmCurrentFocus=Window{later}\nmFocusedApp=ActivityRecord{current}\n",
),
).toEqual({
focusedWindow: { value: null, rawLine: "mCurrentFocus=null" },
focusedApp: {
value: "ActivityRecord{current}",
rawLine: "mFocusedApp=ActivityRecord{current}",
},
});
});
});
describe("parseWmDensityOutput", () => {
it("reads labeled physical and override densities", () => {
+18 -9
View File
@@ -181,21 +181,30 @@ export const parseWmDensityOutput = (
return { physical: physical ?? bare, override };
};
/** Parse focus declarations from `dumpsys window windows` or `displays`. */
interface WindowFocusDeclaration {
readonly value: string | null;
readonly rawLine: string;
}
/** A missing declaration differs from a declared null object reference. */
export const parseWindowFocusOutput = (
stdout: string,
): {
focusedWindow: string | null;
focusedApp: string | null;
focusedWindow: WindowFocusDeclaration | null;
focusedApp: WindowFocusDeclaration | null;
} => {
let focusedWindow = null;
let focusedApp = null;
let focusedWindow: WindowFocusDeclaration | null = null;
let focusedApp: WindowFocusDeclaration | null = 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();
if (focusedWindow === null && trimmed.startsWith("mCurrentFocus=")) {
const value = trimmed.slice("mCurrentFocus=".length).trim();
focusedWindow = { value: value === "null" ? null : value, rawLine: line };
}
if (focusedApp === null && trimmed.startsWith("mFocusedApp=")) {
const value = trimmed.slice("mFocusedApp=".length).trim();
focusedApp = { value: value === "null" ? null : value, rawLine: line };
}
}
return { focusedWindow, focusedApp };
};
+114
View File
@@ -0,0 +1,114 @@
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, onTestFinished } from "vitest";
import { AdbProvider } from "../../../src/adb/AdbProvider.js";
import { toolContract } from "../../../src/contracts/toolContracts.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js";
import { advertisedMcpToolSchemas } from "../../fixtures/advertisedMcpTool.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
for (const { serial, section, focused, missing } of [
{ serial: "null-device", section: "windows", focused: false, missing: false },
{
serial: "fallback-null-device",
section: "displays",
focused: false,
missing: false,
},
{
serial: "focused-device",
section: "windows",
focused: true,
missing: false,
},
{
serial: "missing-device",
section: "displays",
focused: false,
missing: true,
},
]) {
cliTest.skipIf(process.platform === "win32")(
`preserves focus presence through CLI and MCP: ${serial}`,
async ({ cli }) => {
const root = await createTestTempDirectory("rea-adb-window-focus-");
const binary = join(root, "adb");
await writeFile(
binary,
`#!/bin/sh
if [ "$1" = version ]; then
printf 'Android Debug Bridge version 1.0.41\\nVersion 34.0.5-fixture\\nInstalled as %s\\n' "$0"
exit 0
fi
if [ "$1" = -s ] && [ "$3" = shell ] && [ "$4" = dumpsys ] && [ "$5" = window ]; then
printf '%s\\n' "$6" >> "$0.calls"
if [ "$2" = missing-device ]; then printf 'no focus declarations\\n'; exit 0; fi
if [ "$2" = fallback-null-device ] && [ "$6" = windows ]; then printf 'no focus declarations\\n'; exit 0; fi
if [ "$2" = focused-device ]; then
printf ' mCurrentFocus=Window{current}\\n mFocusedApp=ActivityRecord{current}\\n'
else
printf ' mCurrentFocus=null\\n mFocusedApp=null\\n'
fi
exit 0
fi
exit 1
`,
{ mode: 0o755 },
);
const output = await cli.run({
arguments: ["inspect-adb-window", serial, "--json"],
environment: { REA_ADB_PATH: binary },
});
expect(output.exitCode).toBe(0);
const provider = new AdbProvider({
environment: { REA_ADB_PATH: binary },
});
const harness = await createApplicationMcpHarness({
serverOptions: { adbDeviceAnalysis: provider },
});
onTestFinished(() => harness.close());
const schemas = await advertisedMcpToolSchemas(
harness.client,
"inspect_adb_window",
);
const args = { serial };
expect(schemas.ajv.validate(schemas.inputSchema, args)).toBe(true);
const response = await harness.client.callTool({
name: schemas.name,
arguments: args,
});
expect(response.isError).not.toBe(true);
expect(
schemas.ajv.validate(schemas.outputSchema, response.structuredContent),
).toBe(true);
for (const raw of [output.json, response.structuredContent]) {
const evidence = parseEvidence(
toolContract("inspect_adb_window").outputSchema.parse(raw),
);
expect(evidence.normalized_result).toMatchObject({
focused_window: focused ? "Window{current}" : null,
focused_app: focused ? "ActivityRecord{current}" : null,
observed_lines: missing
? []
: [
` mCurrentFocus=${focused ? "Window{current}" : "null"}`,
` mFocusedApp=${focused ? "ActivityRecord{current}" : "null"}`,
],
});
expect(evidence.limitations).toContainEqual(
missing
? expect.stringContaining("focus is unknown, not absent")
: `Focus declarations were read from \`dumpsys window ${section}\``,
);
}
const sections =
section === "windows" ? ["windows"] : ["windows", "displays"];
expect(
(await readFile(`${binary}.calls`, "utf8")).trim().split("\n"),
).toEqual([...sections, ...sections]);
},
);
}
+1 -1
View File
@@ -196,7 +196,7 @@ const results: Readonly<Record<string, unknown>> = {
serial: "emulator-5554",
focused_window: "Window{abcdef u0 com.example.app/Main}",
focused_app: null,
observed_lines: ["Window{abcdef u0 com.example.app/Main}"],
observed_lines: ["mCurrentFocus=Window{abcdef u0 com.example.app/Main}"],
},
read_adb_setting: {
client: clientIdentity,