feat(android): expose exact DEX descriptors and direct invoke sites (#2439)

* feat(android): expose precise method references and DEX invoke sites

* fix(android): bound invoke evidence without losing reference inventory

* perf(android): skip invoke admission for class references

---------

Co-authored-by: LcokHcak <268763621+LcokHcak@users.noreply.github.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
LcokHcak 2026-10-13 23:02:47 +09:00 committed by GitHub
parent ce748be2f3
commit c45d912c91
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 870 additions and 38 deletions

View File

@ -3,8 +3,14 @@ import com.google.gson.*;
import io.modelcontextprotocol.kotlin.sdk.server.*;
import io.modelcontextprotocol.kotlin.sdk.types.*;
import jadx.api.*;
import jadx.api.plugins.input.data.ICodeReader;
import jadx.api.plugins.input.data.IMethodRef;
import jadx.api.plugins.input.insns.InsnIndexType;
import jadx.api.plugins.input.insns.Opcode;
import jadx.core.dex.info.MethodInfo;
import jadx.core.dex.nodes.*;
import java.io.PrintStream;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.CountDownLatch;
import java.util.stream.Collectors;
@ -79,11 +85,17 @@ class ReaJadxBridge {
ordered.sort(Comparator.comparing(MethodNode::getAlias));
List<MethodSnapshot> methods = ordered.stream().map(method -> {
String name = method.getAlias();
String shortId = method.getMethodInfo().getShortId();
String rawName = method.getMethodInfo().getName();
if (!shortId.startsWith(rawName + "("))
throw new IllegalStateException("JADX method ID has no raw-name prefix: " + shortId);
String descriptor = shortId.substring(rawName.length());
String signature = name + "(" + method.getArgTypes().stream()
.map(Object::toString).collect(Collectors.joining(", "))
+ "): " + method.getReturnType().toString();
return new MethodSnapshot(name, method.getMethodInfo().getShortId(), Map.of(
return new MethodSnapshot(name, shortId, Map.of(
"name", name, "signature", signature,
"dex_descriptor", descriptor,
"is_constructor", method.isConstructor(), "def_pos", 0));
}).toList();
List<Map<String, Object>> fields = node.getFields().stream()
@ -140,6 +152,57 @@ class ReaJadxBridge {
"smali", true, List.of("static_initializer_disassembly"));
}
// The local transport admits 8 MiB frames. Reserve 1 KiB for the fixed
// JSON-RPC/CallToolResult envelope and generated numeric request id, and
// account for JSON text being escaped again inside TextContent.
private static final int MAX_XREF_TEXT_BYTES = 8 * 1024 * 1024 - 1024;
private static int escapedJsonBytes(Object value) {
return JSON.toJson(JSON.toJson(value)).getBytes(StandardCharsets.UTF_8).length;
}
private static final class InvokeBudget {
long remaining;
InvokeBudget(long remaining) { this.remaining = Math.max(0, remaining); }
boolean admit(Map<String, Object> site) {
// Check a conservative two-layer escape bound before serialization.
long bound = 256;
for (Object value : site.values())
if (value instanceof String text) bound += 12L * text.length();
if (bound > remaining) return false;
long bytes = escapedJsonBytes(site) - 2L + 1L;
if (bytes > remaining) return false;
remaining -= bytes;
return true;
}
}
private record DirectInvokes(List<Map<String, Object>> sites, boolean exhaustive) {}
/** Raw DEX invokes within one JADX using method; offsets are code units from its code start. */
private static DirectInvokes directInvokes(JavaMethod source, MethodInfo target, InvokeBudget budget) {
MethodNode node = source.getMethodNode();
ICodeReader code = node.getCodeReader();
if (code == null) return null;
List<Map<String, Object>> sites = new ArrayList<>();
boolean[] exhaustive = {true};
code.visitInstructions(insn -> {
insn.decode();
if (insn.getIndexType() != InsnIndexType.METHOD_REF) return;
Opcode opcode = insn.getOpcode();
if (!opcode.name().startsWith("INVOKE_")) return;
IMethodRef ref = insn.getIndexAsMethod();
ref.load();
if (!MethodInfo.fromRef(node.root(), ref).getRawFullId().equals(target.getRawFullId())) return;
Map<String, Object> site = Map.of(
"dex_entry", node.getInputFileName(),
"source_raw_method_id", node.getMethodInfo().getRawFullId(),
"dex_code_unit_offset", insn.getOffset(),
"dex_method_index", insn.getIndex(),
"opcode", opcode.name());
if (budget.admit(site)) sites.add(site);
else exhaustive[0] = false;
});
return new DirectInvokes(sites, exhaustive[0]);
}
private CallToolResult handle(String name, JsonObject args) {
if (name.equals("rea_jvm_status")) return json(Map.of(
"engine_reported_version", BuildInfo.VERSION,
@ -173,17 +236,52 @@ class ReaJadxBridge {
}
List<JavaNode> uses;
String target = string(args, "class_name");
MethodSnapshot selectedMethod = null;
int overloadIndex = 0;
if (name.equals("get_xrefs_to_class")) uses = cls.node().getUseIn();
else {
List<MethodSnapshot> overloads = methods(cls, args);
if (overloads.size() != 1) throw new InputFailure("method_name", "Method references require a unique overload");
target += "." + overloads.get(0).name();
uses = method(cls, overloads.get(0)).getUseIn();
if (overloads.size() != 1 && !args.containsKey("overload_index"))
throw new InputFailure("overload_index", "Select one of " + overloads.size() + " method overloads");
overloadIndex = integer(args, "overload_index", 0);
if (overloadIndex >= overloads.size())
throw new InputFailure("overload_index", "overload_index out of range: " + overloadIndex);
selectedMethod = overloads.get(overloadIndex);
target += "." + selectedMethod.name();
uses = method(cls, selectedMethod).getUseIn();
}
// REA consumes all references without materializing source line information.
List<Map<String, Object>> items = uses.stream().map(node -> current.describeUsage(node, false)).toList();
return json(Map.of("target", target, "total", uses.size(), "count", items.size(),
"resolve_line", false, "items", items));
MethodInfo targetInfo = selectedMethod == null ? null : method(cls, selectedMethod).getMethodNode().getMethodInfo();
List<Map<String, Object>> items = uses.stream().map(node -> {
Map<String, Object> item = new LinkedHashMap<>(current.describeUsage(node, false));
if (targetInfo != null && node instanceof JavaMethod) {
item.put("direct_invokes", List.of());
item.put("direct_invokes_exhaustive", false);
}
return item;
}).toList();
Map<String, Object> result = new LinkedHashMap<>(Map.of("target", target,
"total", uses.size(), "count", items.size(), "resolve_line", false, "items", items));
if (selectedMethod != null) {
result.put("overload_index", overloadIndex);
result.put("reported_signature", selectedMethod.summary().get("signature"));
}
if (targetInfo != null) {
InvokeBudget budget = new InvokeBudget(MAX_XREF_TEXT_BYTES - escapedJsonBytes(result));
for (int index = 0; index < uses.size(); index++) {
if (!(uses.get(index) instanceof JavaMethod source)) continue;
DirectInvokes invokes = directInvokes(source, targetInfo, budget);
Map<String, Object> item = items.get(index);
if (invokes == null) {
item.remove("direct_invokes");
item.remove("direct_invokes_exhaustive");
} else {
item.put("direct_invokes", invokes.sites());
item.put("direct_invokes_exhaustive", invokes.exhaustive());
}
}
}
return json(result);
}
public static void main(String[] args) throws Exception {
@ -217,7 +315,7 @@ class ReaJadxBridge {
List<String> fields = switch (name) {
case "list_classes" -> List.of("offset", "limit");
case "get_method_body" -> List.of("class_name", "method_name", "overload_index");
case "get_xrefs_to_method" -> List.of("class_name", "method_name");
case "get_xrefs_to_method" -> List.of("class_name", "method_name", "overload_index");
default -> List.of("class_name");
};
Map<String, kotlinx.serialization.json.JsonElement> properties = new LinkedHashMap<>();
@ -226,7 +324,7 @@ class ReaJadxBridge {
case "list_classes" -> "Indexed pages of all top-level class names, with offset and limit.";
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 references to an exact class or unique method; source lines are unresolved.";
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.getInputSchema().getRequired(), null), description, null, null, null, null, null, null);

View File

@ -86,6 +86,7 @@ rea inspect-android-method /path/Example.apk example.MainActivity onCreate
rea inspect-android-method /path/Example.apk example.MainActivity select --overload-index 1
rea trace-android-references /path/Example.apk example.MainActivity
rea trace-android-references /path/Example.apk example.MainActivity --method-name onCreate
rea trace-android-references /path/Example.apk example.MainActivity --method-name select --overload-index 1
```
Equivalent MCP methods are `inspect_android_package`, `search_android_classes`,
@ -106,9 +107,10 @@ Every request includes `path`. Class/method operations use `class_name`,
```
Use `inspect_android_class` to choose an overload. REA rejects an ambiguous
method request rather than silently selecting the first match. The engine's
method reference operation cannot select overloads, so REA reports that boundary
as unsupported when multiple same-name methods exist.
method request rather than silently selecting the first match. Method reference
queries accept the same `overload_index`; the bridge resolves the selected
method node and the result reports its index and displayed signature. These
indices are tied to the current artifact and engine, not stable across builds.
The engine's smali fallback also joins same-name overloads; REA rejects that
fallback for an overloaded method instead of attributing all bodies to one
@ -137,14 +139,28 @@ the same source byte limit and explicit truncation reporting.
full reported display types, fields and inner class names. Synthetic members
that code generation hides can appear in the inventory; it stays consistent
after method decompilation within the retained session.
Exact DEX descriptors are unknown. Overload indices are tied to this artifact
and engine, not stable identities across different builds.
Exact DEX prototypes are reported from frozen JADX method metadata as
`dex_descriptor`, for example `(I)V`. Display names can be JADX aliases;
the descriptor alone is not a complete raw DEX method ID. Overload indices
are tied to this artifact and engine, not stable identities across builds.
- Method inspection reports Java or smali, fallback markers and complete/partial
returned text. Decompiled source is a derived representation. A complete text
result does not establish complete semantics or a byte-identical reconstruction.
- Reference results are incoming static relationships between provider nodes.
Source lines and DEX instruction offsets remain unknown. They are not observed
runtime calls.
`reference_unit=using_node` means `total` counts containing JADX nodes, not
individual invoke instructions; two calls from one method can appear as one
entry. `coverage=complete` covers this node inventory only. For a method
reference whose using node has raw code, `direct_invokes` lists matching DEX
invoke instructions with their DEX entry, raw source method ID, code-unit
offset, method index and opcode. An empty array means no direct invoke was
matched in that node; `null` means raw code was unavailable or the using node
was not a method. This list does not establish complete call-site coverage
outside JADX's using-node inventory or through reflection and dynamic loading.
A virtual or interface invoke also does not establish its runtime implementation.
Source lines and the containing node's own `dex_offset` remain unknown.
Method-reference results also carry the selected `dex_descriptor`;
class-reference results report `null`.
These relationships are not observed runtime calls.
- Original requests, APK SHA-256, actual engine JAR SHA-256 and raw producer
responses remain in Evidence. Source revision is populated only for bytes
matching the audited release JAR.
@ -284,3 +300,9 @@ refuses to overwrite a different existing file. APK/JAR files live under ignored
`_reference/` and are excluded from package contents. The lane exercises actual
REA CLI and MCP results with the real engine, separately from synthetic protocol
regressions. It needs no Gradle build, emulator, Android SDK or Ghidra.
Direct invoke sites retain exact DEX code-unit offsets separately from each
containing node's unknown `dex_offset`. `direct_invokes_exhaustive: false`
means instruction rows were bounded by the local bridge frame budget; `null`
means the producer did not establish instruction coverage. Using-node totals
and coverage do not imply complete instruction evidence.

View File

@ -173,6 +173,50 @@ try {
assert.deepEqual(structured.normalized_result, evidence.normalized_result);
console.log(`PASS MCP/CLI parity ${task.name}`);
}
const overloadedInput = {
path,
class_name: "androidx.activity.ComponentActivity",
method_name: "setContentView",
overload_index: 0,
};
const overloadedCli = await execute(
process.execPath,
[
entrypoint,
"trace-android-references",
path,
overloadedInput.class_name,
"--method-name",
overloadedInput.method_name,
"--overload-index",
"0",
"--format",
"json",
],
{
cwd: repository,
env: environment,
timeout: 150_000,
maxBuffer: 8 * 1024 * 1024,
},
);
const overloadedEvidence = parseEvidence(JSON.parse(overloadedCli.stdout));
assert.equal(overloadedEvidence.normalized_result.overload_index, 0);
assert.equal(
overloadedEvidence.normalized_result.reported_signature,
"setContentView(int): void",
);
assert.equal(overloadedEvidence.normalized_result.total, 1);
const overloadedMcp = await client.callTool(
{ name: "trace_android_references", arguments: overloadedInput },
{ timeout: 150_000 },
);
assert.notEqual(overloadedMcp.isError, true, JSON.stringify(overloadedMcp));
assert.deepEqual(
parseEvidence(overloadedMcp.structuredContent).normalized_result,
overloadedEvidence.normalized_result,
);
console.log("PASS real overloaded method references through CLI/MCP");
} catch (cause) {
process.stderr.write(stderr);
throw cause;
@ -205,7 +249,7 @@ assert.equal(method.body_status, "available");
assert.equal(method.source.status, "complete");
assert.ok(method.source.text.includes("getIntent()"));
assert.ok(method.source.text.includes("setListAdapter"));
assert.equal(method.method.dex_descriptor, null);
assert.equal(method.method.dex_descriptor, "(Landroid/os/Bundle;)V");
const references = cliResults.get("trace_android_references").normalized_result;
assert.ok(references.total > 0);
assert.ok(

View File

@ -0,0 +1,112 @@
import assert from "node:assert/strict";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { parseEvidence } from "../../../dist/domain/evidence.js";
import { hashAndroidFile } from "./digest.mjs";
/** The public result keeps one using node and exposes its two raw DEX invokes. */
export const verifyAndroidDirectInvokes = async ({
apk,
dexEntry,
repository,
entrypoint,
environment,
execute,
}) => {
const digest = await hashAndroidFile(apk);
const check = (value) => {
const evidence = parseEvidence(value);
assert.equal(evidence.subject.digest.sha256, digest);
assert.equal(evidence.operation, "trace_android_references");
const result = evidence.normalized_result;
assert.equal(result.class_name, "fixture.Target");
assert.equal(result.method_name, "mark");
assert.equal(result.dex_descriptor, "()V");
assert.equal(result.reference_unit, "using_node");
assert.equal(result.total, 1);
assert.equal(result.references.length, 1);
const source = result.references[0];
assert.equal(source.reported_full_name, "fixture.Caller.twice");
assert.deepEqual(
source.direct_invokes.map((site) => site.dex_code_unit_offset),
[0, 3],
);
for (const site of source.direct_invokes) {
assert.equal(site.dex_entry, dexEntry);
assert.equal(site.source_raw_method_id, "fixture.Caller.twice()V");
assert.equal(site.opcode, "INVOKE_STATIC");
}
assert.equal(
source.direct_invokes[0].dex_method_index,
source.direct_invokes[1].dex_method_index,
);
return evidence;
};
const cliResponse = await execute(
process.execPath,
[
entrypoint,
"trace-android-references",
apk,
"fixture.Target",
"--method-name",
"mark",
"--format",
"json",
],
{
cwd: repository,
env: environment,
timeout: 150_000,
maxBuffer: 8 * 1024 * 1024,
},
);
const cli = check(JSON.parse(cliResponse.stdout));
const transport = new StdioClientTransport({
command: process.execPath,
args: [entrypoint, "mcp"],
cwd: repository,
env: environment,
stderr: "pipe",
});
let stderr = "";
transport.stderr?.on("data", (chunk) => {
stderr += chunk.toString();
});
const client = new Client({
name: "rea-real-android-direct-invokes",
version: "1",
});
try {
await client.connect(transport, { timeout: 30_000 });
const response = await client.callTool(
{
name: "trace_android_references",
arguments: {
path: apk,
class_name: "fixture.Target",
method_name: "mark",
},
},
{ timeout: 150_000 },
);
assert.notEqual(response.isError, true, JSON.stringify(response));
const mcp = check(response.structuredContent);
assert.deepEqual(mcp.normalized_result, cli.normalized_result);
assert.deepEqual(mcp.parameters, cli.parameters);
assert.deepEqual(mcp.provider, cli.provider);
} catch (cause) {
process.stderr.write(stderr);
throw cause;
} finally {
await client.close();
}
assert.equal(
await hashAndroidFile(apk),
digest,
"Static inspection changed fixture bytes",
);
console.log(
`PASS real CLI/MCP direct-invoke offsets from ${dexEntry}, parity and unchanged APK`,
);
};

View File

@ -3,6 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { delimiter, join, resolve } from "node:path";
import { verifyAndroidInitializers } from "./initializers.mjs";
import { verifyAndroidDirectInvokes } from "./direct-invokes.mjs";
/** Prove cold metadata stays unmaterialized and overload identities survive code generation. */
export const verifyAndroidMetadata = async ({
@ -60,6 +61,7 @@ export const verifyAndroidMetadata = async ({
apk,
resolve(repository, "tests/fixtures/android/static-initializers"),
classes,
resolve(repository, "tests/fixtures/android/direct-invokes"),
],
{ timeout: 120_000, maxBuffer: 8 * 1024 * 1024 },
);
@ -71,12 +73,19 @@ export const verifyAndroidMetadata = async ({
result.stdout,
/INITIALIZER_REGRESSION_PASS folded=2 bounded=true/u,
);
assert.match(
result.stdout,
/DIRECT_INVOKE_REGRESSION_PASS nodes=1 offsets=0,3 cross_dex=true/u,
);
console.log(
"PASS real cold metadata, full generic types and stable overloaded method selection",
);
console.log(
"PASS real folded and mixed static initializers, absent bodies and bounded smali",
);
console.log(
"PASS real raw DEX direct-invoke offsets and cross-DEX source identity",
);
await verifyAndroidInitializers({
apk: resolve(classes, "initializers.apk"),
repository,
@ -84,6 +93,22 @@ export const verifyAndroidMetadata = async ({
environment,
execute,
});
await verifyAndroidDirectInvokes({
apk: resolve(classes, "direct-invokes.apk"),
dexEntry: "classes.dex",
repository,
entrypoint,
environment,
execute,
});
await verifyAndroidDirectInvokes({
apk: resolve(classes, "direct-invokes-multidex.apk"),
dexEntry: "classes2.dex",
repository,
entrypoint,
environment,
execute,
});
} finally {
await rm(classes, { recursive: true, force: true });
}

View File

@ -45,7 +45,7 @@ const methodsOf = (summary: ClassSummary) => {
reported_signature: method.signature,
overload_index: index,
is_constructor: method.is_constructor,
dex_descriptor: null,
dex_descriptor: method.dex_descriptor,
};
});
};
@ -314,11 +314,25 @@ const traceReferences = async (
): Promise<JsonValue> => {
const methodName = request.input.method_name;
const candidates = matchingMethods(summary, request);
if (candidates.length > 1)
throw new AnalysisCapabilityUnavailableError(
"jadx",
request.operation,
`References require a unique method name; ${summary.full_name}.${methodName} has ${candidates.length} overloads. No unambiguous reference result is available.`,
if (methodName === undefined && request.input.overload_index !== undefined)
inputFailure(
request,
"overload_index",
"Method name is required for an overload index.",
);
if (candidates.length > 1 && request.input.overload_index === undefined)
inputFailure(
request,
"overload_index",
`Select one of ${candidates.length} overloads: ${candidates.map((method) => `${method.overload_index}: ${method.reported_signature}`).join("; ")}`,
);
const index = request.input.overload_index ?? 0;
const selected = methodName === undefined ? undefined : candidates[index];
if (methodName !== undefined && selected === undefined)
inputFailure(
request,
"overload_index",
`Index ${index} is outside the ${candidates.length} matching overloads.`,
);
const expectedTarget =
methodName === undefined
@ -329,7 +343,9 @@ const traceReferences = async (
methodName === undefined ? "get_xrefs_to_class" : "get_xrefs_to_method",
{
class_name: summary.full_name,
...(methodName === undefined ? {} : { method_name: methodName }),
...(methodName === undefined
? {}
: { method_name: methodName, overload_index: index }),
limit: 2_147_483_647,
resolve_line: false,
},
@ -337,6 +353,9 @@ const traceReferences = async (
);
if (
xrefs.target !== expectedTarget ||
(selected !== undefined &&
(xrefs.overload_index !== index ||
xrefs.reported_signature !== selected.reported_signature)) ||
xrefs.total !== xrefs.count ||
xrefs.count !== xrefs.items.length
)
@ -348,6 +367,9 @@ const traceReferences = async (
engine,
class_name: summary.full_name,
method_name: methodName ?? null,
overload_index: selected?.overload_index ?? null,
reported_signature: selected?.reported_signature ?? null,
dex_descriptor: selected?.dex_descriptor ?? null,
total: xrefs.total,
references: xrefs.items.map((reference) => ({
kind: reference.kind,
@ -357,7 +379,10 @@ const traceReferences = async (
top_class: reference.top_class,
source_line: null,
dex_offset: null,
direct_invokes: reference.direct_invokes ?? null,
direct_invokes_exhaustive: reference.direct_invokes_exhaustive ?? null,
})),
coverage: "complete",
reference_unit: "using_node",
});
};

View File

@ -2,6 +2,7 @@ import { z } from "zod";
import { AnalysisOutputError } from "../domain/analysisErrorCore.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import type { AndroidOperation } from "../domain/android/androidAnalysis.js";
import { dexDescriptorSchema } from "../domain/android/androidAnalysis.js";
const count = z.number().int().nonnegative();
/** Selector failures authored by REA's bridge, separate from arbitrary engine errors. */
@ -50,6 +51,7 @@ export const jadxClassSchema = z.object({
z.object({
name: z.string(),
signature: z.string(),
dex_descriptor: dexDescriptorSchema,
is_constructor: z.boolean(),
def_pos: z.number().int(),
}),
@ -72,6 +74,8 @@ export const jadxMethodSchema = z.object({
});
export const jadxXrefsSchema = z.object({
target: z.string(),
overload_index: count.optional(),
reported_signature: z.string().optional(),
total: count,
count,
resolve_line: z.literal(false),
@ -83,6 +87,18 @@ export const jadxXrefsSchema = z.object({
containing_class: z.string(),
top_class: z.string(),
def_pos: z.number().int(),
direct_invokes_exhaustive: z.boolean().optional(),
direct_invokes: z
.array(
z.object({
dex_entry: z.string().min(1),
source_raw_method_id: z.string().min(1),
dex_code_unit_offset: count,
dex_method_index: count,
opcode: z.string().regex(/^INVOKE_/u),
}),
)
.optional(),
}),
),
});

View File

@ -25,7 +25,8 @@ export const JADX_PROVIDER_IDENTITY = Object.freeze({
/** Audited engine coverage, kept outside portable Android semantics. */
export const JADX_LIMITATIONS = [
"Static decompiler observations do not establish runtime behavior; the APK is never executed.",
"Method signatures use provider display types; exact DEX descriptors and instruction offsets are unavailable. Overload indices are local to this engine and artifact.",
"Method signatures use provider display types; DEX prototypes come from frozen JADX metadata. Display names may be aliases, and overload indices are local to this engine and artifact.",
"Class inventories use parsed metadata before code generation, including synthetic members that may be omitted from decompiled source. Source positions remain unknown.",
"Incoming reference totals count JADX using nodes. Direct invoke entries within a using method report raw DEX code-unit offsets and method indexes, but coverage is limited to those nodes and directly matched invokes; it does not establish every possible dynamic or reflective call site.",
"Manifest summary fields are extracted by the upstream engine; decoded XML is retained for verification. APK signatures, split APKs, native libraries and Android runtime capture are not verified by this provider.",
] as const;

View File

@ -99,13 +99,20 @@ export const registerAndroidCommands = (
}),
});
cli.command(CLI_COMMANDS.traceAndroidReferences, {
description: "Trace incoming APK class or unique-method references",
description:
"Trace incoming APK using nodes for a class or selected method",
args: z.object({ path, className }),
options: z.object({
methodName: z
.string()
.optional()
.describe("Unique declared method name; omit for class references"),
.describe("Declared method name; omit for class references"),
overloadIndex: z
.number()
.int()
.nonnegative()
.optional()
.describe("Index among same-name methods from inspect-android-class"),
}),
run: ({ args, options }) =>
execute(CLI_COMMANDS.traceAndroidReferences, "trace_android_references", {
@ -114,6 +121,9 @@ export const registerAndroidCommands = (
...(options.methodName === undefined
? {}
: { method_name: options.methodName }),
...(options.overloadIndex === undefined
? {}
: { overload_index: options.overloadIndex }),
}),
});
};

View File

@ -51,7 +51,7 @@ export const ANDROID_TOOL_CONTRACTS = [
name: "inspect_android_class",
...toolContractMetadata("inspect_android_class"),
description:
"Inspect one exact Android class's parsed methods, fields and inner classes, including synthetic members. Returns display signatures and explicit unknown DEX descriptors without generating source. Overload indices remain consistent within the artifact and engine session.",
"Inspect one exact Android class's parsed methods, fields and inner classes, including synthetic members. Returns display signatures and exact DEX prototypes without generating source. A prototype is not a complete raw method ID; overload indices remain consistent within the artifact and engine session.",
kind: "android-provider",
inputSchema: androidInputSchemas.inspect_android_class,
outputSchema: evidenceResultOf(androidResultSchemas.inspect_android_class),
@ -66,7 +66,7 @@ export const ANDROID_TOOL_CONTRACTS = [
name: "inspect_android_method",
...toolContractMetadata("inspect_android_method"),
description:
"Decompile one Android method with smali fallback. Requires explicit overload_index when multiple methods share a name. Reports representation, fallback markers and source truncation; never treats display signatures as exact DEX identities.",
"Decompile one Android method with smali fallback. Requires explicit overload_index when multiple methods share a name. Reports its DEX prototype, representation, fallback markers and source truncation; display signatures may be aliases.",
kind: "android-provider",
inputSchema: androidInputSchemas.inspect_android_method,
outputSchema: evidenceResultOf(androidResultSchemas.inspect_android_method),
@ -85,7 +85,7 @@ export const ANDROID_TOOL_CONTRACTS = [
name: "trace_android_references",
...toolContractMetadata("trace_android_references"),
description:
"Trace incoming static references to an exact Android class or unique method. Overloaded method reference queries are explicitly unsupported by the current engine. Source lines and DEX offsets remain unknown; no runtime calls are inferred.",
"Trace incoming static using nodes to an exact Android class or selected method overload. The total counts containing nodes, not individual invoke instructions. Use inspect_android_class to choose an overload_index when names collide. Source lines and the containing node dex_offset remain unknown. direct_invokes carries exact DEX code-unit instruction offsets when available; direct_invokes_exhaustive false marks byte-bounded partial instruction evidence, and null means coverage is unknown. Using-node coverage remains separate; no runtime calls are inferred.",
kind: "android-provider",
inputSchema: androidInputSchemas.trace_android_references,
outputSchema: evidenceResultOf(

View File

@ -40,7 +40,15 @@ export const androidInputSchemas = {
.min(1)
.optional()
.describe(
"Omit for class references; ambiguous overloaded methods are unsupported",
"Omit for class references; select ambiguous methods with overload_index",
),
overload_index: z
.number()
.int()
.nonnegative()
.optional()
.describe(
"Index among same-name methods from inspect_android_class; requires method_name",
),
}),
} as const;
@ -90,12 +98,19 @@ const textCoverage = z.strictObject({
text: z.string(),
reported_total_bytes: z.number().int().nonnegative().nullable(),
});
/** A DEX prototype: parameter descriptors followed by the return descriptor. */
export const dexDescriptorSchema = z
.string()
.regex(
/^\((?:\[*(?:[ZBSCIJFD]|L[^;()]+;))*\)(?:V|\[*(?:[ZBSCIJFD]|L[^;()]+;))$/u,
);
const method = z.strictObject({
name: z.string(),
reported_signature: z.string(),
overload_index: z.number().int().nonnegative(),
is_constructor: z.boolean(),
dex_descriptor: z.null(),
dex_descriptor: dexDescriptorSchema,
});
const engine = z.strictObject({
name: z.string().min(1),
@ -159,6 +174,9 @@ export const androidResultSchemas = {
...common,
class_name: z.string(),
method_name: z.string().nullable(),
overload_index: z.number().int().nonnegative().nullable(),
reported_signature: z.string().nullable(),
dex_descriptor: dexDescriptorSchema.nullable(),
total: z.number().int().nonnegative(),
references: z.array(
z.strictObject({
@ -169,8 +187,23 @@ export const androidResultSchemas = {
top_class: z.string(),
source_line: z.null(),
dex_offset: z.null(),
/** false means transport-bounded instruction evidence; null/omitted is unknown. */
direct_invokes_exhaustive: z.boolean().nullable().optional(),
/** Raw direct invoke instructions inside this using node, when available. */
direct_invokes: z
.array(
z.strictObject({
dex_entry: z.string().min(1),
source_raw_method_id: z.string().min(1),
dex_code_unit_offset: z.number().int().nonnegative(),
dex_method_index: z.number().int().nonnegative(),
opcode: z.string().regex(/^INVOKE_/u),
}),
)
.nullable(),
}),
),
coverage: z.literal("complete"),
reference_unit: z.literal("using_node"),
}),
} as const;

View File

@ -0,0 +1,304 @@
import { expect, it } from "vitest";
import {
analyzeJadxRequest,
type JadxToolPort,
} from "../../../src/android/JadxAnalysis.js";
import { androidInputSchemas } from "../../../src/domain/android/androidAnalysis.js";
const summary = {
full_name: "fixture.Target",
name: "Target",
method_count: 2,
field_count: 0,
inner_class_count: 0,
methods: [
{
name: "choose",
signature: "choose(String): void",
dex_descriptor: "(Ljava/lang/String;)V",
is_constructor: false,
def_pos: 0,
},
{
name: "choose",
signature: "choose(int): void",
dex_descriptor: "(I)V",
is_constructor: false,
def_pos: 0,
},
],
fields: [],
inner_classes: [],
};
const engine = {
name: "jadx",
version: "0.7.1",
artifact_sha256: "a".repeat(64),
source_revision: null,
worker_count: 1,
heap_limit_mib: null,
};
it("publishes exact DEX prototypes instead of discarding bridge metadata", async () => {
const tools: JadxToolPort = {
json: () => Promise.resolve(summary),
text: () => Promise.reject(new Error("unexpected text call")),
};
const result = await analyzeJadxRequest(
tools,
{
operation: "inspect_android_class",
input: androidInputSchemas.inspect_android_class.parse({
path: "/fixture.apk",
class_name: "fixture.Target",
}),
},
{} as never,
engine,
);
expect(result).toMatchObject({
methods: [
{ name: "choose", dex_descriptor: "(Ljava/lang/String;)V" },
{ name: "choose", dex_descriptor: "(I)V" },
],
});
});
it("traces only the selected overloaded method and checks the producer identity", async () => {
const calls: Record<string, unknown>[] = [];
const tools: JadxToolPort = {
json: (name, input) => {
calls.push({ name, ...input });
if (name === "get_class_summary") return Promise.resolve(summary);
return Promise.resolve({
target: "fixture.Target.choose",
overload_index: 1,
reported_signature: "choose(int): void",
total: 1,
count: 1,
resolve_line: false,
items: [
{
kind: "method",
name: "caller",
full_name: "fixture.Other.caller",
containing_class: "fixture.Other",
top_class: "fixture.Other",
def_pos: 0,
direct_invokes: [
{
dex_entry: "classes.dex",
source_raw_method_id: "fixture.Other.caller()V",
dex_code_unit_offset: 0,
dex_method_index: 1,
opcode: "INVOKE_STATIC",
},
{
dex_entry: "classes.dex",
source_raw_method_id: "fixture.Other.caller()V",
dex_code_unit_offset: 3,
dex_method_index: 1,
opcode: "INVOKE_STATIC",
},
],
},
],
});
},
text: () => Promise.reject(new Error("unexpected text call")),
};
const request = {
operation: "trace_android_references",
input: androidInputSchemas.trace_android_references.parse({
path: "/fixture.apk",
class_name: "fixture.Target",
method_name: "choose",
overload_index: 1,
}),
} as const;
const result = await analyzeJadxRequest(tools, request, {} as never, engine);
expect(result).toMatchObject({
method_name: "choose",
overload_index: 1,
reported_signature: "choose(int): void",
dex_descriptor: "(I)V",
total: 1,
reference_unit: "using_node",
references: [
{
direct_invokes: [
{ dex_code_unit_offset: 0 },
{ dex_code_unit_offset: 3 },
],
},
],
});
expect(calls[1]).toMatchObject({
name: "get_xrefs_to_method",
overload_index: 1,
});
});
it("rejects an ambiguous method and a producer response for a different overload", async () => {
const xrefs = {
target: "fixture.Target.choose",
overload_index: 0,
reported_signature: "choose(String): void",
total: 0,
count: 0,
resolve_line: false,
items: [],
};
const tools: JadxToolPort = {
json: (name) =>
Promise.resolve(name === "get_class_summary" ? summary : xrefs),
text: () => Promise.reject(new Error("unexpected text call")),
};
const input = {
path: "/fixture.apk",
class_name: "fixture.Target",
method_name: "choose",
};
await expect(
analyzeJadxRequest(
tools,
{
operation: "trace_android_references",
input,
},
{} as never,
engine,
),
).rejects.toMatchObject({
_tag: "AnalysisInputError",
});
await expect(
analyzeJadxRequest(
tools,
{
operation: "trace_android_references",
input: { ...input, overload_index: 1 },
},
{} as never,
engine,
),
).rejects.toMatchObject({
_tag: "AnalysisOutputError",
});
});
const invokeSite = {
dex_entry: "classes.dex",
source_raw_method_id: "fixture.Other.caller()V",
dex_code_unit_offset: 0,
dex_method_index: 1,
opcode: "INVOKE_STATIC",
};
const traceWithReference = (fields: Record<string, unknown>) =>
analyzeJadxRequest(
{
json: (name) =>
Promise.resolve(
name === "get_class_summary"
? summary
: {
target: "fixture.Target.choose",
overload_index: 0,
reported_signature: "choose(String): void",
total: 1,
count: 1,
resolve_line: false,
items: [
{
kind: "method",
name: "caller",
full_name: "fixture.Other.caller",
containing_class: "fixture.Other",
top_class: "fixture.Other",
def_pos: 0,
...fields,
},
],
},
),
text: () => Promise.reject(new Error("unexpected text call")),
},
{
operation: "trace_android_references",
input: {
path: "/fixture.apk",
class_name: "fixture.Target",
method_name: "choose",
overload_index: 0,
},
},
{} as never,
engine,
);
it.each([
[{}, null],
[{ direct_invokes: [] }, []],
])(
"preserves unavailable versus observed-empty invoke sites: %j",
async (fields, expected) => {
const result = await traceWithReference(fields as Record<string, unknown>);
expect(result).toMatchObject({
references: [{ direct_invokes: expected }],
});
},
);
it.each([
{ ...invokeSite, dex_code_unit_offset: -1 },
{
dex_entry: "classes.dex",
source_raw_method_id: "fixture.Other.caller()V",
dex_code_unit_offset: 0,
dex_method_index: 1,
},
])("rejects malformed direct invoke producer evidence: %j", async (site) => {
await expect(
traceWithReference({ direct_invokes: [site] }),
).rejects.toThrow();
});
it("preserves bounded instruction coverage separately from using-node coverage", async () => {
const result = await traceWithReference({
direct_invokes: [invokeSite],
direct_invokes_exhaustive: false,
});
expect(result).toMatchObject({
coverage: "complete",
reference_unit: "using_node",
references: [
{ direct_invokes: [invokeSite], direct_invokes_exhaustive: false },
],
});
});
it("rejects malformed method descriptors at the Android adapter boundary", async () => {
const invalidSummary = {
...summary,
methods: summary.methods.map((method) => ({
...method,
dex_descriptor: "not-a-dex-descriptor",
})),
};
await expect(
analyzeJadxRequest(
{
json: () => Promise.resolve(invalidSummary),
text: () => Promise.reject(new Error("unexpected text call")),
},
{
operation: "inspect_android_class",
input: { path: "/fixture.apk", class_name: "fixture.Target" },
},
{} as never,
engine,
),
).rejects.toThrow();
});

View File

@ -342,17 +342,30 @@ it("exposes overload candidates and executes only an explicitly selected ambiguo
expect(result.method).toMatchObject({
overload_index: 1,
reported_signature: "choose(int): void",
dex_descriptor: null,
dex_descriptor: "(I)V",
});
expect(result.source.text).toContain("overload 1");
const references = await service.execute("trace_android_references", input);
expect(references).toMatchObject({
ok: false,
error: {
_tag: "AnalysisCapabilityUnavailableError",
reason: expect.stringContaining("unique method name"),
_tag: "AnalysisInputError",
issues: [expect.objectContaining({ path: ["overload_index"] })],
},
});
const selectedReferences = await service.execute("trace_android_references", {
...input,
overload_index: 1,
});
if (!selectedReferences.ok) throw selectedReferences.error;
expect(
androidResultSchemas.trace_android_references.parse(
selectedReferences.value.normalized_result,
),
).toMatchObject({
overload_index: 1,
reported_signature: "choose(int): void",
});
await provider.close();
await verifyCleanup(launches);
});

View File

@ -60,6 +60,78 @@ public class ReaJadxMetadataRegression {
return apk;
}
private static void verifyDirectInvokes(SessionHolder holder, SessionConfig config,
Constructor<?> constructor, Method handle, Path sources, Path output) throws Exception {
SmaliOptions options = new SmaliOptions();
options.apiLevel = 28;
options.jobs = 1;
options.outputDexFile = output.resolve("direct-invokes.dex").toString();
require(Smali.assemble(options, List.of(
sources.resolve("Caller.smali").toString(), sources.resolve("Target.smali").toString())),
"Direct-invoke fixture assembly failed");
Path apk = output.resolve("direct-invokes.apk");
try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(apk))) {
zip.putNextEntry(new ZipEntry("classes.dex"));
Files.copy(Path.of(options.outputDexFile), zip);
zip.closeEntry();
}
BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE,
(scope, continuation) -> holder.unload(continuation));
BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE,
(scope, continuation) -> holder.load(apk.toString(), config, continuation));
JsonObject refs = call(handle, constructor.newInstance(holder), "get_xrefs_to_method",
methodArguments("fixture.Target", "mark"));
checkDirectInvokes(refs, "classes.dex");
SmaliOptions targetOptions = new SmaliOptions();
targetOptions.apiLevel = 28;
targetOptions.jobs = 1;
targetOptions.outputDexFile = output.resolve("direct-target.dex").toString();
SmaliOptions callerOptions = new SmaliOptions();
callerOptions.apiLevel = 28;
callerOptions.jobs = 1;
callerOptions.outputDexFile = output.resolve("direct-caller.dex").toString();
require(Smali.assemble(targetOptions, List.of(sources.resolve("Target.smali").toString())),
"Target DEX assembly failed");
require(Smali.assemble(callerOptions, List.of(sources.resolve("Caller.smali").toString())),
"Caller DEX assembly failed");
Path splitApk = output.resolve("direct-invokes-multidex.apk");
try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(splitApk))) {
zip.putNextEntry(new ZipEntry("classes.dex"));
Files.copy(Path.of(targetOptions.outputDexFile), zip);
zip.closeEntry();
zip.putNextEntry(new ZipEntry("classes2.dex"));
Files.copy(Path.of(callerOptions.outputDexFile), zip);
zip.closeEntry();
}
BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE,
(scope, continuation) -> holder.unload(continuation));
BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE,
(scope, continuation) -> holder.load(splitApk.toString(), config, continuation));
JsonObject crossDex = call(handle, constructor.newInstance(holder), "get_xrefs_to_method",
methodArguments("fixture.Target", "mark"));
checkDirectInvokes(crossDex, "classes2.dex");
System.out.println("DIRECT_INVOKE_REGRESSION_PASS nodes=1 offsets=0,3 cross_dex=true");
}
private static void checkDirectInvokes(JsonObject refs, String dexEntry) {
require(refs.get("total").getAsInt() == 1, "JADX using-node count changed");
JsonArray sites = refs.getAsJsonArray("items").get(0).getAsJsonObject()
.getAsJsonArray("direct_invokes");
require(sites.size() == 2, "Raw DEX invoke count differs from the fixture: " + sites);
for (int i = 0; i < sites.size(); i++) {
JsonObject site = sites.get(i).getAsJsonObject();
require(site.get("dex_entry").getAsString().equals(dexEntry), "Wrong DEX entry");
require(site.get("source_raw_method_id").getAsString().equals("fixture.Caller.twice()V"),
"Wrong raw source method");
require(site.get("dex_code_unit_offset").getAsInt() == i * 3, "Wrong DEX offset");
require(site.get("opcode").getAsString().equals("INVOKE_STATIC"), "Wrong opcode");
}
require(sites.get(0).getAsJsonObject().get("dex_method_index").getAsInt()
== sites.get(1).getAsJsonObject().get("dex_method_index").getAsInt(),
"Two invokes did not reference the same DEX method ID");
}
private static JsonObject methodArguments(String className, String methodName) {
JsonObject value = arguments(className);
value.addProperty("method_name", methodName);
@ -152,13 +224,20 @@ public class ReaJadxMetadataRegression {
JsonObject componentArgs = arguments("androidx.activity.ComponentActivity");
JsonObject componentSummary = call(handle, bridge, "get_class_summary", componentArgs);
List<String> signatures = new ArrayList<>();
List<String> descriptors = new ArrayList<>();
for (JsonElement entry : componentSummary.getAsJsonArray("methods")) {
JsonObject method = entry.getAsJsonObject();
if (method.get("name").getAsString().equals("setContentView"))
if (method.get("name").getAsString().equals("setContentView")) {
signatures.add(method.get("signature").getAsString());
descriptors.add(method.get("dex_descriptor").getAsString());
}
}
require(signatures.size() == 3, "Fixture must contain three setContentView overloads");
require(descriptors.equals(List.of("(I)V", "(Landroid/view/View;)V",
"(Landroid/view/View;Landroid/view/ViewGroup$LayoutParams;)V")),
"Frozen DEX prototypes did not identify the three overloads: " + descriptors);
String[] declarations = { "setContentView(int ", "setContentView(View ", "setContentView(View view, ViewGroup.LayoutParams " };
List<Integer> referenceCounts = new ArrayList<>();
for (int index = 0; index < signatures.size(); index++) {
JsonObject methodArgs = arguments("androidx.activity.ComponentActivity");
methodArgs.addProperty("method_name", "setContentView");
@ -169,12 +248,35 @@ public class ReaJadxMetadataRegression {
"Overload envelope mismatch");
require(first.get("mode").getAsString().equals("java"), "Fixture did not produce Java");
require(first.get("body").getAsString().contains(declarations[index]), "Wrong overload: " + signatures.get(index));
JsonObject references = call(handle, bridge, "get_xrefs_to_method", methodArgs);
require(references.get("overload_index").getAsInt() == index,
"Reference result did not preserve selected overload index");
require(references.get("reported_signature").getAsString().equals(signatures.get(index)),
"Reference result did not preserve selected overload signature");
if (index == 0) {
JsonArray invokes = references.getAsJsonArray("items").get(0).getAsJsonObject()
.getAsJsonArray("direct_invokes");
require(invokes.size() == 1, "Selected overload must have one raw direct invoke");
JsonObject invoke = invokes.get(0).getAsJsonObject();
require(invoke.get("source_raw_method_id").getAsString()
.equals("androidx.activity.ComponentActivity.onCreate(Landroid/os/Bundle;)V"),
"Wrong source method for overloaded target");
require(invoke.get("dex_code_unit_offset").getAsInt() == 37
&& invoke.get("opcode").getAsString().equals("INVOKE_VIRTUAL"),
"Wrong direct invoke for selected overloaded target");
}
referenceCounts.add(references.get("count").getAsInt());
}
require(referenceCounts.equals(List.of(1, 0, 0)),
"Incoming references were not isolated to the selected overload: " + referenceCounts);
require(componentSummary.equals(call(handle, bridge, "get_class_summary", componentArgs)),
"Frozen summary changed after code generation");
require(apiSummary.equals(call(handle, bridge, "get_class_summary", apiArgs)), "Unrelated frozen summary changed");
System.out.println("METADATA_REGRESSION_PASS classes=" + states.size() + " overloads=" + signatures.size());
System.out.println("METADATA_REGRESSION_PASS classes=" + states.size() + " overloads=" + signatures.size()
+ " reference_counts=" + referenceCounts);
verifyInitializers(holder, config, constructor, handle, initializerFixture(args[1], args[2]));
verifyDirectInvokes(holder, config, constructor, handle,
Path.of(args[3]), Path.of(args[2]));
} finally {
BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE,
(scope, continuation) -> holder.unload(continuation));

View File

@ -0,0 +1,9 @@
.class public Lfixture/Caller;
.super Ljava/lang/Object;
.method public static twice()V
.registers 0
invoke-static {}, Lfixture/Target;->mark()V
invoke-static {}, Lfixture/Target;->mark()V
return-void
.end method

View File

@ -0,0 +1,7 @@
.class public Lfixture/Target;
.super Ljava/lang/Object;
.method public static mark()V
.registers 0
return-void
.end method

View File

@ -32,18 +32,21 @@ const methods = [
{
name: "onCreate",
signature: "onCreate(Bundle): void",
dex_descriptor: "(Landroid/os/Bundle;)V",
is_constructor: false,
def_pos: 0,
},
{
name: "choose",
signature: "choose(String): void",
dex_descriptor: "(Ljava/lang/String;)V",
is_constructor: false,
def_pos: 0,
},
{
name: "choose",
signature: "choose(int): void",
dex_descriptor: "(I)V",
is_constructor: false,
def_pos: 0,
},
@ -139,10 +142,18 @@ register("get_method_body", ({ method_name, overload_index }) =>
}),
);
for (const name of ["get_xrefs_to_class", "get_xrefs_to_method"])
register(name, ({ class_name, method_name }) =>
register(name, ({ class_name, method_name, overload_index }) =>
json({
target:
method_name === undefined ? class_name : `${class_name}.${method_name}`,
...(method_name === undefined
? {}
: {
overload_index,
reported_signature: methods.filter(
(method) => method.name === method_name,
)[overload_index]?.signature,
}),
total: mode === "partial-xrefs" ? 2 : 1,
count: 1,
resolve_line: false,