diff --git a/bridge/android/ReaJadxBridge.java b/bridge/android/ReaJadxBridge.java index 620e2454d..e57b24943 100644 --- a/bridge/android/ReaJadxBridge.java +++ b/bridge/android/ReaJadxBridge.java @@ -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 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> 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 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> 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> 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 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 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 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> 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> items = uses.stream().map(node -> { + Map 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 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 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 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 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); diff --git a/docs/android-analysis.md b/docs/android-analysis.md index d98d9b5da..81c9f9285 100644 --- a/docs/android-analysis.md +++ b/docs/android-analysis.md @@ -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. diff --git a/scripts/verify/android/analysis.mjs b/scripts/verify/android/analysis.mjs index 43c51d82b..c29a25d3a 100644 --- a/scripts/verify/android/analysis.mjs +++ b/scripts/verify/android/analysis.mjs @@ -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( diff --git a/scripts/verify/android/direct-invokes.mjs b/scripts/verify/android/direct-invokes.mjs new file mode 100644 index 000000000..7cb9972db --- /dev/null +++ b/scripts/verify/android/direct-invokes.mjs @@ -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`, + ); +}; diff --git a/scripts/verify/android/metadata.mjs b/scripts/verify/android/metadata.mjs index eebcaf812..a44da336c 100644 --- a/scripts/verify/android/metadata.mjs +++ b/scripts/verify/android/metadata.mjs @@ -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 }); } diff --git a/src/android/JadxAnalysis.ts b/src/android/JadxAnalysis.ts index d8afed20c..502c90a69 100644 --- a/src/android/JadxAnalysis.ts +++ b/src/android/JadxAnalysis.ts @@ -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 => { 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", }); }; diff --git a/src/android/JadxProtocol.ts b/src/android/JadxProtocol.ts index 804b7ffa7..22a457b0f 100644 --- a/src/android/JadxProtocol.ts +++ b/src/android/JadxProtocol.ts @@ -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(), }), ), }); diff --git a/src/android/JadxRelease.ts b/src/android/JadxRelease.ts index b5b6bd058..8c7d16840 100644 --- a/src/android/JadxRelease.ts +++ b/src/android/JadxRelease.ts @@ -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; diff --git a/src/cli/androidCommands.ts b/src/cli/androidCommands.ts index 80a71b235..540dd295d 100644 --- a/src/cli/androidCommands.ts +++ b/src/cli/androidCommands.ts @@ -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 }), }), }); }; diff --git a/src/contracts/android/androidToolContracts.ts b/src/contracts/android/androidToolContracts.ts index b555fcbea..2710e0faa 100644 --- a/src/contracts/android/androidToolContracts.ts +++ b/src/contracts/android/androidToolContracts.ts @@ -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( diff --git a/src/domain/android/androidAnalysis.ts b/src/domain/android/androidAnalysis.ts index ca4d6db59..507550537 100644 --- a/src/domain/android/androidAnalysis.ts +++ b/src/domain/android/androidAnalysis.ts @@ -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; diff --git a/tests/boundary/android/JadxAnalysis.test.ts b/tests/boundary/android/JadxAnalysis.test.ts new file mode 100644 index 000000000..36b0ab9d9 --- /dev/null +++ b/tests/boundary/android/JadxAnalysis.test.ts @@ -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[] = []; + 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) => + 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); + 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(); +}); diff --git a/tests/boundary/process/jadxIntegration.test.ts b/tests/boundary/process/jadxIntegration.test.ts index 86e2c5ab7..173f5b522 100644 --- a/tests/boundary/process/jadxIntegration.test.ts +++ b/tests/boundary/process/jadxIntegration.test.ts @@ -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); }); diff --git a/tests/conformance/android/ReaJadxMetadataRegression.java b/tests/conformance/android/ReaJadxMetadataRegression.java index a0cbd9364..7e21dd4fc 100644 --- a/tests/conformance/android/ReaJadxMetadataRegression.java +++ b/tests/conformance/android/ReaJadxMetadataRegression.java @@ -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 signatures = new ArrayList<>(); + List 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 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)); diff --git a/tests/fixtures/android/direct-invokes/Caller.smali b/tests/fixtures/android/direct-invokes/Caller.smali new file mode 100644 index 000000000..4a565cca5 --- /dev/null +++ b/tests/fixtures/android/direct-invokes/Caller.smali @@ -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 diff --git a/tests/fixtures/android/direct-invokes/Target.smali b/tests/fixtures/android/direct-invokes/Target.smali new file mode 100644 index 000000000..463234abf --- /dev/null +++ b/tests/fixtures/android/direct-invokes/Target.smali @@ -0,0 +1,7 @@ +.class public Lfixture/Target; +.super Ljava/lang/Object; + +.method public static mark()V + .registers 0 + return-void +.end method diff --git a/tests/fixtures/android/jadx-mcp.mjs b/tests/fixtures/android/jadx-mcp.mjs index 7319a5501..8b37ae54d 100644 --- a/tests/fixtures/android/jadx-mcp.mjs +++ b/tests/fixtures/android/jadx-mcp.mjs @@ -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,