fix(android): preserve metadata dialect and root manifest identity

Read Apktool 3's producer string dialect before YAML can discard hashes or
reinterpret values. Preserve older YAML behavior and decode producer escapes once.

Select the exact root AndroidManifest.xml resource in REA's JADX bridge and
derive summaries once with namespace-aware XML parsing. Preserve decoded text,
summarize before its display cap, invalidate cached facts with the loaded session,
and remove superseded upstream manifest handlers. Keep the engine pinned to 0.7.1.

Validated Apktool parser/provider and public adapter controls; compiled the bridge
against the verified pinned JAR and ran its real Java regressions plus public CLI
and stdio MCP manifest parity checks. Real Apktool execution remains unverified;
the new manifest fixtures use plain XML. No Android application was executed.

Fixes #6804
Fixes #6866
Fixes #6863
Fixes #6819
This commit is contained in:
morluto
2026-10-24 01:24:33 +08:00
parent f8f52f205a
commit d137a4cdde
9 changed files with 465 additions and 20 deletions
+107 -11
View File
@@ -10,6 +10,7 @@ import jadx.api.plugins.input.insns.Opcode;
import jadx.core.dex.info.MethodInfo;
import jadx.core.dex.nodes.*;
import java.io.PrintStream;
import java.io.StringReader;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.CountDownLatch;
@@ -23,6 +24,10 @@ import kotlin.coroutines.intrinsics.IntrinsicsKt;
import kotlinx.coroutines.BuildersKt;
import kotlinx.io.CoreKt;
import kotlinx.io.JvmCoreKt;
import javax.xml.parsers.DocumentBuilderFactory;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.xml.sax.InputSource;
/** REA-owned metadata boundary for the unmodified jadx-headless-mcp 0.7.1 JAR. */
class ReaJadxBridge {
@@ -30,10 +35,13 @@ class ReaJadxBridge {
private final SessionHolder holder;
private JadxSession indexedSession;
private Map<String, List<ClassSnapshot>> classes = Map.of();
private JadxSession manifestSession;
private ManifestSnapshot manifestSnapshot;
private record MethodSnapshot(String name, String rawName, String shortId, List<String> bodyAbsenceMarkers,
Map<String, Object> summary) {}
private record ClassSnapshot(JavaClass node, List<MethodSnapshot> methods, Map<String, Object> summary) {}
private record ManifestSnapshot(String text, Map<String, Object> summary) {}
private static final class InputFailure extends IllegalArgumentException {
final String field;
@@ -93,6 +101,7 @@ class ReaJadxBridge {
args.has("threads") ? integer(args, "threads", 1) : null,
args.has("code_cache_size") ? integer(args, "code_cache_size", 64) : null,
optionalString(args, "resources"));
clearManifest();
return loadCall(next -> holder.load(path, options, next), continuation);
}
@@ -153,6 +162,77 @@ class ReaJadxBridge {
return current;
}
private void clearManifest() {
manifestSession = null;
manifestSnapshot = null;
}
/** Original archive identity, never a deobfuscated basename or name suffix. */
private ManifestSnapshot manifest(JadxSession current) {
if (manifestSession != current || manifestSnapshot == null) {
ResourceFile selected = null;
for (ResourceFile resource : current.getResources()) {
if (resource.getType() != ResourceType.MANIFEST
|| !resource.getOriginalName().equals("AndroidManifest.xml")) continue;
if (selected != null)
throw new IllegalArgumentException("Root AndroidManifest.xml resource identity is ambiguous");
selected = resource;
}
if (selected == null) throw new IllegalArgumentException("Root AndroidManifest.xml not found");
var content = selected.loadContent();
if (content == null || content.getDataType() != jadx.core.xmlgen.ResContainer.DataType.TEXT)
throw new IllegalArgumentException("Root AndroidManifest.xml did not decode as text");
String xml = content.getText().getCodeStr();
ManifestSnapshot snapshot = new ManifestSnapshot(xml, manifestSummary(xml));
manifestSnapshot = snapshot;
manifestSession = current;
}
return manifestSnapshot;
}
/** One parsed XML owner supplies namespace-aware values alongside the exact decoded text. */
static Map<String, Object> manifestSummary(String xml) {
Element root;
try {
// Match REA's existing JDK XML setup: document types are unsupported.
// Parse an in-memory string; XInclude and external DTD/schema I/O stay disabled.
DocumentBuilderFactory factory = DocumentBuilderFactory.newDefaultNSInstance();
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
factory.setXIncludeAware(false);
factory.setAttribute(javax.xml.XMLConstants.ACCESS_EXTERNAL_DTD, "");
factory.setAttribute(javax.xml.XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
root = factory.newDocumentBuilder().parse(new InputSource(new StringReader(xml)))
.getDocumentElement();
} catch (javax.xml.parsers.ParserConfigurationException | org.xml.sax.SAXException
| java.io.IOException failure) {
throw new IllegalArgumentException("Root AndroidManifest.xml is not supported well-formed XML", failure);
}
if (!root.getTagName().equals("manifest") || root.getNamespaceURI() != null)
throw new IllegalArgumentException("AndroidManifest.xml root must be an unnamespaced <manifest>");
String android = "http://schemas.android.com/apk/res/android";
String minSdk = "";
String targetSdk = "";
List<String> permissions = new ArrayList<>();
for (Node child = root.getFirstChild(); child != null; child = child.getNextSibling()) {
if (!(child instanceof Element element) || element.getNamespaceURI() != null) continue;
if (element.getTagName().equals("uses-sdk")) {
minSdk = element.getAttributeNS(android, "minSdkVersion");
targetSdk = element.getAttributeNS(android, "targetSdkVersion");
} else if (element.getTagName().equals("uses-permission")) {
String permission = element.getAttributeNS(android, "name");
if (!permission.isEmpty()) permissions.add(permission);
}
}
Map<String, Object> summary = new LinkedHashMap<>();
summary.put("package", root.getAttribute("package"));
summary.put("version_name", root.getAttributeNS(android, "versionName"));
summary.put("version_code", root.getAttributeNS(android, "versionCode"));
summary.put("min_sdk", minSdk);
summary.put("target_sdk", targetSdk);
summary.put("permissions", List.copyOf(permissions));
return Collections.unmodifiableMap(summary);
}
private ClassSnapshot selected(JsonObject args) {
JadxSession current = session();
if (indexedSession != current) {
@@ -333,6 +413,9 @@ class ReaJadxBridge {
"java_version", System.getProperty("java.version"),
"metadata_scope", "parsed_members"));
JadxSession current = session();
if (name.equals("get_app_info")) return json(manifest(current).summary());
if (name.equals("get_android_manifest"))
return text(TextUtilKt.truncateToBytes(manifest(current).text(), current.getMaxSourceBytes()), false);
if (name.equals("list_classes")) {
List<String> names = current.getClassFqns();
String query = args.has("query") ? string(args, "query") : "";
@@ -420,6 +503,17 @@ class ReaJadxBridge {
return json(result);
}
private static void registerBridgeTool(Server server, ReaJadxBridge bridge, String name,
ToolSchema schema, String description) {
Tool tool = new Tool(name, schema, description, null, null, null, null, null, null);
server.addTool(tool, (connection, request, continuation) -> {
try { return bridge.handle(name, request.getArguments()); }
catch (InputFailure error) { return text(JSON.toJson(Map.of(
"kind", "invalid-input", "field", error.field, "message", error.getMessage())), true); }
catch (IllegalArgumentException error) { return text(error.getMessage(), true); }
});
}
public static void main(String[] args) throws Exception {
PrintStream protocolOut = System.out;
System.setOut(System.err);
@@ -428,10 +522,9 @@ 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", "4", null, null, null),
Server server = new Server(new Implementation("rea-jadx-bridge", "5", null, null, null),
new ServerOptions(capabilities), "", ignored -> Unit.INSTANCE);
SessionToolsKt.registerSessionTools(server, holder);
ManifestToolsKt.registerManifestTools(server, holder);
ClassToolsKt.registerClassTools(server, holder);
MethodToolsKt.registerMethodTools(server, holder);
XrefToolsKt.registerXrefTools(server, holder);
@@ -440,7 +533,7 @@ class ReaJadxBridge {
server.removeTool("load_apk");
server.addTool(loadTool, (connection, request, continuation) ->
bridge.load(request.getArguments(), defaults, continuation));
Set<String> consumed = Set.of("load_apk", "get_app_info", "get_android_manifest",
Set<String> consumed = Set.of("load_apk",
"list_classes", "get_class_summary", "get_method_body",
"get_xrefs_to_class", "get_xrefs_to_method");
for (String name : new ArrayList<>(server.getTools().keySet()))
@@ -449,6 +542,13 @@ class ReaJadxBridge {
new ToolSchema(null, new kotlinx.serialization.json.JsonObject(Map.of()), List.of(), null),
"Observed worker JVM and metadata scope", null, null, null, null, null, null);
server.addTool(diagnostics, (connection, request, continuation) -> bridge.handle("rea_jvm_status", request.getArguments()));
for (String name : List.of("get_app_info", "get_android_manifest")) {
String description = name.equals("get_app_info")
? "Namespace-aware summary of the exact root AndroidManifest.xml resource."
: "Exact decoded root AndroidManifest.xml text, bounded by the session source byte limit.";
registerBridgeTool(server, bridge, name,
new ToolSchema(null, new kotlinx.serialization.json.JsonObject(Map.of()), List.of(), null), description);
}
for (String name : List.of("list_classes", "get_class_summary",
"get_method_body", "get_xrefs_to_class", "get_xrefs_to_method")) {
Tool tool = server.getTools().get(name).getTool();
@@ -470,14 +570,9 @@ class ReaJadxBridge {
case "get_method_body" -> "Source for an exact class and stable same-name overload index; static initializers use smali to preserve folded field initialization.";
default -> "Complete static using-node inventory for an exact class or selected method overload; one node may contain multiple invoke instructions, and source lines are unresolved.";
};
Tool narrowed = new Tool(name, new ToolSchema(null, new kotlinx.serialization.json.JsonObject(properties),
tool.getInputSchema().getRequired(), null), description, null, null, null, null, null, null);
server.addTool(narrowed, (connection, request, continuation) -> {
try { return bridge.handle(name, request.getArguments()); }
catch (InputFailure error) { return text(JSON.toJson(Map.of(
"kind", "invalid-input", "field", error.field, "message", error.getMessage())), true); }
catch (IllegalArgumentException error) { return text(error.getMessage(), true); }
});
registerBridgeTool(server, bridge, name,
new ToolSchema(null, new kotlinx.serialization.json.JsonObject(properties),
tool.getInputSchema().getRequired(), null), description);
}
CountDownLatch done = new CountDownLatch(1);
server.onClose(() -> { done.countDown(); return Unit.INSTANCE; });
@@ -503,6 +598,7 @@ class ReaJadxBridge {
BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE,
(scope, continuation) -> server.close(continuation));
} finally {
bridge.clearManifest();
BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE,
(scope, continuation) -> holder.unload(continuation));
}
+8 -2
View File
@@ -171,8 +171,14 @@ the same source byte limit and explicit truncation reporting.
## Interpret the result
- Package inspection retains decoded manifest XML, summary declarations,
permissions and class/resource counts. Summary fields are extracted by the
upstream engine and should be checked against the retained XML when needed.
permissions and class/resource counts. REA's bridge selects the exact root
`AndroidManifest.xml` resource by original archive name and manifest type,
independently of asset order. One namespace-aware JDK XML parse supplies
summary attributes and direct permission/SDK declarations; namespace prefixes
and XML entity spelling do not change their values. Raw decoded XML is retained
exactly and only its displayed text is subject to the existing source byte cap.
Summaries use the complete selected XML before that cap. Document types are
unsupported; parsing does not load external DTDs, schemas, or XInclude files.
Signature verification is explicitly `not_performed`.
- Class search filters the engine's cached top-level names before transferring
pages, then follows contiguous inventory positions without rescanning earlier
+8
View File
@@ -96,6 +96,14 @@ still suppresses all string projection, including in this mode.
- **Metadata comes from apktool.yml as apktool printed it.** Version codes
keep their own spelling (`'7'` versus `7`); `package_name` is read from
the decoded manifest, not inferred.
Apktool 3's emitted `version: 3.x.y` header selects its single-line string
dialect: literal hashes, single quotes, empty strings, and the text `null`
stay part of the value; producer backslashes and Unicode escapes decode
once. Older/tagged documents retain YAML scalar semantics; double-quoted
values also retain YAML string semantics.
This distinction follows the released
[3.0.3 metadata writer](https://github.com/iBotPeaches/Apktool/blob/18b5e99cb56ff9451e8aa55b065dcf5bbd616975/brut.apktool/apktool-lib/src/main/java/brut/androlib/meta/VersionInfo.java#L49-L55)
and [string escape owner](https://github.com/iBotPeaches/Apktool/blob/18b5e99cb56ff9451e8aa55b065dcf5bbd616975/brut.j.yaml/src/main/java/brut/yaml/YamlStringEscapeUtils.java#L55-L116).
- **The manifest is the decoded text**, bounded at 512 KiB and reported as
truncated when larger.
- **Strings are projected verbatim.** Escaped entities stay escaped, and
+7
View File
@@ -93,6 +93,10 @@ export const verifyAndroidMetadata = async ({
result.stdout,
/DIRECT_INVOKE_REGRESSION_PASS nodes=1 offsets=0,3 cross_dex=true/u,
);
assert.match(
result.stdout,
/MANIFEST_REGRESSION_PASS root=true namespaces=true entities=true reload=true missing=true bounded=true/u,
);
console.log(
"PASS real cold metadata, full generic types and stable overloaded method selection",
);
@@ -108,6 +112,9 @@ export const verifyAndroidMetadata = async ({
console.log(
"PASS real raw DEX direct-invoke offsets and cross-DEX source identity",
);
console.log(
"PASS root manifest selection, namespace/entity values and session cache identity",
);
await verifyAndroidMethodBodies({
apk: resolve(classes, "initializers.apk"),
repository,
+2 -2
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: "4",
version: "5",
});
/** Remediation when `REA_JADX_MCP_JAR` is missing or not an absolute path. */
@@ -62,5 +62,5 @@ export const JADX_LIMITATIONS = [
"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.",
"Manifest summaries are parsed once by REA's bridge from the exact root AndroidManifest.xml resource, using namespace-aware XML attributes; decoded XML is retained. APK signatures, split APKs, native libraries and Android runtime capture are not verified by this provider.",
] as const;
+81
View File
@@ -107,6 +107,87 @@ describe("parseApktoolYml", () => {
targetSdkVersion: null,
});
});
// Source-derived fixtures from Apktool 3.0.3 VersionInfo.write and
// YamlStringEscapeUtils at 18b5e99cb56ff9451e8aa55b065dcf5bbd616975.
it.each([
["1.0 #beta", "1.0 #beta"],
[String.raw`1.0\\beta`, String.raw`1.0\beta`],
[String.raw`1.0 \uD83D\uDE80`, "1.0 🚀"],
[String.raw`literal \\uD83D\\uDE80`, String.raw`literal \uD83D\uDE80`],
[String.raw`release \"quoted\"`, 'release "quoted"'],
["'release'", "'release'"],
["1.0: beta", "1.0: beta"],
["[beta]", "[beta]"],
[" release ", " release "],
["release\u2028beta", "release\u2028beta"],
["null", "null"],
["", ""],
['"1.0 #beta"', "1.0 #beta"],
])(
"reads Apktool 3 version string %j without YAML scalar loss",
(source, expected) => {
expect(
parseApktoolYml(
`version: 3.0.3\nversionInfo:\n versionCode: 7\n versionName: ${source}\nsdkInfo:\n minSdkVersion: 24\n targetSdkVersion: 35\n`,
),
).toEqual({
versionName: expected,
versionCode: "7",
minSdkVersion: "24",
targetSdkVersion: "35",
});
},
);
it("keeps Apktool 2 quoted and plain YAML semantics", () => {
expect(
parseApktoolYml(
String.raw`version: 2.7.0
versionInfo:
versionName: 'literal \n #beta'
versionCode: 0045
sdkInfo:
minSdkVersion: null
targetSdkVersion: 35 # comment
`,
),
).toEqual({
versionName: String.raw`literal \n #beta`,
versionCode: "0045",
minSdkVersion: null,
targetSdkVersion: "35",
});
});
it("keeps Apktool 3 field ownership across nested decoys and CRLF", () => {
expect(
parseApktoolYml(
[
"version: 3.0.3",
"versionName: root decoy",
"versionInfo:",
" custom:",
" versionName: nested decoy",
" versionName: 1.0 #beta",
"other:",
" versionName: unrelated decoy",
"",
].join("\r\n"),
),
).toMatchObject({ versionName: "1.0 #beta" });
});
it.each([
"versionInfo:\n versionName: one\n versionName: two\n",
"versionInfo:\n versionName: one\nversionInfo:\n versionName: two\n",
"version: 2.7.0\nversionInfo:\n versionName: ambiguous\n",
String.raw`versionInfo:
versionName: invalid\uZZZZ
`,
])("rejects ambiguous or malformed Apktool 3 fields", (fields) => {
expect(() => parseApktoolYml(`version: 3.0.3\n${fields}`)).toThrow();
});
});
describe("parseManifestPackage", () => {
+81 -3
View File
@@ -23,11 +23,15 @@ const metaInfoTag = {
};
/**
* Read values from Apktool's tagged metadata document. YAML's core schema
* distinguishes null from quoted strings; numeric values use their source
* range so version codes retain the spelling Apktool emitted.
* Read the producer's metadata dialect once. Apktool 3 emits an untagged,
* line-oriented document with Java-escaped strings; older tagged documents
* use YAML core semantics. Numeric values retain their source spelling.
*/
export const parseApktoolYml = (yml: string): ApktoolMetadata => {
const source = yml.replace(/^\uFEFF/u, "");
const header = source.split(/\r\n?|\n/u, 1)[0] ?? "";
if (/^version: 3\.\d+\.\d+(?:[-+][0-9A-Za-z.+-]+)?$/u.test(header))
return parseApktool3Metadata(source);
const document = parseDocument(yml, {
schema: "core",
customTags: [metaInfoTag],
@@ -52,6 +56,80 @@ export const parseApktoolYml = (yml: string): ApktoolMetadata => {
};
};
/** Fields written by VersionInfo.write and SdkInfo.write in Apktool 3. */
const apktool3Fields = new Map<string, keyof ApktoolMetadata>([
["versionInfo.versionName", "versionName"],
["versionInfo.versionCode", "versionCode"],
["sdkInfo.minSdkVersion", "minSdkVersion"],
["sdkInfo.targetSdkVersion", "targetSdkVersion"],
]);
/**
* Apktool 3.0.3 YamlWriter writes strings without YAML quoting or comment
* syntax. Read its direct mapping records before a YAML parser can discard
* hashes, consume quotes, or reinterpret an empty string or literal `null`.
*/
const parseApktool3Metadata = (source: string): ApktoolMetadata => {
const values = new Map<keyof ApktoolMetadata, string>();
const sections = new Set<string>();
let section: string | undefined;
for (const line of source.split(/\r\n?|\n/u)) {
if (line.trim() === "" || /^[#!]/u.test(line)) continue;
if (!line.startsWith(" ")) {
const separator = line.indexOf(":");
section = separator === -1 ? undefined : line.slice(0, separator);
if (section === "version") {
if (sections.has(section))
throw new TypeError("apktool.yml has a duplicate producer version");
sections.add(section);
continue;
}
if (section !== "versionInfo" && section !== "sdkInfo") continue;
if (line.slice(separator + 1).trim() !== "" || sections.has(section))
throw new TypeError(`apktool.yml has an invalid ${section} mapping`);
sections.add(section);
continue;
}
const record = /^ ([^ :]+):(?: (.*))?$/su.exec(line);
if (record === null) continue;
const path = `${section ?? ""}.${record[1] ?? ""}`;
const field = apktool3Fields.get(path);
if (field === undefined) continue;
if (values.has(field))
throw new TypeError(`apktool.yml has a duplicate ${path} field`);
const value = record[2] ?? "";
values.set(field, field === "versionCode" ? value : apktool3String(value));
}
return {
versionName: values.get("versionName") ?? null,
versionCode: values.get("versionCode") ?? null,
minSdkVersion: values.get("minSdkVersion") ?? null,
targetSdkVersion: values.get("targetSdkVersion") ?? null,
};
};
const apktool3String = (source: string): string => {
// The producer escapes every double quote. An unescaped opening double
// quote therefore belongs to a standard quoted-YAML compatibility input.
if (source.startsWith('"')) {
const document = parseDocument(source, { schema: "core", strict: true });
if (
document.errors.length === 0 &&
isScalar(document.contents) &&
typeof document.contents.value === "string"
)
return document.contents.value;
throw new TypeError("apktool.yml has an invalid quoted string");
}
// Every escape emitted by YamlStringEscapeUtils is also a JSON string
// escape, including UTF-16 surrogate pairs. Decode once, preserving a
// doubled backslash followed by literal `u` or `n` as source text.
const decoded: unknown = JSON.parse(`"${source}"`);
if (typeof decoded !== "string")
throw new TypeError("apktool.yml has an invalid Apktool 3 string");
return decoded;
};
/** Read the package attribute from the decoded Android manifest root. */
export const parseManifestPackage = (manifest: string): string | null => {
const parsed = parseXmlDocument(manifest, {
+53 -1
View File
@@ -108,6 +108,7 @@ const decodeStub = (
readonly manifestFile?: string;
readonly stringsFile?: string;
readonly stringsXml?: string;
readonly version?: string;
readonly extraValuesDirectories?: readonly string[];
} = {},
): ((root: string) => string) => {
@@ -133,7 +134,11 @@ const decodeStub = (
`printf '%s\\n' '<resources><string name="wrong">Wrong</string></resources>' > "$5/res/values-${name}/strings.xml"`,
)
.join("\n ");
return () => `if [ "$1" = "--version" ]; then ${VERSION_BLOCK}; exit 0; fi
const versionBlock =
options.version === undefined
? VERSION_BLOCK
: `printf '%s\\n' '${options.version.replace(/'/gu, "'\\''")}'`;
return () => `if [ "$1" = "--version" ]; then ${versionBlock}; exit 0; fi
if [ "$1" = "d" ]; then
${
options.failDecode === true
@@ -201,6 +206,53 @@ it("returns table-only facts for a single selected primary table", async () => {
});
});
it("publishes Apktool 3 producer-escaped metadata through the shared evidence workflow", async () => {
const { command, root } = await writeApktoolStub((path) =>
decodeStub({
ymlFile: join(path, "producer.yml"),
version: "3.0.3",
})(path),
);
// A synthetic decoded workspace using the released 3.0.3 writer's format;
// this exercises the production provider, not a real Apktool decoder.
await writeFile(
join(root, "producer.yml"),
String.raw`version: 3.0.3
versionInfo:
versionCode: 7
versionName: 1.0 #beta \\ \uD83D\uDE80 \\u0041
sdkInfo:
minSdkVersion: 24
targetSdkVersion: 35
`,
);
const apk = await apkFixture(root);
const original = await readFile(apk.path);
const provider = providerOver(command);
onTestFinished(() => provider.close());
const outcome = await new ApktoolResourceAnalysisService(provider).execute(
"decode_android_resources",
{ path: apk.path },
);
if (!outcome.ok) throw outcome.error;
expect(outcome.value).toMatchObject({
parameters: { path: apk.path },
subject: { digest: { sha256: apk.sha256 } },
normalized_result: {
client: { apktool_version: "3.0.3" },
target: { path: apk.path, sha256: apk.sha256, bytes: apk.bytes },
metadata: {
version_name: String.raw`1.0 #beta \ 🚀 \u0041`,
version_code: "7",
min_sdk_version: "24",
target_sdk_version: "35",
},
coverage: "complete",
},
});
expect(await readFile(apk.path)).toEqual(original);
});
it.each([
[false, false],
[true, false],
@@ -22,6 +22,11 @@ public class ReaJadxMetadataRegression {
private static JsonObject call(Method handle, Object bridge, String operation, JsonObject arguments)
throws Exception {
return JsonParser.parseString(callText(handle, bridge, operation, arguments)).getAsJsonObject();
}
private static String callText(Method handle, Object bridge, String operation, JsonObject arguments)
throws Exception {
var input = (kotlinx.serialization.json.JsonObject)
kotlinx.serialization.json.Json.Default.parseToJsonElement(arguments.toString());
CallToolResult response;
@@ -33,7 +38,118 @@ public class ReaJadxMetadataRegression {
require(!Boolean.TRUE.equals(response.isError()), operation + " returned an error");
require(response.getContent().size() == 1 && response.getContent().get(0) instanceof TextContent,
"Unsupported response envelope");
return JsonParser.parseString(((TextContent) response.getContent().get(0)).getText()).getAsJsonObject();
return ((TextContent) response.getContent().get(0)).getText();
}
private static void verifyManifestSummary() {
for (String prefix : List.of("android", "a")) {
for (String encoded : List.of("one &amp; beta", "one &amp;amp; beta")) {
String xml = "<manifest xmlns:" + prefix + "=\"http://schemas.android.com/apk/res/android\""
+ " xmlns:other=\"urn:unrelated\" package=\"org.example.root\" "
+ prefix + ":versionName=\"" + encoded + "\" " + prefix + ":versionCode=\"7\""
+ " other:versionName=\"decoy\"><uses-sdk " + prefix + ":minSdkVersion=\"23\" "
+ prefix + ":targetSdkVersion=\"35\"/><uses-permission " + prefix
+ ":name=\"org.example.MARKER\"/><application><uses-permission " + prefix
+ ":name=\"nested.decoy\"/></application></manifest>";
Map<String, Object> summary = ReaJadxBridge.manifestSummary(xml);
require(summary.get("package").equals("org.example.root"), "Wrong manifest package");
require(summary.get("version_name").equals(
encoded.equals("one &amp; beta") ? "one & beta" : "one &amp; beta"),
"Version name lost XML entity identity: " + summary);
require(summary.get("version_code").equals("7") && summary.get("min_sdk").equals("23")
&& summary.get("target_sdk").equals("35"), "Namespace prefix changed summary values");
require(summary.get("permissions").equals(List.of("org.example.MARKER")),
"Permission summary included a nested or unrelated declaration");
}
}
for (String xml : List.of("<other/>", "<manifest><application></manifest>",
"<!DOCTYPE manifest SYSTEM 'file:///rea-unread-external.dtd'><manifest/>")) {
boolean refused = false;
try { ReaJadxBridge.manifestSummary(xml); }
catch (IllegalArgumentException expected) { refused = true; }
require(refused, "Malformed or externally described XML was accepted");
}
}
private static void verifyRootManifests(SessionHolder holder, SessionConfig config,
Constructor<?> constructor, Method handle, Path output) throws Exception {
verifyManifestSummary();
Object bridge = constructor.newInstance(holder);
for (boolean assetFirst : List.of(true, false)) {
String pkg = assetFirst ? "org.example.first" : "org.example.second";
String root = "<manifest xmlns:a=\"http://schemas.android.com/apk/res/android\" package=\""
+ pkg + "\" a:versionName=\"one &amp; beta\" a:versionCode=\"7\"/>";
String asset = "<manifest package=\"org.example.asset\"/>";
Path apk = output.resolve(assetFirst ? "manifest-asset-first.apk" : "manifest-root-first.apk");
try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(apk))) {
for (String path : assetFirst
? List.of("assets/AndroidManifest.xml", "AndroidManifest.xml")
: List.of("AndroidManifest.xml", "assets/AndroidManifest.xml")) {
zip.putNextEntry(new ZipEntry(path));
zip.write((path.equals("AndroidManifest.xml") ? root : asset).getBytes(StandardCharsets.UTF_8));
zip.closeEntry();
}
}
BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE,
(scope, continuation) -> holder.unload(continuation));
BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE,
(scope, continuation) -> holder.load(apk.toString(), config, continuation));
JsonObject summary = call(handle, bridge, "get_app_info", new JsonObject());
require(summary.get("package").getAsString().equals(pkg), "Asset or previous session supplied package facts");
require(summary.get("version_name").getAsString().equals("one & beta"), "Wrong root version value");
String raw = holder.current().getResources().stream()
.filter(resource -> resource.getOriginalName().equals("AndroidManifest.xml"))
.findFirst().orElseThrow().loadContent().getText().getCodeStr();
require(callText(handle, bridge, "get_android_manifest", new JsonObject()).equals(raw),
"Bridge altered the decoded root resource text");
}
Path large = output.resolve("manifest-bounded-text.apk");
String largeXml = "<manifest xmlns:a=\"http://schemas.android.com/apk/res/android\""
+ " package=\"org.example.large\">" + " ".repeat(config.getMaxSourceBytes() + 32)
+ "<uses-sdk a:minSdkVersion=\"23\" a:targetSdkVersion=\"35\"/></manifest>";
try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(large))) {
zip.putNextEntry(new ZipEntry("AndroidManifest.xml"));
zip.write(largeXml.getBytes(StandardCharsets.UTF_8));
zip.closeEntry();
}
BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE,
(scope, continuation) -> holder.unload(continuation));
BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE,
(scope, continuation) -> holder.load(large.toString(), config, continuation));
JsonObject largeSummary = call(handle, bridge, "get_app_info", new JsonObject());
require(largeSummary.get("target_sdk").getAsString().equals("35"),
"Summary was derived from truncated manifest text");
String largeRaw = holder.current().getResources().stream()
.filter(resource -> resource.getOriginalName().equals("AndroidManifest.xml"))
.findFirst().orElseThrow().loadContent().getText().getCodeStr();
require(largeRaw.getBytes(StandardCharsets.UTF_8).length > config.getMaxSourceBytes(),
"Decoded manifest did not exceed the source byte limit");
int targetAt = largeRaw.indexOf("targetSdkVersion");
require(targetAt >= 0 && largeRaw.substring(0, targetAt).getBytes(StandardCharsets.UTF_8).length
> config.getMaxSourceBytes(), "SDK declaration was not beyond the displayed text cap");
require(callText(handle, bridge, "get_android_manifest", new JsonObject())
.equals(TextUtilKt.truncateToBytes(largeRaw, config.getMaxSourceBytes())),
"Manifest text did not retain the existing source byte limit");
Path missing = output.resolve("manifest-asset-only.apk");
try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(missing))) {
zip.putNextEntry(new ZipEntry("assets/AndroidManifest.xml"));
zip.write("<manifest package=\"org.example.asset\"/>".getBytes(StandardCharsets.UTF_8));
zip.closeEntry();
}
BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE,
(scope, continuation) -> holder.unload(continuation));
BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE,
(scope, continuation) -> holder.load(missing.toString(), config, continuation));
var input = (kotlinx.serialization.json.JsonObject)
kotlinx.serialization.json.Json.Default.parseToJsonElement("{}");
boolean refused = false;
try { handle.invoke(bridge, "get_app_info", input); }
catch (InvocationTargetException failure) {
refused = failure.getCause() instanceof IllegalArgumentException
&& failure.getCause().getMessage().contains("Root AndroidManifest.xml not found");
}
require(refused, "Missing root reused an asset or previous-session manifest");
System.out.println("MANIFEST_REGRESSION_PASS root=true namespaces=true entities=true reload=true missing=true bounded=true");
}
private static JsonObject arguments(String className) {
@@ -500,6 +616,7 @@ public class ReaJadxMetadataRegression {
verifyInitializers(holder, config, constructor, handle, initializerFixture(args[1], args[2]));
verifyDirectInvokes(holder, config, constructor, handle,
Path.of(args[3]), Path.of(args[2]));
verifyRootManifests(holder, config, constructor, handle, Path.of(args[2]));
} finally {
BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE,
(scope, continuation) -> holder.unload(continuation));