diff --git a/docs/apktool-resource-analysis.md b/docs/apktool-resource-analysis.md index 3632cce224..099af9bd2a 100644 --- a/docs/apktool-resource-analysis.md +++ b/docs/apktool-resource-analysis.md @@ -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-/strings.xml` instead of the default -table), and `include_strings` (default true). Regional locales such as `pt-BR` +primary `locale` (project `res/values-/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. diff --git a/scripts/verify-apktool.mjs b/scripts/verify-apktool.mjs index 98ccfd52c7..99b8f302fd 100644 --- a/scripts/verify-apktool.mjs +++ b/scripts/verify-apktool.mjs @@ -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`, + ); + } } } } diff --git a/src/apktool/ApktoolProvider.test.ts b/src/apktool/ApktoolProvider.test.ts index dd6a39dbd8..1fb114dc26 100644 --- a/src/apktool/ApktoolProvider.test.ts +++ b/src/apktool/ApktoolProvider.test.ts @@ -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 'Hallo' > "$5/res/values-de/strings.xml" + printf 'Olá' > "$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 'Olá' > "$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 'Hallo' > "$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 'Hallo Welt' > "$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 ' "$5/res/values-fr/strings.xml"`, + ), + ); + const bigStrings = `${Array.from({ length: 2000 }, (_, i) => `val`).join("")}`; + 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), diff --git a/src/apktool/ApktoolProvider.ts b/src/apktool/ApktoolProvider.ts index 7599bb6e83..e86ea7efdf 100644 --- a/src/apktool/ApktoolProvider.ts +++ b/src/apktool/ApktoolProvider.ts @@ -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(); + + 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, }, ), ); diff --git a/src/application/apktool/ApktoolResourceAnalysisService.test.ts b/src/application/apktool/ApktoolResourceAnalysisService.test.ts index 2220ce9e69..ab801294af 100644 --- a/src/application/apktool/ApktoolResourceAnalysisService.test.ts +++ b/src/application/apktool/ApktoolResourceAnalysisService.test.ts @@ -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() { diff --git a/src/cli/apktoolCommands.ts b/src/cli/apktoolCommands.ts index 6bd6f755b2..b3a58d8786 100644 --- a/src/cli/apktoolCommands.ts +++ b/src/cli/apktoolCommands.ts @@ -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 }), }), }); }; diff --git a/src/contracts/apktool/apktoolToolContracts.ts b/src/contracts/apktool/apktoolToolContracts.ts index 40270ab4b7..d9dc5c6487 100644 --- a/src/contracts/apktool/apktoolToolContracts.ts +++ b/src/contracts/apktool/apktoolToolContracts.ts @@ -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[]; diff --git a/src/domain/apktool/apktoolResourceAnalysis.ts b/src/domain/apktool/apktoolResourceAnalysis.ts index 5fa7da6feb..1da4aa92ea 100644 --- a/src/domain/apktool/apktoolResourceAnalysis.ts +++ b/src/domain/apktool/apktoolResourceAnalysis.ts @@ -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-/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-/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; + /** 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"]), }), diff --git a/tests/boundary/cli/apktoolLocaleOption.test.ts b/tests/boundary/cli/apktoolLocaleOption.test.ts index a0fe75d46b..911c46944d 100644 --- a/tests/boundary/cli/apktoolLocaleOption.test.ts +++ b/tests/boundary/cli/apktoolLocaleOption.test.ts @@ -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", + }, + }, + ]); +}); diff --git a/tests/boundary/mcp/apktoolMcp.test.ts b/tests/boundary/mcp/apktoolMcp.test.ts index e937c0d5bf..a261288e95 100644 --- a/tests/boundary/mcp/apktoolMcp.test.ts +++ b/tests/boundary/mcp/apktoolMcp.test.ts @@ -38,6 +38,24 @@ const results: Readonly> = { 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); });