mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
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:
1 parent
3b5605ecd2
commit
6668424343
10 files changed
+804
-89
No files matched your search
@@ -62,13 +62,15 @@ reports `capability_unavailable`.
|
|||||||
rea inspect-apktool-client
|
rea inspect-apktool-client
|
||||||
rea decode-android-resources /targets/Example.apk
|
rea decode-android-resources /targets/Example.apk
|
||||||
rea decode-android-resources /targets/Example.apk --locale de
|
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
|
rea decode-android-resources /targets/Example.apk --no-strings
|
||||||
```
|
```
|
||||||
|
|
||||||
Equivalent MCP methods are `inspect_apktool_client` and
|
Equivalent MCP methods are `inspect_apktool_client` and
|
||||||
`decode_android_resources`. The decode request takes `path`, an optional
|
`decode_android_resources`. The decode request takes `path`, an optional
|
||||||
`locale` (project `res/values-<locale>/strings.xml` instead of the default
|
primary `locale` (project `res/values-<locale>/strings.xml` instead of the default
|
||||||
table), and `include_strings` (default true). Regional locales such as `pt-BR`
|
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
|
select Android's `values-pt-rBR`; Android qualifiers such as `pt-rBR` and
|
||||||
`b+zh+Hans+CN` may also be supplied directly.
|
`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
|
strings-table paths are disclosed with partial coverage. Discovery uses
|
||||||
the same bounded traversal as the file count; filesystem failures remain
|
the same bounded traversal as the file count; filesystem failures remain
|
||||||
failures rather than being interpreted as missing tables.
|
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
|
- **Smali is skipped by design** (`apktool d --no-src`): Java-source
|
||||||
recovery belongs to the JADX family. Resource smali edits and rebuild are
|
recovery belongs to the JADX family. Resource smali edits and rebuild are
|
||||||
a follow-up lane with mutation authority.
|
a follow-up lane with mutation authority.
|
||||||
|
|||||||
@@ -103,6 +103,29 @@ try {
|
|||||||
Array.isArray(locale.strings),
|
Array.isArray(locale.strings),
|
||||||
`${locale.strings.length} string(s)`,
|
`${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`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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á" }]);
|
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 () => {
|
it("marks an oversized strings document partial without claiming it is missing", async () => {
|
||||||
const { command, root } = await writeApktoolStub((root) =>
|
const { command, root } = await writeApktoolStub((root) =>
|
||||||
decodeStub({ stringsFile: join(root, "large-strings.xml") })(root),
|
decodeStub({ stringsFile: join(root, "large-strings.xml") })(root),
|
||||||
|
|||||||
+371
-85
@@ -32,7 +32,11 @@ import {
|
|||||||
} from "../application/AnalysisProvider.js";
|
} from "../application/AnalysisProvider.js";
|
||||||
import {
|
import {
|
||||||
androidLocaleDirectorySuffix,
|
androidLocaleDirectorySuffix,
|
||||||
|
AGGREGATE_STRINGS_XML_MAX_BYTES,
|
||||||
|
MAX_AGGREGATE_STRINGS,
|
||||||
|
MAX_STRINGS,
|
||||||
type ApktoolRequest,
|
type ApktoolRequest,
|
||||||
|
type StringTable,
|
||||||
} from "../domain/apktool/apktoolResourceAnalysis.js";
|
} from "../domain/apktool/apktoolResourceAnalysis.js";
|
||||||
import {
|
import {
|
||||||
AnalysisCapabilityUnavailableError,
|
AnalysisCapabilityUnavailableError,
|
||||||
@@ -66,6 +70,8 @@ import {
|
|||||||
parseApktoolYml,
|
parseApktoolYml,
|
||||||
parseDecodedStrings,
|
parseDecodedStrings,
|
||||||
parseManifestPackage,
|
parseManifestPackage,
|
||||||
|
type ApktoolStringsDocumentError,
|
||||||
|
type DecodedStringEntry,
|
||||||
} from "./ApktoolDecodeOutput.js";
|
} from "./ApktoolDecodeOutput.js";
|
||||||
|
|
||||||
/** Stable identity for the Apktool resource-decoding provider. */
|
/** 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 DECODE_MAX_OUTPUT_BYTES = 4 * 1024 * 1024;
|
||||||
const MANIFEST_MAX_BYTES = 512 * 1024;
|
const MANIFEST_MAX_BYTES = 512 * 1024;
|
||||||
const STRINGS_XML_MAX_BYTES = 8 * 1024 * 1024;
|
const STRINGS_XML_MAX_BYTES = 8 * 1024 * 1024;
|
||||||
const MAX_STRINGS = 2_000;
|
|
||||||
const MAX_DECODED_ENTRIES_VISITED = 100_000;
|
const MAX_DECODED_ENTRIES_VISITED = 100_000;
|
||||||
const STDERR_EXCERPT_BYTES = 512;
|
const STDERR_EXCERPT_BYTES = 512;
|
||||||
|
|
||||||
@@ -510,6 +515,7 @@ export class ApktoolProvider {
|
|||||||
readonly path: string;
|
readonly path: string;
|
||||||
readonly include_strings: boolean;
|
readonly include_strings: boolean;
|
||||||
readonly locale?: string | undefined;
|
readonly locale?: string | undefined;
|
||||||
|
readonly locales?: readonly string[] | undefined;
|
||||||
},
|
},
|
||||||
clientLimitations: readonly string[],
|
clientLimitations: readonly string[],
|
||||||
signal: AbortSignal | undefined,
|
signal: AbortSignal | undefined,
|
||||||
@@ -585,26 +591,19 @@ export class ApktoolProvider {
|
|||||||
manifest === null || manifestRead.truncated
|
manifest === null || manifestRead.truncated
|
||||||
? null
|
? null
|
||||||
: parseManifestPackage(manifest);
|
: parseManifestPackage(manifest);
|
||||||
const stringsDirectory =
|
let strings: Array<{ name: string; value: string }>;
|
||||||
input.locale === undefined
|
let locale: string | null;
|
||||||
? "values"
|
let stringTables: StringTable[] | undefined;
|
||||||
: `values-${androidLocaleDirectorySuffix(input.locale)}`;
|
const limitations: string[] = [
|
||||||
const stringsRead = input.include_strings
|
...clientLimitations,
|
||||||
? await resources.readText(
|
"Decoding skips smali (--no-src); Java-source recovery belongs to the JADX family",
|
||||||
join(workspace, "res", stringsDirectory, "strings.xml"),
|
];
|
||||||
STRINGS_XML_MAX_BYTES,
|
let stringsTruncated = false;
|
||||||
signal,
|
let stringsUnparsedCount = 0;
|
||||||
)
|
let stringsTotalCount: number | null = null;
|
||||||
: { text: null, truncated: false };
|
let stringsDocumentFailure: ApktoolStringsDocumentError | undefined;
|
||||||
// Do not parse an incomplete XML document as though its projection were complete.
|
let stringsMissingLimitation: string | undefined;
|
||||||
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) ?? [];
|
|
||||||
const files = await countDecodedFiles(
|
const files = await countDecodedFiles(
|
||||||
resources,
|
resources,
|
||||||
workspace,
|
workspace,
|
||||||
@@ -612,17 +611,357 @@ export class ApktoolProvider {
|
|||||||
MAX_DECODED_ENTRIES_VISITED,
|
MAX_DECODED_ENTRIES_VISITED,
|
||||||
);
|
);
|
||||||
throwIfCancelled(operation, signal);
|
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 =
|
const coverage =
|
||||||
(parsedStrings !== undefined &&
|
(input.locales === undefined
|
||||||
parsedStrings.entries.length > MAX_STRINGS) ||
|
? (stringsTotalCount !== null && stringsTotalCount > MAX_STRINGS) ||
|
||||||
|
stringsTruncated ||
|
||||||
|
stringsUnparsedCount > 0 ||
|
||||||
|
stringsDocumentFailure !== undefined
|
||||||
|
: anyTablePartial || aggregateCapped) ||
|
||||||
manifestRead.truncated ||
|
manifestRead.truncated ||
|
||||||
stringsRead.truncated ||
|
|
||||||
(parsedStrings !== undefined && parsedStrings.unparsedCount > 0) ||
|
|
||||||
documentFailure !== undefined ||
|
|
||||||
files.capped ||
|
files.capped ||
|
||||||
files.unreadableStringsTables.length > 0
|
files.unreadableStringsTables.length > 0
|
||||||
? "partial"
|
? "partial"
|
||||||
: "complete";
|
: "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(
|
result = ok(
|
||||||
createAnalysisExecution(
|
createAnalysisExecution(
|
||||||
{
|
{
|
||||||
@@ -636,72 +975,19 @@ export class ApktoolProvider {
|
|||||||
package_name: packageName,
|
package_name: packageName,
|
||||||
},
|
},
|
||||||
manifest,
|
manifest,
|
||||||
strings: bounded,
|
strings,
|
||||||
locale: input.locale ?? null,
|
locale,
|
||||||
locales: files.locales,
|
locales: files.locales,
|
||||||
|
...(stringTables !== undefined
|
||||||
|
? { string_tables: stringTables }
|
||||||
|
: {}),
|
||||||
decoded_file_count: files.count,
|
decoded_file_count: files.count,
|
||||||
coverage,
|
coverage,
|
||||||
},
|
},
|
||||||
provider,
|
provider,
|
||||||
{
|
{
|
||||||
subject: { path: input.path, sha256, format: "apk" },
|
subject: { path: input.path, sha256, format: "apk" },
|
||||||
limitations: [
|
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`,
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -33,6 +33,14 @@ const refusedPort: ApktoolResourceAnalysisPort = {
|
|||||||
it.each([
|
it.each([
|
||||||
[{ path: "/targets/app.apk", include_strings: "yes" }],
|
[{ path: "/targets/app.apk", include_strings: "yes" }],
|
||||||
[{ path: "/targets/app.apk", locale: "not a locale" }],
|
[{ 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 }],
|
[{ path: "/targets/app.apk", extra: true }],
|
||||||
])("rejects invalid decode selection before any effect: %j", async (input) => {
|
])("rejects invalid decode selection before any effect: %j", async (input) => {
|
||||||
const service = new ApktoolResourceAnalysisService(refusedPort);
|
const service = new ApktoolResourceAnalysisService(refusedPort);
|
||||||
@@ -41,6 +49,41 @@ it.each([
|
|||||||
).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } });
|
).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 () => {
|
it("composes observed Evidence with the caller parameters inline", async () => {
|
||||||
const port: ApktoolResourceAnalysisPort = {
|
const port: ApktoolResourceAnalysisPort = {
|
||||||
async inspectAvailability() {
|
async inspectAvailability() {
|
||||||
|
|||||||
@@ -54,6 +54,14 @@ export const registerApktoolCommands = (
|
|||||||
// `b+zh+Hans+CN` form at the CLI boundary.
|
// `b+zh+Hans+CN` form at the CLI boundary.
|
||||||
options: z.object({
|
options: z.object({
|
||||||
locale: apktoolInputSchemas.decode_android_resources.shape.locale,
|
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
|
strings: z
|
||||||
.boolean()
|
.boolean()
|
||||||
.default(
|
.default(
|
||||||
@@ -70,6 +78,7 @@ export const registerApktoolCommands = (
|
|||||||
path: args.path,
|
path: args.path,
|
||||||
include_strings: options.strings,
|
include_strings: options.strings,
|
||||||
...(options.locale === undefined ? {} : { locale: options.locale }),
|
...(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",
|
name: "decode_android_resources",
|
||||||
...toolContractMetadata("decode_android_resources"),
|
...toolContractMetadata("decode_android_resources"),
|
||||||
description:
|
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",
|
kind: "apktool-provider",
|
||||||
inputSchema: apktoolInputSchemas.decode_android_resources,
|
inputSchema: apktoolInputSchemas.decode_android_resources,
|
||||||
outputSchema: evidenceResultOf(
|
outputSchema: evidenceResultOf(
|
||||||
@@ -37,6 +37,10 @@ export const APKTOOL_TOOL_CONTRACTS = [
|
|||||||
title: "Decode one locale's strings",
|
title: "Decode one locale's strings",
|
||||||
input: { path: "/targets/Example.apk", locale: "de" },
|
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[];
|
] as const satisfies readonly ToolContract[];
|
||||||
@@ -2,7 +2,10 @@ import { z } from "zod";
|
|||||||
import { localPathStringSchema } from "../localPath.js";
|
import { localPathStringSchema } from "../localPath.js";
|
||||||
import { digestSchema } from "../digests.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([
|
const ANDROID_NON_LOCALE_QUALIFIERS = new Set([
|
||||||
"car",
|
"car",
|
||||||
"desk",
|
"desk",
|
||||||
@@ -71,6 +74,14 @@ export const apktoolInputSchemas = {
|
|||||||
.describe(
|
.describe(
|
||||||
"Project strings from res/values-<locale>/strings.xml instead of the default table",
|
"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;
|
} as const;
|
||||||
|
|
||||||
@@ -102,6 +113,46 @@ const client = z.strictObject({
|
|||||||
apktool_version: z.string().min(1).nullable(),
|
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. */
|
/** Portable Apktool observations with explicit coverage limitations. */
|
||||||
export const apktoolResultSchemas = {
|
export const apktoolResultSchemas = {
|
||||||
inspect_apktool_client: z.strictObject({
|
inspect_apktool_client: z.strictObject({
|
||||||
@@ -135,6 +186,7 @@ export const apktoolResultSchemas = {
|
|||||||
),
|
),
|
||||||
locale: z.string().nullable(),
|
locale: z.string().nullable(),
|
||||||
locales: z.array(z.string().min(1)),
|
locales: z.array(z.string().min(1)),
|
||||||
|
string_tables: z.array(stringTableSchema).optional(),
|
||||||
decoded_file_count: z.number().int().nonnegative(),
|
decoded_file_count: z.number().int().nonnegative(),
|
||||||
coverage: z.enum(["complete", "partial"]),
|
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 () => {
|
it("still refuses a locale the apktool input owner refuses", async () => {
|
||||||
const result = await invoke([
|
const result = await invoke([
|
||||||
"decode-android-resources",
|
"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.exitCode).not.toBe(0);
|
||||||
expect(result.requests).toEqual([]);
|
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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -38,6 +38,24 @@ const results: Readonly<Record<string, unknown>> = {
|
|||||||
strings: [{ name: "app_name", value: "ApktoolProbe" }],
|
strings: [{ name: "app_name", value: "ApktoolProbe" }],
|
||||||
locale: null,
|
locale: null,
|
||||||
locales: ["de"],
|
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,
|
decoded_file_count: 5,
|
||||||
coverage: "complete",
|
coverage: "complete",
|
||||||
},
|
},
|
||||||
@@ -112,5 +130,15 @@ it("advertises exact valid Apktool schemas and records inline evidence through M
|
|||||||
expect(evidence.normalized_result).toMatchObject({
|
expect(evidence.normalized_result).toMatchObject({
|
||||||
metadata: { package_name: "com.rea.apktool.probe", version_name: "1.2.3" },
|
metadata: { package_name: "com.rea.apktool.probe", version_name: "1.2.3" },
|
||||||
coverage: "complete",
|
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);
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user