diff --git a/bridge/android/ReaJadxBridge.java b/bridge/android/ReaJadxBridge.java index e57b24943a..c265929084 100644 --- a/bridge/android/ReaJadxBridge.java +++ b/bridge/android/ReaJadxBridge.java @@ -19,7 +19,6 @@ import kotlin.coroutines.EmptyCoroutineContext; import kotlinx.coroutines.BuildersKt; import kotlinx.io.CoreKt; import kotlinx.io.JvmCoreKt; -import kotlinx.serialization.json.JsonObject; /** REA-owned metadata boundary for the unmodified jadx-headless-mcp 0.7.1 JAR. */ class ReaJadxBridge { @@ -45,14 +44,14 @@ class ReaJadxBridge { private static CallToolResult json(Object value) { return text(JSON.toJson(value), false); } private static String string(JsonObject args, String name) { - JsonElement value = JsonParser.parseString(args.toString()).getAsJsonObject().get(name); + JsonElement value = args.get(name); if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) throw new IllegalArgumentException(name + " must be a string"); return value.getAsString(); } private static int integer(JsonObject args, String name, int fallback) { - JsonElement value = JsonParser.parseString(args.toString()).getAsJsonObject().get(name); + JsonElement value = args.get(name); if (value == null) return fallback; if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isNumber()) throw new IllegalArgumentException(name + " must be a nonnegative integer"); @@ -203,7 +202,8 @@ class ReaJadxBridge { return new DirectInvokes(sites, exhaustive[0]); } - private CallToolResult handle(String name, JsonObject args) { + private CallToolResult handle(String name, kotlinx.serialization.json.JsonObject input) { + JsonObject args = JsonParser.parseString(input.toString()).getAsJsonObject(); if (name.equals("rea_jvm_status")) return json(Map.of( "engine_reported_version", BuildInfo.VERSION, "max_heap_bytes", Runtime.getRuntime().maxMemory(), @@ -213,12 +213,25 @@ class ReaJadxBridge { JadxSession current = session(); if (name.equals("list_classes")) { List names = current.getClassFqns(); + String query = args.has("query") ? string(args, "query") : ""; int offset = integer(args, "offset", 0); int limit = integer(args, "limit", 200); int start = Math.min(offset, names.size()); - int end = (int) Math.min((long) start + limit, names.size()); - return json(Map.of("total", names.size(), "offset", offset, "limit", limit, - "items", names.subList(start, end))); + int end = start; + List items; + if (query.isEmpty()) { + end = (int) Math.min((long) start + limit, names.size()); + items = names.subList(start, end); + } else { + items = new ArrayList<>(); + while (end < names.size() && items.size() < limit) { + String nameInInventory = names.get(end++); + if (nameInInventory.contains(query)) items.add(nameInInventory); + } + } + return json(Map.of("total_classes", names.size(), "next_offset", end, + "scope", "top_level", "offset", offset, "limit", limit, + "items", items)); } ClassSnapshot cls = selected(args); if (name.equals("get_class_summary")) return json(cls.summary()); @@ -241,7 +254,7 @@ class ReaJadxBridge { if (name.equals("get_xrefs_to_class")) uses = cls.node().getUseIn(); else { List overloads = methods(cls, args); - if (overloads.size() != 1 && !args.containsKey("overload_index")) + if (overloads.size() != 1 && !args.has("overload_index")) throw new InputFailure("overload_index", "Select one of " + overloads.size() + " method overloads"); overloadIndex = integer(args, "overload_index", 0); if (overloadIndex >= overloads.size()) @@ -291,7 +304,7 @@ class ReaJadxBridge { 1048576, 0, 90000L, 1, List.of(), List.of(), 64, ResourceMode.FULL)); ServerCapabilities capabilities = new ServerCapabilities( new ServerCapabilities.Tools(null), null, null, null, null, null, null, Map.of()); - Server server = new Server(new Implementation("rea-jadx-bridge", "1", null, null, null), + Server server = new Server(new Implementation("rea-jadx-bridge", "2", null, null, null), new ServerOptions(capabilities), "", ignored -> Unit.INSTANCE); SessionToolsKt.registerSessionTools(server, holder); ManifestToolsKt.registerManifestTools(server, holder); @@ -305,7 +318,7 @@ class ReaJadxBridge { for (String name : new ArrayList<>(server.getTools().keySet())) if (!consumed.contains(name)) server.removeTool(name); Tool diagnostics = new Tool("rea_jvm_status", - new ToolSchema(null, new JsonObject(Map.of()), List.of(), null), + new ToolSchema(null, new kotlinx.serialization.json.JsonObject(Map.of()), List.of(), null), "Observed worker JVM and metadata scope", null, null, null, null, null, null); server.addTool(diagnostics, (connection, request, continuation) -> bridge.handle("rea_jvm_status", request.getArguments())); for (String name : List.of("list_classes", "get_class_summary", @@ -320,13 +333,16 @@ class ReaJadxBridge { }; Map properties = new LinkedHashMap<>(); for (String field : fields) properties.put(field, tool.getInputSchema().getProperties().get(field)); + if (name.equals("list_classes")) properties.put("query", + new kotlinx.serialization.json.JsonObject(Map.of( + "type", kotlinx.serialization.json.JsonElementKt.JsonPrimitive("string")))); String description = switch (name) { - case "list_classes" -> "Indexed pages of all top-level class names, with offset and limit."; + case "list_classes" -> "Pages of case-sensitive matching top-level class names; offset and next_offset are inventory positions, limit bounds matching rows. Omitted or empty query inventories all names."; case "get_class_summary" -> "Parsed member metadata for one exact class name; no source generation."; case "get_method_body" -> "Source for an exact class and stable same-name overload index; static initializers use smali to preserve folded field initialization."; default -> "Complete static using-node inventory for an exact class or selected method overload; one node may contain multiple invoke instructions, and source lines are unresolved."; }; - Tool narrowed = new Tool(name, new ToolSchema(null, new JsonObject(properties), + Tool narrowed = new Tool(name, new ToolSchema(null, new kotlinx.serialization.json.JsonObject(properties), tool.getInputSchema().getRequired(), null), description, null, null, null, null, null, null); server.addTool(narrowed, (connection, request, continuation) -> { try { return bridge.handle(name, request.getArguments()); } diff --git a/docs/android-analysis.md b/docs/android-analysis.md index 493eef7d0b..a382926b11 100644 --- a/docs/android-analysis.md +++ b/docs/android-analysis.md @@ -103,9 +103,10 @@ that every class or engine operation will load successfully. Discovery limits each ZIP metadata read to 8 MiB before allocation; a larger directory reports the exhausted read budget and the selected JAR path. -The current metadata bridge is verified on macOS arm64 with OpenJDK 21 and the -public Appium ApiDemos fixture. The POSIX adapter also supports Linux; the new -bridge has not yet undergone real verification on Linux/JDK 25. +The filtered class-page bridge (protocol 2) is verified on Linux x86-64 with +Temurin 21, public Appium ApiDemos 6.0.18 and NewPipe 0.29.1 APKs. Earlier +metadata-bridge records covered macOS arm64/OpenJDK 21 and Windows x64/JDK 21; +this protocol revision has not been rerun on those hosts or Linux/JDK 25. Windows x64 uses an owned Job Object with bidirectional stdio, atomic job assignment and kill-on-owner-close. Real Windows verification uses JDK 21 and the pinned JADX 0.7.1 JAR; see the real verification lane below. @@ -163,7 +164,10 @@ the same source byte limit and explicit truncation reporting. permissions and class/resource counts. Summary fields are extracted by the upstream engine and should be checked against the retained XML when needed. Signature verification is explicitly `not_performed`. -- Class search consumes all pages and checks their counts. An empty `query` +- Class search filters the engine's cached top-level names before transferring + pages, then follows contiguous inventory positions without rescanning earlier + rows. It checks selection, scope, examined ranges and counts before reporting + complete coverage. Complete producer replies stay in Evidence; an empty `query` inventories all class-name rows; class names are decompiler representations. Repeated names remain repeated in `matches` so the inventory row count and producer evidence stay visible. `duplicate_names` identifies matching names diff --git a/scripts/verify/android/analysis.mjs b/scripts/verify/android/analysis.mjs index 30024b557f..baf875545d 100644 --- a/scripts/verify/android/analysis.mjs +++ b/scripts/verify/android/analysis.mjs @@ -12,6 +12,7 @@ import { JADX_RELEASE } from "../../../dist/android/JadxRelease.js"; import fixture from "../../fixtures/android/apidemos.json" with { type: "json" }; import { verifyAndroidCliCancellation } from "./cli-cancellation.mjs"; import { verifyAndroidMetadata } from "./metadata.mjs"; +import { verifyAndroidClassSearch } from "./class-search.mjs"; import { verifyAndroidFailureRecovery } from "./failure-recovery.mjs"; import { resolveJadxConfigurationInputs, @@ -217,6 +218,7 @@ try { overloadedEvidence.normalized_result, ); console.log("PASS real overloaded method references through CLI/MCP"); + await verifyAndroidClassSearch({ client, path, sha256: fixture.sha256 }); } catch (cause) { process.stderr.write(stderr); throw cause; diff --git a/scripts/verify/android/class-search.mjs b/scripts/verify/android/class-search.mjs new file mode 100644 index 0000000000..1f5106f6a5 --- /dev/null +++ b/scripts/verify/android/class-search.mjs @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { parseEvidence } from "../../../dist/domain/evidence.js"; +import { requireMcpOperationResult } from "../../lib/mcp-verifier-results.mjs"; + +/** Compare native selection with an explicit full-inventory oracle on one live session. */ +export const verifyAndroidClassSearch = async ({ client, path, sha256 }) => { + const search = async (query) => { + const response = await client.callTool( + { name: "search_android_classes", arguments: { path, query } }, + { timeout: 150_000 }, + ); + const result = requireMcpOperationResult( + response, + "search_android_classes", + ); + const evidence = parseEvidence(response.structuredContent); + assert.equal(evidence.subject.digest.sha256, sha256); + assert.equal(evidence.parameters.query, query); + assert.equal(result.query, query); + return result; + }; + const full = await search(""); + assert.equal(full.matches.length, full.total_classes); + for (const query of [ + "ApiDemos", + "apidemos", + "[.*]", + "😀", + "日本語", + "\0", + "\ud800", + "\udc00", + ]) { + const selected = await search(query); + assert.equal(selected.coverage, "complete"); + assert.equal(selected.total_classes, full.total_classes); + assert.deepEqual( + selected.matches, + full.matches.filter((name) => name.includes(query)), + ); + } + console.log( + "PASS native class search matches full inventory, including literal and UTF-16 selectors", + ); +}; diff --git a/scripts/verify/android/metadata.mjs b/scripts/verify/android/metadata.mjs index a44da336c2..bb0295b59f 100644 --- a/scripts/verify/android/metadata.mjs +++ b/scripts/verify/android/metadata.mjs @@ -69,6 +69,10 @@ export const verifyAndroidMetadata = async ({ result.stdout, /METADATA_REGRESSION_PASS classes=\d+ overloads=3/u, ); + assert.match( + result.stdout, + /CLASS_SEARCH_REGRESSION_PASS pages=\d+ nonzero=true/u, + ); assert.match( result.stdout, /INITIALIZER_REGRESSION_PASS folded=2 bounded=true/u, @@ -80,6 +84,9 @@ export const verifyAndroidMetadata = async ({ console.log( "PASS real cold metadata, full generic types and stable overloaded method selection", ); + console.log( + "PASS real Java filtered pages, nonzero inventory cursors and complete combined results", + ); console.log( "PASS real folded and mixed static initializers, absent bodies and bounded smali", ); diff --git a/src/android/JadxAnalysis.ts b/src/android/JadxAnalysis.ts index 502c90a69a..244424b49e 100644 --- a/src/android/JadxAnalysis.ts +++ b/src/android/JadxAnalysis.ts @@ -136,56 +136,55 @@ const searchClasses = async ( loaded: z.infer, engine: Engine, ): Promise => { - const classes: string[] = []; + const matches: string[] = []; const pageSize = 4096; - let total: number | undefined; + let offset = 0; do { const page = jadxClassPageSchema.parse( await tools.json("list_classes", { - offset: classes.length, + query: request.input.query, + offset, limit: pageSize, }), ); if ( - (total !== undefined && total !== page.total) || - page.offset !== classes.length || + page.total_classes !== loaded.class_count || + page.offset !== offset || page.limit !== pageSize || - page.items.length > pageSize + page.next_offset < offset || + page.next_offset > loaded.class_count || + page.items.length > pageSize || + page.items.length > page.next_offset - offset || + (request.input.query === "" && + page.items.length !== page.next_offset - offset) || + page.items.some((name) => !name.includes(request.input.query)) ) throw new AnalysisOutputError( request.operation, "JADX class pagination changed identity or counts", ); - total = page.total; - classes.push(...page.items); if ( - classes.length > total || - (page.items.length === 0 && classes.length < total) + (offset < loaded.class_count && page.next_offset === offset) || + (page.next_offset < loaded.class_count && page.items.length !== pageSize) ) throw new AnalysisOutputError( request.operation, "JADX class pagination is incomplete", ); - } while (classes.length < total); - if (total !== loaded.class_count) - throw new AnalysisOutputError( - request.operation, - "JADX class inventory count differs from the loaded class count", - ); + matches.push(...page.items); + offset = page.next_offset; + } while (offset < loaded.class_count); const occurrences = new Map(); - for (const name of classes) + for (const name of matches) occurrences.set(name, (occurrences.get(name) ?? 0) + 1); - const matchingNames = classes.filter((name) => - name.includes(request.input.query), - ); const duplicateNames = [...occurrences] - .filter(([name, count]) => count > 1 && name.includes(request.input.query)) + .filter(([, count]) => count > 1) .map(([name, count]) => ({ name, occurrences: count })); return androidResultSchemas.search_android_classes.parse({ engine, query: request.input.query, - total_classes: total, - matches: matchingNames, + total_classes: loaded.class_count, + matches, duplicate_names: duplicateNames, coverage: "complete", }); diff --git a/src/android/JadxProtocol.ts b/src/android/JadxProtocol.ts index 22a457b0f4..f572ccf6b6 100644 --- a/src/android/JadxProtocol.ts +++ b/src/android/JadxProtocol.ts @@ -36,7 +36,9 @@ export const jadxAppSchema = z.object({ permissions: z.array(z.string()), }); export const jadxClassPageSchema = z.object({ - total: count, + scope: z.literal("top_level"), + total_classes: count, + next_offset: count, offset: count, limit: count, items: z.array(z.string()), diff --git a/src/android/JadxRelease.ts b/src/android/JadxRelease.ts index 5db3b3c993..fae641089b 100644 --- a/src/android/JadxRelease.ts +++ b/src/android/JadxRelease.ts @@ -9,7 +9,7 @@ export const JADX_RELEASE = Object.freeze({ /** Identity of REA's metadata bridge, distinct from the producing engine. */ export const JADX_BRIDGE_IDENTITY = Object.freeze({ name: "rea-jadx-bridge", - version: "1", + version: "2", }); /** Remediation when `REA_JADX_MCP_JAR` is missing or not an absolute path. */ diff --git a/src/application/android/AndroidSearchEvidenceSummary.ts b/src/application/android/AndroidSearchEvidenceSummary.ts index 42c0fb80f5..4db495ece1 100644 --- a/src/application/android/AndroidSearchEvidenceSummary.ts +++ b/src/application/android/AndroidSearchEvidenceSummary.ts @@ -5,11 +5,10 @@ import type { JsonValue } from "../../domain/jsonValue.js"; import { ok, type Result } from "../../domain/result.js"; /** - * Complete class-inventory pages dominate raw-call provenance for focused - * searches: one traversal embeds every examined class name regardless of the - * match count. The summary keeps the pagination audit inline (totals, page - * offsets and sizes, elided item counts) and links the retained record that - * still carries the complete inventory. + * Class-search pages duplicate matching names already present in normalized + * results; an empty query also returns the complete top-level inventory. + * Keep the selection and pagination audit inline and link the retained + * record carrying every complete producer reply. */ const elideInventoryText = (text: string): string | undefined => { let page: JsonValue; @@ -68,9 +67,8 @@ const summarizeInventoryCall = (call: JsonValue): JsonValue => { /** * Retain the complete search Evidence — full raw provider calls included — * in the session record, then deliver a bounded summary Evidence inline. - * Callers without a retaining session keep the complete inline delivery, so - * complete CLI JSON output is unchanged. Coverage metadata stays truthful in - * both copies: the examined inventory is never rewritten to matched-only. + * Callers without a retaining session receive complete source Evidence inline. + * Both copies preserve examined inventory ranges separately from matching rows. */ export const summarizeRetainedAndroidSearch = ( evidence: Evidence, diff --git a/src/contracts/android/androidToolContracts.ts b/src/contracts/android/androidToolContracts.ts index 2710e0faa8..5ae9373db2 100644 --- a/src/contracts/android/androidToolContracts.ts +++ b/src/contracts/android/androidToolContracts.ts @@ -36,7 +36,7 @@ export const ANDROID_TOOL_CONTRACTS = [ name: "search_android_classes", ...toolContractMetadata("search_android_classes"), description: - "Search complete decompiler class-name rows in an explicit local APK by case-sensitive substring. Empty query inventories all rows. Repeated names remain repeated and appear in duplicate_names; the provider accepts only class names for lookup, so these rows do not identify which same-name class node a later lookup selects. Retains artifact and engine identity; no APK execution.", + "Search complete top-level decompiler class-name rows in an explicit local APK by case-sensitive substring. Empty query inventories all rows. Repeated names remain repeated and appear in duplicate_names; the provider accepts only class names for lookup, so these rows do not identify which same-name class node a later lookup selects. Retains artifact and engine identity; no APK execution.", kind: "android-provider", inputSchema: androidInputSchemas.search_android_classes, outputSchema: evidenceResultOf(androidResultSchemas.search_android_classes), diff --git a/tests/boundary/android/JadxAnalysis.test.ts b/tests/boundary/android/JadxAnalysis.test.ts index 10d451cad9..550e6a6526 100644 --- a/tests/boundary/android/JadxAnalysis.test.ts +++ b/tests/boundary/android/JadxAnalysis.test.ts @@ -44,6 +44,148 @@ const engine = { heap_limit_mib: null, }; +it("pages filtered matches independently of the examined inventory and preserves cross-page duplicates", async () => { + const names = Array.from({ length: 5000 }, (_, i) => `fixture.Target${i}`); + names[0] = "fixture.TargetDuplicate"; + names[4096] = "fixture.TargetDuplicate"; + const calls: Record[] = []; + const tools: JadxToolPort = { + json: (name, input) => { + expect(name).toBe("list_classes"); + calls.push({ ...input }); + const offset = Number(input.offset); + return Promise.resolve({ + scope: "top_level", + total_classes: 10000, + next_offset: offset === 0 ? 8191 : 10000, + offset, + limit: 4096, + items: offset === 0 ? names.slice(0, 4096) : names.slice(4096), + }); + }, + text: () => Promise.reject(new Error("unexpected text call")), + }; + const result = await analyzeJadxRequest( + tools, + { + operation: "search_android_classes", + input: { path: "/fixture.apk", query: "Target" }, + }, + { + state: "LOADED", + apk_path: "/snapshot.apk", + class_count: 10000, + resource_count: 0, + threads: 1, + resources: "full", + }, + engine, + ); + expect(calls).toEqual([ + { query: "Target", offset: 0, limit: 4096 }, + { query: "Target", offset: 8191, limit: 4096 }, + ]); + expect(result).toMatchObject({ + total_classes: 10000, + matches: names, + duplicate_names: [{ name: "fixture.TargetDuplicate", occurrences: 2 }], + coverage: "complete", + }); +}); + +it.each([ + { name: "inventory scope", query: "Target", page: { scope: "all_dex" } }, + { name: "inventory total", query: "Target", page: { total_classes: 9 } }, + { name: "out-of-range cursor", query: "Target", page: { next_offset: 11 } }, + { + name: "impossible examined row count", + query: "Target", + page: { next_offset: 1, items: ["TargetA", "TargetB"] }, + }, + { name: "offset identity", query: "Target", page: { offset: 1 } }, + { name: "requested page size", query: "Target", page: { limit: 1 } }, + { name: "nonmatching row", query: "Target", page: { items: ["Other"] } }, + { + name: "non-progressing page", + query: "Target", + page: { next_offset: 0, items: [] }, + }, + { name: "premature page end", query: "Target", page: { next_offset: 9 } }, + { name: "empty-query completeness", query: "", page: {} }, +])( + "refuses changed $name instead of reporting complete search coverage", + async ({ query, page }) => { + const tools: JadxToolPort = { + json: () => + Promise.resolve({ + scope: "top_level", + total_classes: 10, + next_offset: 10, + offset: 0, + limit: 4096, + items: ["fixture.Target"], + ...page, + }), + text: () => Promise.reject(new Error("unexpected text call")), + }; + await expect( + analyzeJadxRequest( + tools, + { + operation: "search_android_classes", + input: { path: "/fixture.apk", query }, + }, + { + state: "LOADED", + apk_path: "/snapshot.apk", + class_count: 10, + resource_count: 0, + threads: 1, + resources: "full", + }, + engine, + ), + ).rejects.toThrow(); + }, +); + +it("accepts an examined empty inventory without requiring cursor progress", async () => { + const tools: JadxToolPort = { + json: () => + Promise.resolve({ + scope: "top_level", + total_classes: 0, + offset: 0, + next_offset: 0, + limit: 4096, + items: [], + }), + text: () => Promise.reject(new Error("unexpected text call")), + }; + const result = await analyzeJadxRequest( + tools, + { + operation: "search_android_classes", + input: { path: "/fixture.apk", query: "Target" }, + }, + { + state: "LOADED", + apk_path: "/snapshot.apk", + class_count: 0, + resource_count: 0, + threads: 1, + resources: "full", + }, + engine, + ); + expect(result).toMatchObject({ + total_classes: 0, + matches: [], + duplicate_names: [], + coverage: "complete", + }); +}); + it("requires explicit direct invoke coverage state in result evidence", () => { const result = { engine, diff --git a/tests/boundary/process/androidAnalysisMcp.test.ts b/tests/boundary/process/androidAnalysisMcp.test.ts index 14a301775e..117fb80cbd 100644 --- a/tests/boundary/process/androidAnalysisMcp.test.ts +++ b/tests/boundary/process/androidAnalysisMcp.test.ts @@ -5,6 +5,7 @@ import { expect, it as test, onTestFinished } from "vitest"; import { z } from "zod"; import { access } from "node:fs/promises"; import { ANDROID_TOOL_CONTRACTS } from "../../../src/contracts/android/androidToolContracts.js"; +import { JADX_BRIDGE_IDENTITY } from "../../../src/android/JadxRelease.js"; import { androidPartialObservationSchema } from "../../../src/domain/android/androidPartialObservation.js"; import { parseEvidence } from "../../../src/domain/evidence.js"; import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; @@ -157,7 +158,7 @@ test.each([ mode: "tool-error", errorCode: "execution_failure", serverName: "rea-jadx-bridge", - serverVersion: "1", + serverVersion: JADX_BRIDGE_IDENTITY.version, engineVersion: "0.7.1", calls: [ { operation: "rea_jvm_status" }, @@ -175,7 +176,7 @@ test.each([ mode: "rpc-disconnect", errorCode: "execution_failure", serverName: "rea-jadx-bridge", - serverVersion: "1", + serverVersion: JADX_BRIDGE_IDENTITY.version, engineVersion: "0.7.1", calls: [{ operation: "rea_jvm_status" }, { operation: "load_apk" }], containsAmbientEnvironment: true, @@ -239,7 +240,7 @@ test.each([ target: { selected_path: apk, path: apk, format: "apk" }, provider_facts: { bridge_name: "rea-jadx-bridge", - bridge_version: "1", + bridge_version: JADX_BRIDGE_IDENTITY.version, server_name: serverName, server_version: serverVersion, engine_version: engineVersion, diff --git a/tests/boundary/process/androidSearchEvidenceBudget.test.ts b/tests/boundary/process/androidSearchEvidenceBudget.test.ts index 143c72189d..7f5be8c0ac 100644 --- a/tests/boundary/process/androidSearchEvidenceBudget.test.ts +++ b/tests/boundary/process/androidSearchEvidenceBudget.test.ts @@ -82,7 +82,7 @@ test("delivers bounded search_android_classes Evidence while retaining the compl expect(singleRaw).not.toContain("fixture.Class"); expect(singleRaw).toContain("items_omitted"); expect(Buffer.byteLength(JSON.stringify(single))).toBeLessThan(32 * 1024); - // The complete inventory stays locally retrievable through the retained record. + // Every matching producer row remains in the retained source record. const completeId = single.evidence_links[0]; expect(completeId).toBeDefined(); expect(completeId).toBe( @@ -92,8 +92,25 @@ test("delivers bounded search_android_classes Evidence while retaining the compl const complete = session.evidenceById(completeId as string); expect(complete).toBeDefined(); expect(complete?.operation).toBe("search_android_classes"); - expect(rawInventoryNames(complete?.raw_result)).toBe(INVENTORY_CLASS_COUNT); - expect(JSON.stringify(complete?.raw_result)).toContain("fixture.Class7094"); + expect(rawInventoryNames(complete?.raw_result)).toBe(1); + expect(JSON.stringify(complete?.raw_result)).toContain("fixture.Target"); + expect(JSON.stringify(complete?.raw_result)).not.toContain("fixture.Class"); + + // Selection precedes paging: a match beyond the first unfiltered page is + // still found, and the complete inventory remains explicitly available. + const last = await search("Class7094"); + expect(last.normalized_result).toMatchObject({ + total_classes: INVENTORY_CLASS_COUNT, + matches: ["fixture.Class7094"], + coverage: "complete", + }); + const inventory = await search(""); + const inventorySource = session.evidenceById( + inventory.evidence_links[0] as string, + ); + expect(rawInventoryNames(inventorySource?.raw_result)).toBe( + INVENTORY_CLASS_COUNT, + ); const empty = await search("fixture.Missing"); expect( @@ -111,7 +128,7 @@ test("delivers bounded search_android_classes Evidence while retaining the compl await verifyJadxFixtureCleanup(launches); }); -test("keeps the complete search inventory inline without a retaining session", async () => { +test("keeps complete matching producer rows inline without a retaining session", async () => { const { service, apk, launches, provider } = await createJadxProtocolFixture( "normal", undefined, @@ -122,9 +139,10 @@ test("keeps the complete search inventory inline without a retaining session", a query: "Target", }); if (!result.ok) throw result.error; - // Complete CLI JSON output keeps the full raw provider inventory. - expect(rawInventoryNames(result.value.raw_result)).toBe( - INVENTORY_CLASS_COUNT, + // Complete CLI Evidence keeps every matching row, without unrelated names. + expect(rawInventoryNames(result.value.raw_result)).toBe(1); + expect(JSON.stringify(result.value.raw_result)).not.toContain( + "fixture.Class", ); await provider.close(); await verifyJadxFixtureCleanup(launches); diff --git a/tests/boundary/process/jadxIntegration.test.ts b/tests/boundary/process/jadxIntegration.test.ts index 774244ba6e..d8bd2d9f5c 100644 --- a/tests/boundary/process/jadxIntegration.test.ts +++ b/tests/boundary/process/jadxIntegration.test.ts @@ -451,19 +451,21 @@ it("retains APK identity, original input, raw producer data and normalized obser await verifyCleanup(launches); }); -it("fetches all class pages rather than silently retaining the first upstream page", async () => { - const { service, apk, launches, provider } = await setup(); +it("pages matching classes independently of the total APK class count", async () => { + const { service, apk, launches, provider } = await setup("large-inventory"); const outcome = await service.execute("search_android_classes", { path: apk, - query: "", + query: "Class", }); if (!outcome.ok) throw outcome.error; const result = androidResultSchemas.search_android_classes.parse( outcome.value.normalized_result, ); - expect(result.total_classes).toBe(401); - expect(result.matches).toHaveLength(401); - expect(result.matches.at(-1)).toBe("fixture.Class399"); + expect(result.total_classes).toBe(9001); + expect(result.matches).toHaveLength(9000); + expect(result.matches[0]).toBe("fixture.Class0"); + expect(result.matches[4096]).toBe("fixture.Class4096"); + expect(result.matches.at(-1)).toBe("fixture.Class8999"); await provider.close(); await verifyCleanup(launches); }); diff --git a/tests/conformance/android/ReaJadxMetadataRegression.java b/tests/conformance/android/ReaJadxMetadataRegression.java index 7e21dd4fc6..5509b51c00 100644 --- a/tests/conformance/android/ReaJadxMetadataRegression.java +++ b/tests/conformance/android/ReaJadxMetadataRegression.java @@ -42,6 +42,44 @@ public class ReaJadxMetadataRegression { return value; } + private static void verifyClassPages(Method handle, Object bridge, List inventory) + throws Exception { + int pages = 0; + boolean nonzeroOffset = false; + for (String query : List.of("", ".", "ApiDemos", "REA_ABSENT_83f1e89d")) { + int limit = query.equals("ApiDemos") ? 1 : 17; + List expected = inventory.stream().filter(name -> name.contains(query)).toList(); + List actual = new ArrayList<>(); + int offset = 0; + do { + JsonObject input = new JsonObject(); + input.addProperty("query", query); + input.addProperty("offset", offset); + input.addProperty("limit", limit); + JsonObject page = call(handle, bridge, "list_classes", input); + require(page.get("scope").getAsString().equals("top_level"), "Class scope changed"); + require(page.get("total_classes").getAsInt() == inventory.size(), "Inventory count changed"); + require(page.get("offset").getAsInt() == offset && page.get("limit").getAsInt() == limit, + "Page selection changed"); + int next = page.get("next_offset").getAsInt(); + require(next > offset && next <= inventory.size(), "Inventory cursor did not advance"); + List selected = new ArrayList<>(); + for (JsonElement name : page.getAsJsonArray("items")) selected.add(name.getAsString()); + require(selected.size() <= limit, "Matching page exceeded limit"); + require(selected.equals(inventory.subList(offset, next).stream() + .filter(name -> name.contains(query)).toList()), "Page omitted or reordered matching rows"); + require(next == inventory.size() || selected.size() == limit, "Page stopped before filling"); + actual.addAll(selected); + nonzeroOffset |= offset > 0; + offset = next; + pages++; + } while (offset < inventory.size()); + require(actual.equals(expected), "Combined class pages differ from the native inventory"); + } + require(nonzeroOffset, "Fixture did not exercise later native class pages"); + System.out.println("CLASS_SEARCH_REGRESSION_PASS pages=" + pages + " nonzero=true"); + } + private static Path initializerFixture(String sources, String output) throws Exception { Path directory = Path.of(output); SmaliOptions options = new SmaliOptions(); @@ -214,6 +252,7 @@ public class ReaJadxMetadataRegression { Object bridge = constructor.newInstance(holder); Method handle = bridgeType.getDeclaredMethod("handle", String.class, kotlinx.serialization.json.JsonObject.class); handle.setAccessible(true); + verifyClassPages(handle, bridge, session.getClassFqns()); JsonObject apiArgs = arguments("io.appium.android.apis.ApiDemos"); JsonObject apiSummary = call(handle, bridge, "get_class_summary", apiArgs); for (var entry : states.entrySet()) require(entry.getKey().getState() == entry.getValue(), diff --git a/tests/fixtures/android/jadx-mcp.mjs b/tests/fixtures/android/jadx-mcp.mjs index 8b37ae54dd..1a485cdcf1 100644 --- a/tests/fixtures/android/jadx-mcp.mjs +++ b/tests/fixtures/android/jadx-mcp.mjs @@ -1,6 +1,7 @@ import { McpServer } from "@modelcontextprotocol/server"; import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"; import { z } from "zod"; +import { JADX_BRIDGE_IDENTITY } from "../../../src/android/JadxRelease.ts"; // An actual child MCP server exercising the production stdio/process seam. // It is a protocol fixture, not evidence of JADX decompiler support. @@ -8,7 +9,11 @@ const mode = process.env.REA_FAKE_JADX_MODE ?? "normal"; const server = new McpServer({ name: mode === "empty-server-identity" ? "" : "rea-jadx-bridge", version: - mode === "empty-server-identity" ? "" : mode === "version" ? "99.0.0" : "1", + mode === "empty-server-identity" + ? "" + : mode === "version" + ? "99.0.0" + : JADX_BRIDGE_IDENTITY.version, }); const requestedCount = Number.parseInt( process.env.REA_FAKE_JADX_CLASS_COUNT ?? "", @@ -96,14 +101,22 @@ register("get_android_manifest", () => { if (mode === "large-manifest") return text("x".repeat(1024 * 1024)); return text(''); }); -register("list_classes", ({ offset, limit }) => - json({ - total: classes.length, +register("list_classes", ({ offset, limit, query = "" }) => { + const matching = []; + let nextOffset = offset; + while (nextOffset < classes.length && matching.length < limit) { + const name = classes[nextOffset++]; + if (name.includes(query)) matching.push(name); + } + return json({ + scope: "top_level", + total_classes: classes.length, + next_offset: nextOffset, offset, limit, - items: mode === "empty-page" ? [] : classes.slice(offset, offset + limit), - }), -); + items: mode === "empty-page" ? [] : matching, + }); +}); register("get_class_summary", () => json({ full_name: mode === "wrong-class" ? "fixture.Other" : "fixture.Target",