feat(apktool): share one APK resource decode across selected locale tables (#4777) (#4804)

Project default and multiple selected localized string tables from one
immutable decode pass. Remove the need for repeated APK snapshotting,
digesting, version probing, apktool d invocations, and workspace enumeration.

- Contract: add optional locales array to decode_android_resources input
  schema (bounded to 20 locales) and structured string_tables to output schema
  with per-table status, strings, total_count, unparsed_count, and coverage.
  Preserve legacy strings and locale fields for complete backward compatibility.
- Bounds: enforce 32 MiB aggregate XML read cap and 10,000 aggregate string
  entries cap across batched projections. Handle missing, oversized, and
  malformed tables per-table without failing or losing completed tables.
- Lifecycle: maintain strict descriptor cleanup and provider-owned workspace
  removal in finally, preserving typed cancellation and failure details.
- Adapters: add --locales repeatable option to CLI and update MCP contract.

Validation: 10 service tests, 3 CLI boundary tests, and MCP boundary schema
tests pass. Fast typecheck/lint and turbo docs:check:built pass.
Fixes #4777
This commit is contained in:
Abhay Tiwari authored and GitHub committed 2026-10-18 03:57:17 +08:00
1 parent 3b5605ecd2
commit 6668424343
10 files changed
+804 -89

No files matched your search

+9 -2
View File
@@ -62,13 +62,15 @@ reports `capability_unavailable`.
rea inspect-apktool-client
rea decode-android-resources /targets/Example.apk
rea decode-android-resources /targets/Example.apk --locale de
rea decode-android-resources /targets/Example.apk --locales de --locales pt-BR
rea decode-android-resources /targets/Example.apk --no-strings
```
Equivalent MCP methods are `inspect_apktool_client` and
`decode_android_resources`. The decode request takes `path`, an optional
`locale` (project `res/values-<locale>/strings.xml` instead of the default
table), and `include_strings` (default true). Regional locales such as `pt-BR`
primary `locale` (project `res/values-<locale>/strings.xml` instead of the default
table), `locales` (repeatable array of additional localized string tables to project
from the same single decode pass), and `include_strings` (default true). Regional locales such as `pt-BR`
select Android's `values-pt-rBR`; Android qualifiers such as `pt-rBR` and
`b+zh+Hans+CN` may also be supplied directly.
@@ -102,6 +104,11 @@ select Android's `values-pt-rBR`; Android qualifiers such as `pt-rBR` and
strings-table paths are disclosed with partial coverage. Discovery uses
the same bounded traversal as the file count; filesystem failures remain
failures rather than being interpreted as missing tables.
When multiple tables are requested with `locales`, `string_tables` records
each projected table with its locale, status (`present`, `missing`,
`oversized`, `malformed`, or `skipped`), entry counts, and coverage,
bounded at an aggregate budget of 10,000 strings and 32 MiB XML. When `locales`
is omitted, legacy single-table payloads are preserved without adding `string_tables`.
- **Smali is skipped by design** (`apktool d --no-src`): Java-source
recovery belongs to the JADX family. Resource smali edits and rebuild are
a follow-up lane with mutation authority.
+23
View File
@@ -103,6 +103,29 @@ try {
Array.isArray(locale.strings),
`${locale.strings.length} string(s)`,
);
const selectedLocales = decoded.locales.slice(0, 2);
const batched = await run("decode_android_resources", {
path: options.apk,
include_strings: true,
locales: selectedLocales,
});
if (batched !== null) {
const expectedLocales = [null, ...selectedLocales];
const hasExpectedTables =
Array.isArray(batched.string_tables) &&
expectedLocales.every((expected) =>
batched.string_tables.some(
(table) =>
table.locale === expected && table.status === "present",
),
);
check(
"batched string tables projected with expected locales and status",
hasExpectedTables,
`${String(batched.string_tables?.length)} table(s) present`,
);
}
}
}
}
+216
View File
@@ -535,6 +535,222 @@ it("uses an owned APK snapshot and framework cache and resolves regional locales
expect(result.strings).toEqual([{ name: "greeting", value: "Olá" }]);
});
it("shares one decode across default and selected locale tables", async () => {
const { command, root } = await writeApktoolStub((root) =>
decodeStub({ withGerman: false })(root).replace(
'if [ "$1" = "d" ]; then',
`if [ "$1" = "d" ]; then
printf 'd\\n' >> "${root}/decode-invocations.log"
mkdir -p "$5/res/values-de" "$5/res/values-pt-rBR"
printf '<resources><string name="greeting">Hallo</string></resources>' > "$5/res/values-de/strings.xml"
printf '<resources><string name="greeting">Olá</string></resources>' > "$5/res/values-pt-rBR/strings.xml"`,
),
);
const apk = await apkFixture(root);
const observation = await new ApktoolResourceAnalysisService(
providerOver(command),
).execute("decode_android_resources", {
path: apk.path,
include_strings: true,
locales: ["de", "pt-BR"],
});
if (!observation.ok) throw observation.error;
const result = apktoolResultSchemas.decode_android_resources.parse(
observation.value.normalized_result,
);
expect(result.string_tables).toEqual([
{
locale: null,
status: "present",
strings: [
{ name: "api_endpoint", value: "https://api.example.com/v1" },
{ name: "app_name", value: "ApktoolProbe" },
],
total_count: 2,
unparsed_count: 0,
coverage: "complete",
},
{
locale: "de",
status: "present",
strings: [{ name: "greeting", value: "Hallo" }],
total_count: 1,
unparsed_count: 0,
coverage: "complete",
},
{
locale: "pt-BR",
status: "present",
strings: [{ name: "greeting", value: "Olá" }],
total_count: 1,
unparsed_count: 0,
coverage: "complete",
},
]);
expect(result.strings).toEqual([
{ name: "api_endpoint", value: "https://api.example.com/v1" },
{ name: "app_name", value: "ApktoolProbe" },
]);
expect(result.locale).toBe(null);
expect(result.coverage).toBe("complete");
const invocations = (
await readFile(join(root, "decode-invocations.log"), "utf8").catch(() => "")
)
.trim()
.split("\n")
.filter((line) => line.length > 0);
expect(invocations).toHaveLength(1);
});
it("reuses normalized directory table for locale aliases without extra reads", async () => {
const { command, root } = await writeApktoolStub((root) =>
decodeStub()(root).replace(
'if [ "$1" = "d" ]; then',
`if [ "$1" = "d" ]; then
printf 'd\\n' >> "${root}/decode-invocations.log"
mkdir -p "$5/res/values-pt-rBR"
printf '<resources><string name="greeting">Olá</string></resources>' > "$5/res/values-pt-rBR/strings.xml"`,
),
);
const apk = await apkFixture(root);
const observation = await new ApktoolResourceAnalysisService(
providerOver(command),
).execute("decode_android_resources", {
path: apk.path,
include_strings: true,
locale: "pt-BR",
locales: ["pt-rBR"],
});
if (!observation.ok) throw observation.error;
const result = apktoolResultSchemas.decode_android_resources.parse(
observation.value.normalized_result,
);
expect(result.string_tables).toEqual([
{
locale: "pt-BR",
status: "present",
strings: [{ name: "greeting", value: "Olá" }],
total_count: 1,
unparsed_count: 0,
coverage: "complete",
},
{
locale: "pt-rBR",
status: "present",
strings: [{ name: "greeting", value: "Olá" }],
total_count: 1,
unparsed_count: 0,
coverage: "complete",
},
]);
const invocations = (
await readFile(join(root, "decode-invocations.log"), "utf8").catch(() => "")
)
.trim()
.split("\n")
.filter((line) => line.length > 0);
expect(invocations).toHaveLength(1);
});
it("skips all string tables when include_strings is false despite locales requested", async () => {
const { command, root } = await writeApktoolStub((root) =>
decodeStub({ withGerman: false })(root).replace(
'if [ "$1" = "d" ]; then',
`if [ "$1" = "d" ]; then
mkdir -p "$5/res/values-de"
printf '<resources><string name="greeting">Hallo</string></resources>' > "$5/res/values-de/strings.xml"`,
),
);
const apk = await apkFixture(root);
const observation = await new ApktoolResourceAnalysisService(
providerOver(command),
).execute("decode_android_resources", {
path: apk.path,
include_strings: false,
locales: ["de"],
});
if (!observation.ok) throw observation.error;
const result = apktoolResultSchemas.decode_android_resources.parse(
observation.value.normalized_result,
);
expect(result.string_tables).toEqual([]);
expect(result.strings).toEqual([]);
});
it("marks table with styled markup present with partial coverage and unparsed count", async () => {
const { command, root } = await writeApktoolStub((root) =>
decodeStub({ withGerman: false })(root).replace(
'if [ "$1" = "d" ]; then',
`if [ "$1" = "d" ]; then
mkdir -p "$5/res/values-de"
printf '<resources><string name="styled">Hallo <b>Welt</b></string></resources>' > "$5/res/values-de/strings.xml"`,
),
);
const apk = await apkFixture(root);
const observation = await new ApktoolResourceAnalysisService(
providerOver(command),
).execute("decode_android_resources", {
path: apk.path,
include_strings: true,
locales: ["de"],
});
if (!observation.ok) throw observation.error;
const result = apktoolResultSchemas.decode_android_resources.parse(
observation.value.normalized_result,
);
const deTable = result.string_tables?.find((t) => t.locale === "de");
expect(deTable).toEqual({
locale: "de",
status: "present",
strings: [],
total_count: 0,
unparsed_count: 1,
coverage: "partial",
});
});
it("skips remaining tables without reading or parsing once aggregate string budget is exhausted", async () => {
const { command, root } = await writeApktoolStub((root) =>
decodeStub({
withGerman: false,
stringsFile: join(root, "big-strings.xml"),
})(root).replace(
'if [ "$1" = "d" ]; then',
`if [ "$1" = "d" ]; then
mkdir -p "$5/res/values-de" "$5/res/values-es" "$5/res/values-it" "$5/res/values-fr"
cp '${join(root, "big-strings.xml")}' "$5/res/values-de/strings.xml"
cp '${join(root, "big-strings.xml")}' "$5/res/values-es/strings.xml"
cp '${join(root, "big-strings.xml")}' "$5/res/values-it/strings.xml"
printf '<resources><broken' > "$5/res/values-fr/strings.xml"`,
),
);
const bigStrings = `<resources>${Array.from({ length: 2000 }, (_, i) => `<string name="s_${String(i)}">val</string>`).join("")}</resources>`;
await writeFile(join(root, "big-strings.xml"), bigStrings);
const apk = await apkFixture(root);
const observation = await new ApktoolResourceAnalysisService(
providerOver(command),
).execute("decode_android_resources", {
path: apk.path,
include_strings: true,
locales: ["de", "es", "it", "fr"],
});
if (!observation.ok) throw observation.error;
const result = apktoolResultSchemas.decode_android_resources.parse(
observation.value.normalized_result,
);
const frTable = result.string_tables?.find((t) => t.locale === "fr");
expect(frTable).toEqual({
locale: "fr",
status: "skipped",
strings: [],
total_count: null,
unparsed_count: null,
coverage: "partial",
});
expect(result.coverage).toBe("partial");
});
it("marks an oversized strings document partial without claiming it is missing", async () => {
const { command, root } = await writeApktoolStub((root) =>
decodeStub({ stringsFile: join(root, "large-strings.xml") })(root),
+371 -85
View File
@@ -32,7 +32,11 @@ import {
} from "../application/AnalysisProvider.js";
import {
androidLocaleDirectorySuffix,
AGGREGATE_STRINGS_XML_MAX_BYTES,
MAX_AGGREGATE_STRINGS,
MAX_STRINGS,
type ApktoolRequest,
type StringTable,
} from "../domain/apktool/apktoolResourceAnalysis.js";
import {
AnalysisCapabilityUnavailableError,
@@ -66,6 +70,8 @@ import {
parseApktoolYml,
parseDecodedStrings,
parseManifestPackage,
type ApktoolStringsDocumentError,
type DecodedStringEntry,
} from "./ApktoolDecodeOutput.js";
/** Stable identity for the Apktool resource-decoding provider. */
@@ -79,7 +85,6 @@ const DECODE_TIMEOUT_MS = 600_000;
const DECODE_MAX_OUTPUT_BYTES = 4 * 1024 * 1024;
const MANIFEST_MAX_BYTES = 512 * 1024;
const STRINGS_XML_MAX_BYTES = 8 * 1024 * 1024;
const MAX_STRINGS = 2_000;
const MAX_DECODED_ENTRIES_VISITED = 100_000;
const STDERR_EXCERPT_BYTES = 512;
@@ -510,6 +515,7 @@ export class ApktoolProvider {
readonly path: string;
readonly include_strings: boolean;
readonly locale?: string | undefined;
readonly locales?: readonly string[] | undefined;
},
clientLimitations: readonly string[],
signal: AbortSignal | undefined,
@@ -585,26 +591,19 @@ export class ApktoolProvider {
manifest === null || manifestRead.truncated
? null
: parseManifestPackage(manifest);
const stringsDirectory =
input.locale === undefined
? "values"
: `values-${androidLocaleDirectorySuffix(input.locale)}`;
const stringsRead = input.include_strings
? await resources.readText(
join(workspace, "res", stringsDirectory, "strings.xml"),
STRINGS_XML_MAX_BYTES,
signal,
)
: { text: null, truncated: false };
// Do not parse an incomplete XML document as though its projection were complete.
const stringsXml = stringsRead.truncated ? null : stringsRead.text;
const stringsProjection =
stringsXml === null ? undefined : parseDecodedStrings(stringsXml);
const parsedStrings =
stringsProjection?.ok === true ? stringsProjection.value : undefined;
const documentFailure =
stringsProjection?.ok === false ? stringsProjection.error : undefined;
const bounded = parsedStrings?.entries.slice(0, MAX_STRINGS) ?? [];
let strings: Array<{ name: string; value: string }>;
let locale: string | null;
let stringTables: StringTable[] | undefined;
const limitations: string[] = [
...clientLimitations,
"Decoding skips smali (--no-src); Java-source recovery belongs to the JADX family",
];
let stringsTruncated = false;
let stringsUnparsedCount = 0;
let stringsTotalCount: number | null = null;
let stringsDocumentFailure: ApktoolStringsDocumentError | undefined;
let stringsMissingLimitation: string | undefined;
const files = await countDecodedFiles(
resources,
workspace,
@@ -612,17 +611,357 @@ export class ApktoolProvider {
MAX_DECODED_ENTRIES_VISITED,
);
throwIfCancelled(operation, signal);
let aggregateCapped = false;
let anyTablePartial = false;
if (input.locales === undefined) {
locale = input.locale ?? null;
const stringsDirectory =
input.locale === undefined
? "values"
: `values-${androidLocaleDirectorySuffix(input.locale)}`;
const stringsRead = input.include_strings
? await resources.readText(
join(workspace, "res", stringsDirectory, "strings.xml"),
STRINGS_XML_MAX_BYTES,
signal,
)
: { text: null, truncated: false };
stringsTruncated = stringsRead.truncated;
const stringsXml = stringsRead.truncated ? null : stringsRead.text;
const stringsProjection =
stringsXml === null ? undefined : parseDecodedStrings(stringsXml);
const parsedStrings =
stringsProjection?.ok === true ? stringsProjection.value : undefined;
stringsDocumentFailure =
stringsProjection?.ok === false ? stringsProjection.error : undefined;
strings = parsedStrings?.entries.slice(0, MAX_STRINGS) ?? [];
stringsTotalCount = parsedStrings?.entries.length ?? null;
stringsUnparsedCount = parsedStrings?.unparsedCount ?? 0;
if (input.include_strings && stringsRead.text === null) {
stringsMissingLimitation =
input.locale !== undefined
? `No strings.xml was found for locale ${input.locale}`
: "No default res/values/strings.xml was found in the decoded workspace";
}
if (stringsMissingLimitation !== undefined) {
limitations.push(stringsMissingLimitation);
}
if (stringsDocumentFailure !== undefined) {
limitations.push(
`String table ${JSON.stringify(`res/${stringsDirectory}/strings.xml`)} could not be projected: ${stringsDocumentFailure.message}; entry counts are unknown`,
);
}
if (stringsUnparsedCount > 0) {
limitations.push(
`String entries omitted due to unsupported markup or missing names: ${String(stringsUnparsedCount)}`,
);
}
if (stringsTotalCount !== null && stringsTotalCount > MAX_STRINGS) {
limitations.push(
`strings reports the first ${String(MAX_STRINGS)} of ${String(stringsTotalCount)} entries`,
);
}
if (stringsTruncated) {
limitations.push(
`strings.xml exceeds the ${String(STRINGS_XML_MAX_BYTES)}-byte projection budget`,
);
}
} else {
locale = input.locale ?? null;
stringTables = [];
const requestedLocales: Array<{
readonly locale: string | null;
readonly isPrimary: boolean;
}> = [];
requestedLocales.push({
locale,
isPrimary: true,
});
for (const loc of input.locales) {
if (!requestedLocales.some((r) => r.locale === loc)) {
requestedLocales.push({ locale: loc, isPrimary: false });
}
}
if (!input.include_strings) {
strings = [];
stringTables = [];
} else {
let aggregateXmlBytes = 0;
let aggregateStringsCount = 0;
const tableLimitations: string[] = [];
type PhysicalCached =
| {
readonly status: "missing" | "oversized" | "malformed";
readonly total_count: number | null;
readonly unparsed_count: number | null;
readonly coverage: "complete" | "partial";
readonly limitation: string;
}
| {
readonly status: "present";
readonly entries: readonly DecodedStringEntry[];
readonly unparsed_count: number;
};
const physicalCache = new Map<string, PhysicalCached>();
for (const item of requestedLocales) {
throwIfCancelled(operation, signal);
const loc = item.locale;
const dirName =
loc === null
? "values"
: `values-${androidLocaleDirectorySuffix(loc)}`;
const filePath = join(workspace, "res", dirName, "strings.xml");
const tableLabel =
loc === null
? "default res/values/strings.xml"
: `res/${dirName}/strings.xml`;
let cached = physicalCache.get(dirName);
if (cached === undefined) {
const remainingXmlBudget = Math.max(
0,
AGGREGATE_STRINGS_XML_MAX_BYTES - aggregateXmlBytes,
);
const remainingStringsBudget = Math.max(
0,
MAX_AGGREGATE_STRINGS - aggregateStringsCount,
);
if (remainingXmlBudget <= 0 || remainingStringsBudget <= 0) {
aggregateCapped = true;
stringTables.push({
locale: loc,
status: "skipped",
strings: [],
total_count: null,
unparsed_count: null,
coverage: "partial",
});
tableLimitations.push(
`Projection for ${tableLabel} skipped: aggregate resource limit reached`,
);
continue;
}
const tableBudget = Math.min(
STRINGS_XML_MAX_BYTES,
remainingXmlBudget,
);
const stringsRead = await resources.readText(
filePath,
tableBudget,
signal,
);
if (stringsRead.text !== null) {
if (stringsRead.truncated) {
aggregateXmlBytes += tableBudget;
} else {
aggregateXmlBytes += Buffer.byteLength(
stringsRead.text,
"utf-8",
);
}
}
if (stringsRead.text === null) {
cached = {
status: "missing",
total_count: 0,
unparsed_count: 0,
coverage: "complete",
limitation:
loc !== null
? `No strings.xml was found for locale ${loc}`
: "No default res/values/strings.xml was found in the decoded workspace",
};
physicalCache.set(dirName, cached);
} else if (stringsRead.truncated) {
if (tableBudget < STRINGS_XML_MAX_BYTES) {
aggregateCapped = true;
}
cached = {
status: "oversized",
total_count: null,
unparsed_count: null,
coverage: "partial",
limitation:
loc === null
? `strings.xml exceeds the ${String(tableBudget)}-byte projection budget`
: `strings.xml for locale ${loc} exceeds the ${String(tableBudget)}-byte projection budget`,
};
physicalCache.set(dirName, cached);
} else {
const parsed = parseDecodedStrings(stringsRead.text);
if (!parsed.ok) {
cached = {
status: "malformed",
total_count: null,
unparsed_count: null,
coverage: "partial",
limitation: `String table ${JSON.stringify(`res/${dirName}/strings.xml`)} could not be projected: ${parsed.error.message}; entry counts are unknown`,
};
physicalCache.set(dirName, cached);
} else {
cached = {
status: "present",
entries: parsed.value.entries,
unparsed_count: parsed.value.unparsedCount,
};
physicalCache.set(dirName, cached);
}
}
}
if (cached.status !== "present") {
stringTables.push({
locale: loc,
status: cached.status,
strings: [],
total_count: cached.total_count,
unparsed_count: cached.unparsed_count,
coverage: cached.coverage,
});
if (!tableLimitations.includes(cached.limitation)) {
tableLimitations.push(cached.limitation);
}
continue;
}
const totalCount = cached.entries.length;
const remainingStringsBudget = Math.max(
0,
MAX_AGGREGATE_STRINGS - aggregateStringsCount,
);
const tableLimit = Math.min(MAX_STRINGS, remainingStringsBudget);
if (remainingStringsBudget <= 0) {
aggregateCapped = true;
stringTables.push({
locale: loc,
status: "skipped",
strings: [],
total_count: null,
unparsed_count: null,
coverage: "partial",
});
tableLimitations.push(
`Projection for ${tableLabel} skipped: aggregate resource limit reached`,
);
continue;
}
const bounded = cached.entries.slice(0, tableLimit);
aggregateStringsCount += bounded.length;
if (item.isPrimary) {
// Primary table copy in top-level strings also consumes output entry budget
aggregateStringsCount += bounded.length;
}
if (bounded.length < Math.min(totalCount, MAX_STRINGS)) {
aggregateCapped = true;
}
const tableCoverage =
totalCount > MAX_STRINGS ||
bounded.length < totalCount ||
cached.unparsed_count > 0
? "partial"
: "complete";
stringTables.push({
locale: loc,
status: "present",
strings: bounded,
total_count: totalCount,
unparsed_count: cached.unparsed_count,
coverage: tableCoverage,
});
if (cached.unparsed_count > 0) {
tableLimitations.push(
loc === null
? `String entries omitted due to unsupported markup or missing names: ${String(cached.unparsed_count)}`
: `String entries omitted in locale ${loc} due to unsupported markup or missing names: ${String(cached.unparsed_count)}`,
);
}
if (totalCount > MAX_STRINGS) {
tableLimitations.push(
loc === null
? `strings reports the first ${String(MAX_STRINGS)} of ${String(totalCount)} entries`
: `Locale ${loc} strings reports the first ${String(MAX_STRINGS)} of ${String(totalCount)} entries`,
);
} else if (bounded.length < totalCount) {
tableLimitations.push(
`Locale ${loc ?? "default"} strings reports ${String(bounded.length)} entries due to aggregate strings limit`,
);
}
}
limitations.push(...tableLimitations);
const primaryTable = stringTables.find((t) => t.locale === locale);
strings = primaryTable?.strings ?? [];
}
anyTablePartial = stringTables.some(
(t) =>
t.coverage === "partial" ||
t.status === "oversized" ||
t.status === "malformed" ||
t.status === "skipped",
);
}
const coverage =
(parsedStrings !== undefined &&
parsedStrings.entries.length > MAX_STRINGS) ||
(input.locales === undefined
? (stringsTotalCount !== null && stringsTotalCount > MAX_STRINGS) ||
stringsTruncated ||
stringsUnparsedCount > 0 ||
stringsDocumentFailure !== undefined
: anyTablePartial || aggregateCapped) ||
manifestRead.truncated ||
stringsRead.truncated ||
(parsedStrings !== undefined && parsedStrings.unparsedCount > 0) ||
documentFailure !== undefined ||
files.capped ||
files.unreadableStringsTables.length > 0
? "partial"
: "complete";
if (manifestRead.truncated) {
limitations.push(
`manifest is truncated at ${String(MANIFEST_MAX_BYTES)} bytes`,
);
}
if (files.unreadableStringsTables.length > 0) {
limitations.push(
`String tables omitted because they are not regular files: ${files.unreadableStringsTables.map((table) => `${JSON.stringify(table.path)} (${table.kind})`).join(", ")}`,
);
}
if (files.otherStringsTables.length > 0) {
limitations.push(
`Other strings tables are not selected by locale; their resource configuration suffixes are preserved without inferring Android runtime matching: ${files.otherStringsTables.map((table) => JSON.stringify(table)).join(", ")}`,
);
}
if (files.capped) {
limitations.push(
`Decoded workspace enumeration stopped after ${String(MAX_DECODED_ENTRIES_VISITED)} entries; decoded_file_count and locales may be partial`,
);
}
if (aggregateCapped) {
limitations.push(
"Aggregate string table projection stopped due to resource limits",
);
}
result = ok(
createAnalysisExecution(
{
@@ -636,72 +975,19 @@ export class ApktoolProvider {
package_name: packageName,
},
manifest,
strings: bounded,
locale: input.locale ?? null,
strings,
locale,
locales: files.locales,
...(stringTables !== undefined
? { string_tables: stringTables }
: {}),
decoded_file_count: files.count,
coverage,
},
provider,
{
subject: { path: input.path, sha256, format: "apk" },
limitations: [
...clientLimitations,
"Decoding skips smali (--no-src); Java-source recovery belongs to the JADX family",
...(input.locale !== undefined &&
stringsRead.text === null &&
input.include_strings
? [`No strings.xml was found for locale ${input.locale}`]
: []),
...(input.include_strings &&
stringsRead.text === null &&
input.locale === undefined
? [
"No default res/values/strings.xml was found in the decoded workspace",
]
: []),
...(documentFailure === undefined
? []
: [
`String table ${JSON.stringify(`res/${stringsDirectory}/strings.xml`)} could not be projected: ${documentFailure.message}; entry counts are unknown`,
]),
...(parsedStrings !== undefined && parsedStrings.unparsedCount > 0
? [
`String entries omitted due to unsupported markup or missing names: ${String(parsedStrings.unparsedCount)}`,
]
: []),
...(parsedStrings !== undefined &&
parsedStrings.entries.length > MAX_STRINGS
? [
`strings reports the first ${String(MAX_STRINGS)} of ${String(parsedStrings.entries.length)} entries`,
]
: []),
...(manifestRead.truncated
? [
`manifest is truncated at ${String(MANIFEST_MAX_BYTES)} bytes`,
]
: []),
...(stringsRead.truncated
? [
`strings.xml exceeds the ${String(STRINGS_XML_MAX_BYTES)}-byte projection budget`,
]
: []),
...(files.unreadableStringsTables.length === 0
? []
: [
`String tables omitted because they are not regular files: ${files.unreadableStringsTables.map((table) => `${JSON.stringify(table.path)} (${table.kind})`).join(", ")}`,
]),
...(files.otherStringsTables.length === 0
? []
: [
`Other strings tables are not selected by locale; their resource configuration suffixes are preserved without inferring Android runtime matching: ${files.otherStringsTables.map((table) => JSON.stringify(table)).join(", ")}`,
]),
...(files.capped
? [
`Decoded workspace enumeration stopped after ${String(MAX_DECODED_ENTRIES_VISITED)} entries; decoded_file_count and locales may be partial`,
]
: []),
],
limitations,
},
),
);
@@ -33,6 +33,14 @@ const refusedPort: ApktoolResourceAnalysisPort = {
it.each([
[{ path: "/targets/app.apk", include_strings: "yes" }],
[{ path: "/targets/app.apk", locale: "not a locale" }],
[{ path: "/targets/app.apk", locales: ["not a locale"] }],
[{ path: "/targets/app.apk", locales: [] }],
[
{
path: "/targets/app.apk",
locales: Array.from({ length: 21 }, () => "de"),
},
],
[{ path: "/targets/app.apk", extra: true }],
])("rejects invalid decode selection before any effect: %j", async (input) => {
const service = new ApktoolResourceAnalysisService(refusedPort);
@@ -41,6 +49,41 @@ it.each([
).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } });
});
it("admits valid batched locales and forwards them to provider", async () => {
const port: ApktoolResourceAnalysisPort = {
async inspectAvailability() {
return { status: "available", code: null, reason: null, diagnostics: {} };
},
async close() {},
async execute(request) {
expect(request).toEqual({
operation: "decode_android_resources",
input: {
path: "/targets/app.apk",
include_strings: true,
locales: ["de", "pt-BR"],
},
});
return ok(execution);
},
};
const result = await new ApktoolResourceAnalysisService(port).execute(
"decode_android_resources",
{
path: "/targets/app.apk",
include_strings: true,
locales: ["de", "pt-BR"],
},
);
if (!result.ok) throw new Error(result.error.message);
const evidence = parseEvidence(result.value);
expect(evidence.parameters).toEqual({
path: "/targets/app.apk",
include_strings: true,
locales: ["de", "pt-BR"],
});
});
it("composes observed Evidence with the caller parameters inline", async () => {
const port: ApktoolResourceAnalysisPort = {
async inspectAvailability() {
+9
View File
@@ -54,6 +54,14 @@ export const registerApktoolCommands = (
// `b+zh+Hans+CN` form at the CLI boundary.
options: z.object({
locale: apktoolInputSchemas.decode_android_resources.shape.locale,
locales: z
.array(
apktoolInputSchemas.decode_android_resources.shape.locale.unwrap(),
)
.optional()
.describe(
"Repeat to project multiple localized string tables, e.g. --locales de --locales pt-BR",
),
strings: z
.boolean()
.default(
@@ -70,6 +78,7 @@ export const registerApktoolCommands = (
path: args.path,
include_strings: options.strings,
...(options.locale === undefined ? {} : { locale: options.locale }),
...(options.locales === undefined ? {} : { locales: options.locales }),
}),
});
};
@@ -22,7 +22,7 @@ export const APKTOOL_TOOL_CONTRACTS = [
name: "decode_android_resources",
...toolContractMetadata("decode_android_resources"),
description:
"Decode one local APK's resources with `apktool d --no-src` in a provider-owned workspace that is removed after projection: the readable AndroidManifest.xml, apktool.yml version and SDK facts, the app's string resources (default or one locale, bounded to 2000 entries), selectable string locales, and the decoded file count. The target APK is digest with SHA-256 before decoding; smali is skipped because Java-source recovery belongs to the JADX family.",
"Decode one local APK's resources with `apktool d --no-src` in a provider-owned workspace that is removed after projection: the readable AndroidManifest.xml, apktool.yml version and SDK facts, the app's string resources (default or selected locales, bounded to 2000 entries per table), selectable string locales, and the decoded file count. The target APK is digest with SHA-256 before decoding; smali is skipped because Java-source recovery belongs to the JADX family.",
kind: "apktool-provider",
inputSchema: apktoolInputSchemas.decode_android_resources,
outputSchema: evidenceResultOf(
@@ -37,6 +37,10 @@ export const APKTOOL_TOOL_CONTRACTS = [
title: "Decode one locale's strings",
input: { path: "/targets/Example.apk", locale: "de" },
},
{
title: "Decode multiple locale string tables in one pass",
input: { path: "/targets/Example.apk", locales: ["de", "fr"] },
},
],
},
] as const satisfies readonly ToolContract[];
+53 -1
View File
@@ -2,7 +2,10 @@ import { z } from "zod";
import { localPathStringSchema } from "../localPath.js";
import { digestSchema } from "../digests.js";
const MAX_STRINGS = 2_000;
export const MAX_STRINGS = 2_000;
export const MAX_SELECTED_LOCALES = 20;
export const MAX_AGGREGATE_STRINGS = 10_000;
export const AGGREGATE_STRINGS_XML_MAX_BYTES = 32 * 1024 * 1024;
const ANDROID_NON_LOCALE_QUALIFIERS = new Set([
"car",
"desk",
@@ -71,6 +74,14 @@ export const apktoolInputSchemas = {
.describe(
"Project strings from res/values-<locale>/strings.xml instead of the default table",
),
locales: z
.array(androidLocaleQualifierSchema)
.min(1)
.max(MAX_SELECTED_LOCALES)
.optional()
.describe(
`Project multiple localized string tables from res/values-<locale>/strings.xml alongside the default table (bounded to ${String(MAX_SELECTED_LOCALES)} locales)`,
),
}),
} as const;
@@ -102,6 +113,46 @@ const client = z.strictObject({
apktool_version: z.string().min(1).nullable(),
});
/** An individual projected Android string table from an Apktool decode. */
export const stringTableSchema = z.strictObject({
locale: z
.string()
.nullable()
.describe(
"null for default res/values/strings.xml, or the locale qualifier",
),
status: z
.enum(["present", "missing", "oversized", "malformed", "skipped"])
.describe(
"Table availability: present, missing from APK, oversized, malformed XML, or skipped due to limits",
),
strings: z.array(
z.strictObject({
name: z.string().min(1),
value: z.string(),
}),
),
total_count: z
.number()
.int()
.nonnegative()
.nullable()
.describe(
"Total valid string entries before per-table cap, or null if unmeasured/skipped/malformed",
),
unparsed_count: z
.number()
.int()
.nonnegative()
.nullable()
.describe(
"Entries skipped due to nested markup or missing attributes, or null if unmeasured/skipped/malformed",
),
coverage: z.enum(["complete", "partial"]),
});
export type StringTable = z.infer<typeof stringTableSchema>;
/** Portable Apktool observations with explicit coverage limitations. */
export const apktoolResultSchemas = {
inspect_apktool_client: z.strictObject({
@@ -135,6 +186,7 @@ export const apktoolResultSchemas = {
),
locale: z.string().nullable(),
locales: z.array(z.string().min(1)),
string_tables: z.array(stringTableSchema).optional(),
decoded_file_count: z.number().int().nonnegative(),
coverage: z.enum(["complete", "partial"]),
}),
@@ -80,6 +80,30 @@ it("forwards every locale the apktool input owner admits", async () => {
}
});
it("forwards repeated --locales to project multiple localized tables", async () => {
const result = await invoke([
"decode-android-resources",
"/targets/app.apk",
"--locales",
"de",
"--locales",
"pt-BR",
"--json",
]);
expect(result.exitCode).toBe(0);
expect(result.requests).toEqual([
{
operation: "decode_android_resources",
input: {
path: "/targets/app.apk",
include_strings: true,
locales: ["de", "pt-BR"],
},
},
]);
});
it("still refuses a locale the apktool input owner refuses", async () => {
const result = await invoke([
"decode-android-resources",
@@ -92,3 +116,26 @@ it("still refuses a locale the apktool input owner refuses", async () => {
expect(result.exitCode).not.toBe(0);
expect(result.requests).toEqual([]);
});
it("forwards --no-strings with --locale preserving caller options", async () => {
const result = await invoke([
"decode-android-resources",
"/targets/app.apk",
"--no-strings",
"--locale",
"de",
"--json",
]);
expect(result.exitCode).toBe(0);
expect(result.requests).toEqual([
{
operation: "decode_android_resources",
input: {
path: "/targets/app.apk",
include_strings: false,
locale: "de",
},
},
]);
});
+28
View File
@@ -38,6 +38,24 @@ const results: Readonly<Record<string, unknown>> = {
strings: [{ name: "app_name", value: "ApktoolProbe" }],
locale: null,
locales: ["de"],
string_tables: [
{
locale: null,
status: "present",
strings: [{ name: "app_name", value: "ApktoolProbe" }],
total_count: 1,
unparsed_count: 0,
coverage: "complete",
},
{
locale: "de",
status: "present",
strings: [{ name: "app_name", value: "ApktoolProbe DE" }],
total_count: 1,
unparsed_count: 0,
coverage: "complete",
},
],
decoded_file_count: 5,
coverage: "complete",
},
@@ -112,5 +130,15 @@ it("advertises exact valid Apktool schemas and records inline evidence through M
expect(evidence.normalized_result).toMatchObject({
metadata: { package_name: "com.rea.apktool.probe", version_name: "1.2.3" },
coverage: "complete",
string_tables: [
{ locale: null, status: "present" },
{ locale: "de", status: "present" },
],
});
const batched = await client.callTool({
name: "decode_android_resources",
arguments: { path: "/targets/probe.apk", locales: ["de"] },
});
expect(batched.isError).not.toBe(true);
});