diff --git a/.github/workflows/real-android-metadata.yml b/.github/workflows/real-android-metadata.yml new file mode 100644 index 0000000000..db417c5bd1 --- /dev/null +++ b/.github/workflows/real-android-metadata.yml @@ -0,0 +1,55 @@ +name: Real Android metadata bridge + +on: + push: + branches: ["**"] + paths: + - .github/workflows/real-android-metadata.yml + - bridge/android/** + - src/android/JadxRelease.ts + - scripts/fixtures/android/** + - tests/conformance/android/** + - tests/fixtures/android/static-initializers/** + - tests/fixtures/android/direct-invokes/** + - .nvmrc + workflow_dispatch: + workflow_call: + +permissions: + contents: read + +concurrency: + group: android-metadata-${{ github.ref }} + cancel-in-progress: true + +jobs: + android-metadata: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: .nvmrc + - name: Fetch checksum-pinned engine and public APK fixture + run: node scripts/fixtures/android/fetch.mjs "$RUNNER_TEMP/rea-android" + - name: Compile and exercise the real metadata bridge + shell: bash + run: | + export JAVA_HOME="$JAVA_HOME_21_X64" + android_root="$RUNNER_TEMP/rea-android" + classes="$android_root/classes" + jar="$android_root/jadx-headless-mcp-0.7.1-all.jar" + mkdir -p "$classes" + "$JAVA_HOME/bin/javac" -J-Xmx512m -J-XX:ActiveProcessorCount=2 \ + -classpath "$jar" -d "$classes" \ + bridge/android/ReaJadxBridge.java \ + tests/conformance/android/ReaJadxMetadataRegression.java + "$JAVA_HOME/bin/java" -Xmx512m -XX:ActiveProcessorCount=2 \ + -classpath "$classes:$jar" ReaJadxMetadataRegression \ + "$android_root/ApiDemos-debug.apk" \ + "$GITHUB_WORKSPACE/tests/fixtures/android/static-initializers" \ + "$classes" "$GITHUB_WORKSPACE/tests/fixtures/android/direct-invokes" \ + | tee "$android_root/metadata.log" + grep -F 'RAW_CLASS_SELECTOR_REGRESSION_PASS nested=true metadata=true references=true' \ + "$android_root/metadata.log" diff --git a/bridge/android/ReaJadxBridge.java b/bridge/android/ReaJadxBridge.java index de64e3123a..5bbb0b3f9a 100644 --- a/bridge/android/ReaJadxBridge.java +++ b/bridge/android/ReaJadxBridge.java @@ -269,12 +269,16 @@ class ReaJadxBridge { "type", field.getType().toString(), "def_pos", 0)).toList(); List inner = node.getInnerClasses().stream().map(ClassNode::getFullName).toList(); Map summary = Map.of( - "full_name", cls.getFullName(), "name", cls.getName(), + "full_name", cls.getFullName(), "raw_name", cls.getRawName(), "name", cls.getName(), "method_count", methods.size(), "field_count", fields.size(), "inner_class_count", inner.size(), "inner_classes", inner, "methods", methods.stream().map(MethodSnapshot::summary).toList(), "fields", fields); - index.computeIfAbsent(cls.getFullName(), ignored -> new ArrayList<>()) - .add(new ClassSnapshot(cls, methods, summary)); + ClassSnapshot snapshot = new ClassSnapshot(cls, methods, summary); + index.computeIfAbsent(cls.getFullName(), ignored -> new ArrayList<>()).add(snapshot); + // Keep the binary identity supplied by JADX; '$' can also be a literal + // name character, so replacing separators would invent lookup aliases. + if (!cls.getRawName().equals(cls.getFullName())) + index.computeIfAbsent(cls.getRawName(), ignored -> new ArrayList<>()).add(snapshot); } } classes = Collections.unmodifiableMap(index); @@ -454,7 +458,7 @@ class ReaJadxBridge { "markers", code.getMarkers(), "body", code.getText())); } List uses; - String target = string(args, "class_name"); + String target = (String) cls.summary().get("full_name"); MethodSnapshot selectedMethod = null; int overloadIndex = 0; if (name.equals("get_xrefs_to_class")) uses = cls.node().getUseIn(); @@ -522,7 +526,7 @@ class ReaJadxBridge { SessionHolder holder = new SessionHolder(defaults); ServerCapabilities capabilities = new ServerCapabilities( new ServerCapabilities.Tools(null), null, null, null, null, null, null, Map.of()); - Server server = new Server(new Implementation("rea-jadx-bridge", "5", null, null, null), + Server server = new Server(new Implementation("rea-jadx-bridge", "6", null, null, null), new ServerOptions(capabilities), "", ignored -> Unit.INSTANCE); SessionToolsKt.registerSessionTools(server, holder); ClassToolsKt.registerClassTools(server, holder); diff --git a/docs/android-analysis.md b/docs/android-analysis.md index 4db31e0045..eda56f4d45 100644 --- a/docs/android-analysis.md +++ b/docs/android-analysis.md @@ -240,6 +240,13 @@ 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. + Class and method inspection and reference tracing accept either the exact + reported class name or its original binary name, including `$` in nested and + anonymous classes. The bridge keeps both identities from JADX; it does not + replace separators or guess suffix matches. Ambiguous selectors are refused. + Follow-up calls preserve the selected name, while results report the frozen + JADX display name. Quote `$` names in the shell, for example + `'com.example.Manager$WorkerThread'`. 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 diff --git a/docs/testing.md b/docs/testing.md index b471e68062..4164fb35fd 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1002,6 +1002,16 @@ application execution is required. The lane compares real CLI/MCP package, class search, class inventory, method decompilation and incoming references. See [Android analysis](android-analysis.md) for boundaries and resource budgets. +The **Real Android metadata bridge** workflow compiles the Java bridge and runs +its metadata regressions against the same checksum-pinned engine and public APK. +It runs on branch pushes affecting its bridge, fixture or engine inputs, and can +also be dispatched manually or called by another workflow. It uses the runner's +JDK with two active processors and a 512 MiB heap; it needs no npm dependency +installation. This lane checks exact raw/display class selectors and the existing +cold-metadata regressions. It does not establish CLI/MCP parity or application +execution behavior. The broader `verify:android` lane checks CLI/MCP parity; +neither lane runs the APK. + `verify:jeb` requires a caller-started JEB client serving MCP at `REA_JEB_MCP_URL` (default `http://127.0.0.1:8425/mcp`) with a project already open, and exercises shared application/provider client inspection, unit listing coverage, diff --git a/scripts/verify/android/metadata.mjs b/scripts/verify/android/metadata.mjs index f0a5beb11a..44948d4667 100644 --- a/scripts/verify/android/metadata.mjs +++ b/scripts/verify/android/metadata.mjs @@ -77,6 +77,10 @@ export const verifyAndroidMetadata = async ({ result.stdout, /CLASS_SEARCH_REGRESSION_PASS pages=\d+ nonzero=true/u, ); + assert.match( + result.stdout, + /RAW_CLASS_SELECTOR_REGRESSION_PASS nested=true metadata=true references=true/u, + ); assert.match( result.stdout, /INITIALIZER_REGRESSION_PASS folded=2 bounded=true/u, @@ -103,6 +107,9 @@ export const verifyAndroidMetadata = async ({ console.log( "PASS real Java filtered pages, nonzero inventory cursors and complete combined results", ); + console.log( + "PASS real nested class binary selectors preserve metadata and reference identity", + ); console.log( "PASS real folded and mixed static initializers, absent bodies and bounded smali", ); diff --git a/src/android/JadxAnalysis.ts b/src/android/JadxAnalysis.ts index 44cd01ff81..0560cb9f42 100644 --- a/src/android/JadxAnalysis.ts +++ b/src/android/JadxAnalysis.ts @@ -92,7 +92,10 @@ const classSummary = async ( { cause: summary.error }, ); const value = summary.data; - if (value.full_name !== request.input.class_name) + if ( + value.full_name !== request.input.class_name && + value.raw_name !== request.input.class_name + ) inputFailure( request, "class_name", @@ -240,7 +243,7 @@ const inspectMethod = async ( ); const body = jadxMethodSchema.parse( await tools.json("get_method_body", { - class_name: summary.full_name, + class_name: request.input.class_name, method_name: request.input.method_name, overload_index: index, smali_fallback: true, @@ -334,7 +337,7 @@ const traceReferences = async ( await tools.json( methodName === undefined ? "get_xrefs_to_class" : "get_xrefs_to_method", { - class_name: summary.full_name, + class_name: request.input.class_name, ...(methodName === undefined ? {} : { method_name: methodName, overload_index: index }), diff --git a/src/android/JadxProtocol.ts b/src/android/JadxProtocol.ts index 68a689042b..ab21b25669 100644 --- a/src/android/JadxProtocol.ts +++ b/src/android/JadxProtocol.ts @@ -80,7 +80,8 @@ export const parseJadxClassPage = (value: JsonValue): JadxClassPage => { }; export const jadxClassSchema = z.object({ - full_name: z.string(), + full_name: z.string().min(1), + raw_name: z.string().min(1), name: z.string(), method_count: count, field_count: count, diff --git a/src/android/JadxRelease.ts b/src/android/JadxRelease.ts index 01f90e6876..eaa450b98e 100644 --- a/src/android/JadxRelease.ts +++ b/src/android/JadxRelease.ts @@ -9,7 +9,7 @@ export const JADX_RELEASE = Object.freeze({ /** Identity of REA's metadata bridge, distinct from the producing engine. */ export const JADX_BRIDGE_IDENTITY = Object.freeze({ name: "rea-jadx-bridge", - version: "5", + version: "6", }); /** Remediation when `REA_JADX_MCP_JAR` is missing or not an absolute path. */ diff --git a/tests/boundary/android/JadxAnalysis.test.ts b/tests/boundary/android/JadxAnalysis.test.ts index 5d30282abb..4b0dd840d5 100644 --- a/tests/boundary/android/JadxAnalysis.test.ts +++ b/tests/boundary/android/JadxAnalysis.test.ts @@ -5,12 +5,14 @@ import { type JadxToolPort, } from "../../../src/android/JadxAnalysis.js"; import { + type AndroidRequest, androidInputSchemas, androidResultSchemas, } from "../../../src/domain/android/androidAnalysis.js"; const summary = { full_name: "fixture.Target", + raw_name: "fixture.Target", name: "Target", method_count: 2, field_count: 0, @@ -126,6 +128,111 @@ it.each([ }, ); +const nestedClassInput = { + path: "/fixture.apk", + class_name: "fixture.Outer$Worker", +}; +const nestedClassRequests: AndroidRequest[] = [ + { operation: "inspect_android_class", input: nestedClassInput }, + { + operation: "inspect_android_method", + input: { ...nestedClassInput, method_name: "choose" }, + }, + { operation: "trace_android_references", input: nestedClassInput }, + { + operation: "trace_android_references", + input: { ...nestedClassInput, method_name: "choose" }, + }, +]; + +it.each(nestedClassRequests)( + "preserves the raw nested class selector for $operation and its followups: $input", + async (request) => { + const calls: Record[] = []; + const fullName = "fixture.Outer.Worker"; + const selected = summary.methods[1]!; + const tools: JadxToolPort = { + listClasses: () => + Promise.reject(new Error("unexpected class inventory call")), + text: () => Promise.reject(new Error("unexpected text call")), + json: (name, input) => { + calls.push({ name, ...input }); + if (name === "get_class_summary") + return Promise.resolve({ + ...summary, + full_name: fullName, + raw_name: nestedClassInput.class_name, + method_count: 1, + methods: [selected], + }); + if (name === "get_method_body") + return Promise.resolve({ + class_name: fullName, + method_name: selected.name, + full_name: `${fullName}.${selected.name}`, + overload_index: 0, + overload_count: 1, + dex_descriptor: selected.dex_descriptor, + mode: "java", + fell_back: false, + markers: [], + body: "void choose(int value) {}", + }); + return Promise.resolve({ + target: + input.method_name === undefined + ? fullName + : `${fullName}.${selected.name}`, + ...(input.method_name === undefined + ? {} + : { + overload_index: 0, + reported_signature: selected.signature, + }), + total: 0, + count: 0, + resolve_line: false, + items: [], + }); + }, + }; + const result = await analyzeJadxRequest( + tools, + request, + {} as never, + engine, + ); + expect(result).toMatchObject({ class_name: fullName }); + expect(calls.map((call) => call.class_name)).toEqual( + Array.from({ length: calls.length }, () => nestedClassInput.class_name), + ); + expect(calls).toHaveLength( + request.operation === "inspect_android_class" ? 1 : 2, + ); + }, +); + +it("rejects a nested class when neither producer identity matches the requested selector", async () => { + await expect( + analyzeJadxRequest( + { + listClasses: () => + Promise.reject(new Error("unexpected class inventory call")), + text: () => Promise.reject(new Error("unexpected text call")), + json: () => + Promise.resolve({ + ...summary, + full_name: "fixture.Outer.Worker", + raw_name: "fixture.Outer$Different", + }), + }, + { operation: "inspect_android_class", input: nestedClassInput }, + {} as never, + engine, + ), + ).rejects.toMatchObject({ _tag: "AnalysisInputError" }); +}); + it("pages filtered matches independently of the examined inventory and preserves cross-page duplicates", async () => { const names = Array.from({ length: 5000 }, (_, i) => `fixture.Target${i}`); names[0] = "fixture.TargetDuplicate"; diff --git a/tests/conformance/android/ReaJadxMetadataRegression.java b/tests/conformance/android/ReaJadxMetadataRegression.java index e7c1433191..57e4293c0e 100644 --- a/tests/conformance/android/ReaJadxMetadataRegression.java +++ b/tests/conformance/android/ReaJadxMetadataRegression.java @@ -158,6 +158,32 @@ public class ReaJadxMetadataRegression { return value; } + private static void verifyRawClassSelectors(Method handle, Object bridge, JadxDecompiler decompiler) + throws Exception { + List classes = decompiler.getClassesWithInners(); + JavaClass nested = classes.stream() + .filter(cls -> cls.getRawName().contains("$") && !cls.getRawName().equals(cls.getFullName())) + .filter(cls -> classes.stream().filter(other -> + other.getFullName().equals(cls.getFullName()) || other.getRawName().equals(cls.getFullName())) + .count() == 1) + .findFirst().orElseThrow(() -> new AssertionError("Fixture has no unambiguous nested class")); + JsonObject raw = arguments(nested.getRawName()); + JsonObject display = arguments(nested.getFullName()); + JsonObject summary = call(handle, bridge, "get_class_summary", raw); + require(summary.get("raw_name").getAsString().equals(nested.getRawName()), + "Class summary discarded original binary identity"); + require(summary.get("full_name").getAsString().equals(nested.getFullName()), + "Class summary replaced the displayed name with the selector"); + require(summary.equals(call(handle, bridge, "get_class_summary", display)), + "Raw and displayed selectors resolved different class metadata"); + JsonObject references = call(handle, bridge, "get_xrefs_to_class", raw); + require(references.get("target").getAsString().equals(nested.getFullName()), + "Reference target mixed its lookup selector with its displayed identity"); + require(references.equals(call(handle, bridge, "get_xrefs_to_class", display)), + "Raw and displayed selectors resolved different incoming class references"); + System.out.println("RAW_CLASS_SELECTOR_REGRESSION_PASS nested=true metadata=true references=true"); + } + private static void verifyClassPages(Method handle, Object bridge, List inventory) throws Exception { int pages = 0; @@ -553,6 +579,7 @@ public class ReaJadxMetadataRegression { Method handle = bridgeType.getDeclaredMethod("handle", String.class, kotlinx.serialization.json.JsonObject.class); handle.setAccessible(true); verifyClassPages(handle, bridge, session.getClassFqns()); + verifyRawClassSelectors(handle, bridge, decompiler); JsonObject apiArgs = arguments("io.appium.android.apis.ApiDemos"); JsonObject apiSummary = call(handle, bridge, "get_class_summary", apiArgs); for (var entry : states.entrySet()) require(entry.getKey().getState() == entry.getValue(), diff --git a/tests/fixtures/android/jadx-mcp.mjs b/tests/fixtures/android/jadx-mcp.mjs index 470b57b1c8..4e7325af19 100644 --- a/tests/fixtures/android/jadx-mcp.mjs +++ b/tests/fixtures/android/jadx-mcp.mjs @@ -158,6 +158,7 @@ register("list_classes", ({ offset, limit, query = "" }) => { register("get_class_summary", () => json({ full_name: mode === "wrong-class" ? "fixture.Other" : "fixture.Target", + raw_name: mode === "wrong-class" ? "fixture.Other" : "fixture.Target", name: "Target", method_count: mode === "summary-error" ? -1 : methods.length, field_count: 0,