mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
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:
@@ -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()); }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
};
|
||||
@@ -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
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Vendored
+20
-7
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user