perf(android): filter class pages before bridge serialization (#4791)

Filter class names at the native owner before serialization, and resume matching pages through contiguous inventory positions without a second index or query-result cache. Preserve selected source Evidence, query identity, complete top-level coverage and shared CLI/MCP behavior.

Fixes #4776.

Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
This commit is contained in:
Ysopwnable
2026-10-17 21:21:37 +08:00
committed by GitHub
parent 060eb43b54
commit 2d84c800bc
16 changed files with 361 additions and 73 deletions
+28 -12
View File
@@ -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<String> 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<String> 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<MethodSnapshot> 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<String, kotlinx.serialization.json.JsonElement> 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()); }
+8 -4
View File
@@ -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
+2
View File
@@ -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;
+45
View File
@@ -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",
);
};
+7
View File
@@ -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",
);
+22 -23
View File
@@ -136,56 +136,55 @@ const searchClasses = async (
loaded: z.infer<typeof jadxLoadSchema>,
engine: Engine,
): Promise<JsonValue> => {
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<string, number>();
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",
});
+3 -1
View File
@@ -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()),
+1 -1
View File
@@ -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. */
@@ -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,
@@ -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),
+142
View File
@@ -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<string, unknown>[] = [];
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,
@@ -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,
@@ -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);
@@ -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);
});
@@ -42,6 +42,44 @@ public class ReaJadxMetadataRegression {
return value;
}
private static void verifyClassPages(Method handle, Object bridge, List<String> 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<String> expected = inventory.stream().filter(name -> name.contains(query)).toList();
List<String> 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<String> 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(),
+20 -7
View File
@@ -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('<manifest package="fixture"/>');
});
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",