diff --git a/docs/adb-device-analysis.md b/docs/adb-device-analysis.md index 56c4d225fe..3921cbe746 100644 --- a/docs/adb-device-analysis.md +++ b/docs/adb-device-analysis.md @@ -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 diff --git a/src/adb/AdbProvider.test.ts b/src/adb/AdbProvider.test.ts index e3bc096037..dfe81d510c 100644 --- a/src/adb/AdbProvider.test.ts +++ b/src/adb/AdbProvider.test.ts @@ -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), { diff --git a/src/adb/AdbProvider.ts b/src/adb/AdbProvider.ts index 9fdca59419..59b5aba716 100644 --- a/src/adb/AdbProvider.ts +++ b/src/adb/AdbProvider.ts @@ -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, diff --git a/src/adb/AdbShellOutput.test.ts b/src/adb/AdbShellOutput.test.ts index 20182153e7..5a04d1de7d 100644 --- a/src/adb/AdbShellOutput.test.ts +++ b/src/adb/AdbShellOutput.test.ts @@ -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", () => { diff --git a/src/adb/AdbShellOutput.ts b/src/adb/AdbShellOutput.ts index 118dc05a29..51fd43d041 100644 --- a/src/adb/AdbShellOutput.ts +++ b/src/adb/AdbShellOutput.ts @@ -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 }; }; diff --git a/tests/boundary/cli/adbWindowFocus.test.ts b/tests/boundary/cli/adbWindowFocus.test.ts new file mode 100644 index 0000000000..d54163ceb7 --- /dev/null +++ b/tests/boundary/cli/adbWindowFocus.test.ts @@ -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]); + }, + ); +} diff --git a/tests/boundary/mcp/adbMcp.test.ts b/tests/boundary/mcp/adbMcp.test.ts index 61af5378fe..b629287e6b 100644 --- a/tests/boundary/mcp/adbMcp.test.ts +++ b/tests/boundary/mcp/adbMcp.test.ts @@ -196,7 +196,7 @@ const results: Readonly> = { 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,