fix(apktool): derive locale size from resource configuration fields

Allow the 23-character script-region-variant representation emitted by Apktool, retaining the shared admission grammar and request aliases. Exact discovery and CLI/MCP selection consume the same bound.

Validation: 67 focused tests across four files; five controls fail on the baseline; static/docs checks and two source reviews. Twelve ordinary tiny CLI/MCP task comparisons preserve Evidence; two actual authored-launcher long-locale CLI/MCP calls select the table. No real Apktool/JVM or native Windows verification.

Fixes #4817
This commit is contained in:
morluto committed 2026-10-17 23:00:26 +08:00
1 parent 98886adddf
commit 6385ef4355
6 files changed
+88 -15

No files matched your search

+3
View File
@@ -65,6 +65,9 @@ select Android's `values-pt-rBR`; Android qualifiers such as `pt-rBR` and
default table is bounded to 2000 entries with partial coverage when more
exist. `locales` lists observed regular `strings.xml` tables whose exact
`values-<locale>` directory is selectable by the request's locale grammar.
Locale suffixes allow up to 23 ASCII characters, derived from Apktool's
language, script, region, and variant fields; for example,
`b+sr+Latn+RS+REVISED` selects that exact table.
Other table directories, including combined qualifiers such as
`values-en-rUS-v21` and `values-mcc310-fr`, are named in limitations with
their source spelling. They are not advertised as selectable locales;
+24 -4
View File
@@ -928,16 +928,30 @@ it("discloses qualified and unfamiliar strings tables while preserving exact loc
"night",
"v21",
"pt-BR",
"b+sr+Latn+RS+REVISED",
"unknown_CONFIGURATION",
];
const { command, root } = await writeApktoolStub(
decodeStub({ extraValuesDirectories: [...tables, "fr", "b+sr+Latn"] }),
decodeStub({
extraValuesDirectories: [
...tables,
"fr",
"b+sr+Latn",
"b+sr+Latn+RS+REVISED",
"b+fil+Latn+419+ABCDEFGH",
],
}),
);
const apk = await apkFixture(root);
const provider = providerOver(command);
try {
for (const locale of [undefined, "fr", "b+sr+Latn", "en-rUS"] as const) {
for (const locale of [
undefined,
"fr",
"b+sr+Latn",
"b+sr+Latn+RS+REVISED",
"b+fil+Latn+419+ABCDEFGH",
"en-rUS",
] as const) {
const result = await provider.execute({
operation: "decode_android_resources",
input: {
@@ -948,7 +962,13 @@ it("discloses qualified and unfamiliar strings tables while preserving exact loc
});
if (!result.ok) throw result.error;
expect(result.value.result).toMatchObject({
locales: ["b+sr+Latn", "de", "fr"],
locales: [
"b+fil+Latn+419+ABCDEFGH",
"b+sr+Latn",
"b+sr+Latn+RS+REVISED",
"de",
"fr",
],
locale: locale ?? null,
coverage: "complete",
});
@@ -1,18 +1,24 @@
import { expect, it } from "vitest";
import {
androidLocaleDirectorySuffix,
androidLocaleQualifierSchema,
classifyAndroidStringsTable,
} from "./apktoolResourceAnalysis.js";
it.each(["de", "pt-rBR", "en-r419", "b+sr+Latn", "b+zh+Hans+CN"])(
"recognizes the exact selectable strings table values-%s",
(suffix) => {
expect(classifyAndroidStringsTable(suffix)).toEqual({
kind: "locale",
locale: suffix,
});
},
);
it.each([
"de",
"pt-rBR",
"en-r419",
"b+sr+Latn",
"b+zh+Hans+CN",
"b+sr+Latn+RS+REVISED",
"b+fil+Latn+419+ABCDEFGH",
])("recognizes the exact selectable strings table values-%s", (suffix) => {
expect(classifyAndroidStringsTable(suffix)).toEqual({
kind: "locale",
locale: suffix,
});
});
it.each([
"en-rUS-v21",
@@ -25,7 +31,6 @@ it.each([
"v21",
"pt-BR",
"en-419",
"b+sr+Latn+RS+REVISED",
"unknown_CONFIGURATION",
"",
])(
@@ -49,3 +54,20 @@ it.each([
expect(androidLocaleDirectorySuffix(locale)).toBe(suffix);
},
);
it.each([
"b+fil+Latn+419+ABCDEFGH+US",
"b+sr+Latn+RS+REVISED-v21",
"../values-fr",
"car",
"night",
])(
"refuses suffixes outside the bounded locale selection contract: %s",
(suffix) => {
expect(androidLocaleQualifierSchema.safeParse(suffix).success).toBe(false);
expect(classifyAndroidStringsTable(suffix)).toEqual({
kind: "other",
suffix,
});
},
);
@@ -14,10 +14,15 @@ const ANDROID_NON_LOCALE_QUALIFIERS = new Set([
"notnight",
]);
// Apktool emits b+language[+script][+region][+variant] from ARSC fields:
// language/region expand to at most 3 ASCII characters, script has 4 bytes,
// and variant has 8 bytes (BinaryResourceParser / ResConfig).
const MAX_ANDROID_LOCALE_SUFFIX = 2 + 3 + (1 + 4) + (1 + 3) + (1 + 8);
const localeQualifierSchema = z
.string()
.min(2)
.max(16)
.max(MAX_ANDROID_LOCALE_SUFFIX)
.regex(
/^(?:[a-z]{2,3}(?:-(?:r?[A-Z]{2}|r?[0-9]{3}))?|b\+[a-z]{2,3}(?:\+[A-Za-z0-9]{2,8})*)$/u,
"Locale must look like de, pt-BR, pt-rBR, or b+zh+Hans+CN",
@@ -56,6 +56,8 @@ const OWNER_ADMITTED_LOCALES = [
"zh-rCN",
"en-419",
"b+zh+Hans+CN",
"b+sr+Latn+RS+REVISED",
"b+fil+Latn+419+ABCDEFGH",
] as const;
it("forwards every locale the apktool input owner admits", async () => {
+21
View File
@@ -44,12 +44,15 @@ const results: Readonly<Record<string, unknown>> = {
};
it("advertises exact valid Apktool schemas and records inline evidence through MCP", async () => {
const requestedLocales: (string | undefined)[] = [];
const provider: ApktoolResourceAnalysisPort = {
async inspectAvailability() {
return { status: "available", code: null, reason: null, diagnostics: {} };
},
async close() {},
async execute(request) {
if (request.operation === "decode_android_resources")
requestedLocales.push(request.input.locale);
return ok(createAnalysisExecution(results[request.operation], identity));
},
};
@@ -77,6 +80,24 @@ it("advertises exact valid Apktool schemas and records inline evidence through M
for (const name of operationNames) expect(names, name).toContain(name);
assertAdvertisedMcpContracts(operationNames, tools);
const localeSchema = tools.find(
(tool) => tool.name === "decode_android_resources",
)?.inputSchema.properties?.locale;
expect(localeSchema).toMatchObject({ maxLength: 23 });
for (const locale of ["b+sr+Latn+RS+REVISED", "b+fil+Latn+419+ABCDEFGH"]) {
const reply = await client.callTool({
name: "decode_android_resources",
arguments: { path: "/targets/probe.apk", locale },
});
expect(reply.isError).not.toBe(true);
expect(parseEvidence(reply.structuredContent).parameters).toMatchObject({
locale,
});
}
expect(requestedLocales).toEqual([
"b+sr+Latn+RS+REVISED",
"b+fil+Latn+419+ABCDEFGH",
]);
const decoded = await client.callTool({
name: "decode_android_resources",