fix(android): preserve binary class selectors across metadata calls

This commit is contained in:
morluto
2026-10-24 16:33:39 +08:00
parent a45ee81167
commit 43c7255dbd
11 changed files with 232 additions and 10 deletions
@@ -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"
+9 -5
View File
@@ -269,12 +269,16 @@ class ReaJadxBridge {
"type", field.getType().toString(), "def_pos", 0)).toList();
List<String> inner = node.getInnerClasses().stream().map(ClassNode::getFullName).toList();
Map<String, Object> 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<JavaNode> 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);
+7
View File
@@ -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
+10
View File
@@ -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,
+7
View File
@@ -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",
);
+6 -3
View File
@@ -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 }),
+2 -1
View File
@@ -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,
+1 -1
View File
@@ -9,7 +9,7 @@ export const JADX_RELEASE = Object.freeze({
/** Identity of REA's metadata bridge, distinct from the producing engine. */
export const JADX_BRIDGE_IDENTITY = Object.freeze({
name: "rea-jadx-bridge",
version: "5",
version: "6",
});
/** Remediation when `REA_JADX_MCP_JAR` is missing or not an absolute path. */
+107
View File
@@ -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<string, unknown>[] = [];
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";
@@ -158,6 +158,32 @@ public class ReaJadxMetadataRegression {
return value;
}
private static void verifyRawClassSelectors(Method handle, Object bridge, JadxDecompiler decompiler)
throws Exception {
List<JavaClass> 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<String> 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(),
+1
View File
@@ -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,