perf(apktool): avoid duplicated primary strings on request

Add opt-in string_tables_only and CLI --string-tables-only to keep selected
strings once in typed table facts. Preserve existing defaults and legacy replies;
charge aggregate output capacity for the copies actually requested.

Validate shared CLI/MCP behavior with independent XML oracles, three real APKs,
94 source tests, nine compiled boundary tests, 36 completed MCP tasks and three
CLI parity tasks. Preserve full task estimates, negative timings, resource
admission refusal and verification limitations in the measured report.

Fixes #4982

Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-18 15:38:16 +08:00
1 parent 8f7d12e30d
commit 7c458f04b9
14 files changed
+3107 -82

No files matched your search

+21 -2
View File
@@ -8,7 +8,7 @@ installs Apktool or a Java runtime.
## Supply tools
Use Apktool 2.7.0 or newer (verified with 2.7.0) and a Java runtime the
Use Apktool 2.7.0 or newer (verified with 2.7.0 and 3.0.3 on Linux) and a Java runtime the
launcher can find. Select the launcher through the environment:
```sh
@@ -63,6 +63,7 @@ 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 --locales de --locales pt-BR --string-tables-only
rea decode-android-resources /targets/Example.apk --no-strings
```
@@ -74,6 +75,15 @@ from the same single decode pass), and `include_strings` (default true). Regiona
select Android's `values-pt-rBR`; Android qualifiers such as `pt-rBR` and
`b+zh+Hans+CN` may also be supplied directly.
Set `string_tables_only: true` (CLI `--string-tables-only`) to return string
entries only inside `string_tables`, including when selecting a single table.
The top-level `strings` array is then empty by request; it does not establish
that the primary table is empty. Table status, counts and coverage still describe
the examined source. This avoids repeating primary entries in multi-locale
responses and charges the aggregate output budget for one copy per table.
Omitting the option preserves existing responses. `include_strings: false`
still suppresses all string projection, including in this mode.
## Interpret the result
- **An owned APK snapshot is digested and decoded.** `target.sha256` and
@@ -108,7 +118,8 @@ select Android's `values-pt-rBR`; Android qualifiers such as `pt-rBR` and
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`.
is omitted and `string_tables_only` is not true, 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.
@@ -143,3 +154,11 @@ locales — launcher version, on-disk digest agreement, apktool.yml metadata
(1.2.3, SDK 24–34), manifest package agreement, default and `de` string
tables projected. Parser goldens in the test suite come from the same
decode. See `docs/testing.md` for the lane summary.
Multi-locale and table-only projection were also verified through real CLI and
stdio MCP workflows on Linux with Apktool 3.0.3/JDK 21 against NewPipe 0.29.1,
ApiDemos 6.0.18 and Appium Settings 8.2.1. Independent XML projection checks,
exact facts/Evidence parity and bounded task costs are recorded in the
[shared-decode and table-only checkpoint](../experiments/analysis-efficiency/apktool-string-tables.md).
This qualifies selected resource facts, not application execution or independent
binary resource-decoder completeness.
@@ -1,5 +1,10 @@
# Analysis efficiency measurements
For Apktool locale queries, see the
[shared-decode and table-only checkpoint](apktool-string-tables.md). It separates
the existing decode reuse from duplicate string delivery, with three real APKs,
CLI/MCP parity, whole-task estimates and negative timing results.
For immutable imported native/JavaScript parents, see the
[validation-reuse checkpoint](immutable-parent-validation.md). It measures the
owning format change with the existing retained-query driver, including first
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,171 @@
# Apktool shared decoding and table-only projection
The measured question is the same in every comparison: inspect an APK's
metadata/manifest and its default, German and Brazilian Portuguese string tables.
One existing batch removes repeated decoding. An optional table-only projection
then removes repeated primary strings without changing the selected facts.
This is a static resource-query measurement, not an agent evaluation or a claim
about reconstructing an application's behavior. The machine-readable measured
summary is [apktool-string-tables.json](apktool-string-tables.json).
## Revisions, inputs and execution
- Baseline: `8f7d12e30d63f3d3b4cfe2a90336362841438f56`, latest main at issue start.
- Projection source: `406af90023a545fb280601bf51f445e122c36378`.
- Existing shared decode: [#4777](https://github.com/morluto/rea/issues/4777),
integrated by [#4804](https://github.com/morluto/rea/pull/4804).
- Projection follow-up: [#4982](https://github.com/morluto/rea/issues/4982).
- Linux x86-64, kernel 6.1.0-53-cloud-amd64, Node 24.18.0, npm 11.16.0,
Temurin 21.0.12.1, Apktool 3.0.3. The selected JAR's closeout digest is
`dbf930b076c6b9be08d57c449cacefc3bdd6b71ebd59b3066fc0e1f5b14f9423`;
decoder contents were not independently hashed for every request.
- [newpipe](https://github.com/TeamNewPipe/NewPipe/releases/tag/v0.29.1): 11,507,122 bytes; SHA-256 `18447bfb1e06d113edc88df93f471827280de06f6f3d4dc42f56f28b9c1bab79`. Valid projected counts: 1126/833/779; unsupported entries: 1/1/1.
- [apidemos](https://github.com/appium/android-apidemos/releases/tag/v6.0.18): 6,521,217 bytes; SHA-256 `a9eecf37b26cd084855c530db81c2bb1b91f4c1b095a04f47aa7c20e2791f686`. Valid projected counts: 967/29/29; unsupported entries: 8/0/0.
- [settings](https://github.com/appium/io.appium.settings/releases/tag/v8.2.1): 3,099,589 bytes; SHA-256 `e4024b3ee348929252bcaf5eb422bcab9e4ace96703499a84f3332281c03375f`. Valid projected counts: 25/19/19; unsupported entries: 0/0/0.
The counts are default/de/pt-BR respectively. Source paths, XML digests, exact
caller paths, runtime hashes and per-run identities/costs are in the JSON summary.
The APKs were never installed or executed. Input digests were checked before and
after each completed task; provider-owned workspaces were confirmed absent.
Each task uses a fresh production stdio MCP runtime, complete paginated tool
discovery, client inspection, resource call(s), `close_binary`, transport close
and owned cleanup. OS file caches were not flushed. Every decode has a fresh JVM,
APK snapshot, framework directory and workspace: these are not warm retained
engine/database queries, nor cold-disk measurements.
Tasks run serially on one logical CPU, with an 8 GiB family hard limit, 7 GiB
early stop and no swap. Admission requires 16 GiB host availability; running
tasks stop below 8 GiB host availability. CPU and kernel `memory.peak` cover
the complete owned cgroup, including subprocesses, observer and file cache.
Counters are captured at payload exit while the observer is alive; disappeared
final whole-scope counters remain unknown. All completed payload receipts show
successful owned cleanup and zero observed OOM/limit events at that checkpoint.
## Correctness and owning change
Separate native decodes were retained for independent Python Expat validation.
It checks direct string entries, source entity spelling, unsupported markup
counts, ordering and emitted prefixes against the XML bytes. Manifest text and
package identity are checked separately. All six serial/batch pairs agree on
selected strings, manifest, metadata and target identity. All nine projection
pairs additionally preserve table counts and coverage; batch/table-only counts
and coverage match the XML oracle. This qualifies REA's projection of these
native outputs, not independent binary ARSC decoder completeness.
The existing default batch repeats its primary table in top-level `strings` and
`string_tables[0]`. On NewPipe this made the full resource replies larger than
three serial replies despite reducing decode calls: 616,059 → 680,188 bytes.
ApiDemos' larger common manifest produces the opposite tradeoff. The existing
batch remains the compatibility default.
`string_tables_only: true` (CLI `--string-tables-only`) keeps the complete
requested table facts inline in `string_tables` and leaves `strings` empty by
request. An unrequested field does not establish that a source table is empty.
This also supports one table without `locales`. `include_strings: false` still
suppresses string projection. Omitting the new option preserves old requests:
three real legacy workflows produce identical complete MCP replies, including
Evidence identity, at the new source revision.
The resource projection owner computes the output choice and charges aggregate
entries for the copies actually emitted. The same 10,000-entry policy can thus
return five distinct 2,000-entry tables rather than consuming capacity on a
duplicate primary copy. Per-table limits, XML budgets, physical alias reuse,
coverage, partial observations and cancellation remain at the existing owner.
CLI and MCP share this implementation; there is no new tool or cross-call cache.
## Existing shared decode: three serial calls versus one batch
Three alternating fresh-session pairs per APK, all at the same main revision.
The table reports marginal medians; paired speedups are reported separately.
Elapsed/CPU include discovery, client/Java startup, calls, transcript/hash checks
and cleanup. Native oracle creation, tokenization and verification gates are
separate costs in the JSON, not hidden inside these task timings.
| Target | Resource calls, s | Whole elapsed, s | Whole CPU, s | Family peak, MiB |
| -------- | ----------------: | ---------------: | ------------: | ---------------: |
| newpipe | 23.19 → 7.40 | 35.32 → 19.86 | 34.95 → 19.65 | 1341.7 → 1309.5 |
| apidemos | 17.43 → 6.22 | 30.32 → 18.85 | 30.05 → 18.63 | 1187.1 → 1202.7 |
Paired resource-call speedup medians: NewPipe **3.23×** (2.90–3.23), ApiDemos
**2.80×** (2.66–3.09). Instrumentation confirms three versus one actual decoder
invocations. These call timings include protocol/projection costs; they do not
isolate the Java engine phase. NewPipe's whole raw token estimate increases
951,332 → 962,330 with the default batch; ApiDemos decreases
1,054,893 → 948,591. Fewer calls alone do not establish less context.
## New selector: default batch versus table-only batch
Three alternating pairs per APK; NewPipe repetitions 1/4/3, others 1/2/3.
Repetition 4 replaces a refused start, described below. Both variants still
perform one decode and return the same required facts.
| Target | Resource call, s | Whole elapsed, s | Whole CPU, s | Family peak, MiB |
| -------- | ---------------: | ---------------: | ------------: | ---------------: |
| newpipe | 9.18 → 9.35 | 23.95 → 24.85 | 23.65 → 23.82 | 1327.5 → 1323.6 |
| apidemos | 6.46 → 5.81 | 23.28 → 19.79 | 22.90 → 19.47 | 1247.8 → 1197.0 |
| settings | 4.21 → 4.06 | 17.66 → 19.39 | 17.42 → 19.04 | 1191.6 → 1122.4 |
There is no consistent latency or whole-family memory improvement established
for the selector. NewPipe and Settings have higher whole-task elapsed and CPU
medians. Elapsed ranges overlap: NewPipe 23.53–34.46 → 21.59–42.80 s;
ApiDemos 18.55–27.07 → 19.25–25.96 s; Settings 16.55–23.39 → 16.14–21.72 s.
The cause of this variation was not established; a passing or faster run does
not explain a slower one. The selector's supported benefit is reduced duplicate
delivery, with its full task costs retained here.
Full resource reply bytes fall 680,188 → 506,481 for NewPipe,
616,841 → 463,588 for ApiDemos and 38,725 → 34,199 for Settings.
Offline `tiktoken` 0.12.0 / `cl100k_base` estimates count one complete catalog
page-array plus every call name/argument/reply, including inspection and close.
Full replies include both textual and structured copies. Text-only replies are
also counted separately. These are serialization estimates, not actual model
usage; initialization/framing, prompts, client filtering/rendering/replay and
continuations are unmeasured.
| Target | Text reply tokens | Whole raw transport tokens | Whole estimated reduction |
| -------- | ----------------: | -------------------------: | ------------------------: |
| newpipe | 73,661 → 55,797 | 962,330 → 924,352 | 3.95% |
| apidemos | 65,693 → 49,162 | 948,591 → 913,693 | 3.68% |
| settings | 4,387 → 3,942 | 814,859 → 814,029 | 0.10% |
Discovery still has 207 tools; only `decode_android_resources` changes.
The added schema costs 557 catalog bytes / 113 estimated catalog tokens
(805,217 → 805,330). Raw discovery dominates this transport estimate; it does
not establish what a client supplies to its model. Smaller replies and these
estimates do not prove an actual agent context or investigation advantage.
## Verification, attempts and limits
- 94 source tests: provider/resource projection/service, including alias reads,
aggregate output exhaustion, malformed/physical failures, partial facts,
cancellation and cleanup. The table-only capacity, single-table provider
and CLI cases fail on the old implementation (three selected failures,
82 unrelated tests skipped).
- Nine compiled CLI/MCP tests: schema conversion/advertisement, option forwarding,
Evidence parameters, legacy locale grammar and existing boolean choices.
- 36 completed real MCP tasks and three real CLI tasks. Nine exact projection
oracle pairs; three legacy full-reply comparisons; alias/missing, single-table
and omitted-string cases pass. CLI normalized data, subjects and Evidence IDs
match MCP exactly on all three APKs.
- `check:fast`, `build:cached` and `verify:test-discovery` passed; the last
discovered all 1,579 test files exactly once. `npm run check` and `npm run docs:check` also passed.
- An authored test group initially exceeded the 150-line lint limit (153).
That gate failed after 189.31 s / 186.19 CPU s, peak 4,487,798,784 bytes;
build/compiled checks did not run in that attempt. The capacity test was moved
out of that group, and the complete corrected gate passed after 343.97 s /
338.67 CPU s, peak 4,311,293,952 bytes. No rule was disabled.
- `projection-newpipe-after-2` was refused before scope/payload creation because
host availability was below 16 GiB; a following poll showed about 10.5 GiB.
It has no payload timing/counters and is not a completed measurement. The
refusal stays recorded; a fresh whole repetition-4 pair ran after recovery.
No existing receipts were overwritten and resource thresholds were unchanged.
Raw transcripts, native outputs, intermediate failures and per-request workspace
arguments remain in ignored local audit storage; the committed JSON is a curated
summary. Small Settings resources and negative time results are retained.
Actual models, APK execution, other providers/platforms and general complex
software effectiveness were not verified by this checkpoint.
+32
View File
@@ -169,6 +169,38 @@ const apkFixture = async (
};
};
it("returns table-only facts for a single selected primary table", async () => {
const { command, root } = await writeApktoolStub(decodeStub());
const apk = await apkFixture(root);
const provider = providerOver(command);
onTestFinished(() => provider.close());
const outcome = await new ApktoolResourceAnalysisService(provider).execute(
"decode_android_resources",
{ path: apk.path, string_tables_only: true },
);
if (!outcome.ok) throw outcome.error;
expect(outcome.value).toMatchObject({
parameters: { path: apk.path, string_tables_only: true },
subject: { digest: { sha256: apk.sha256 } },
normalized_result: {
strings: [],
string_tables: [
{
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",
},
],
},
});
});
it.each([
[false, false],
[true, false],
+1 -1
View File
@@ -660,7 +660,7 @@ export class ApktoolProvider {
: "complete";
completedDecode.strings = [...projectedStrings.strings];
if (input.locales !== undefined)
if (input.locales !== undefined || input.string_tables_only === true)
completedDecode.string_tables = [...projectedStrings.tables];
completedDecode.coverage = coverage;
if (!projection.ok) throw projection.error.cause;
+121 -64
View File
@@ -18,30 +18,66 @@ const textRead = (text: string) => ({
sourceBytes: Buffer.byteLength(text, "utf8"),
});
describe("physical Apktool string facts", () => {
it("reads one physical table for two caller-selected aliases", async () => {
const paths: string[] = [];
const outcome = await projectApktoolStringTables(
{
async readText(path) {
paths.push(path);
return textRead(xml(1));
},
it("charges table-only output without reserving a duplicate primary copy", async () => {
const paths: string[] = [];
const outcome = await projectApktoolStringTables(
{
async readText(path) {
paths.push(path);
return textRead(xml(MAX_STRINGS));
},
"decoded",
{ include_strings: true, locale: "pt-BR", locales: ["pt-rBR"] },
);
if (!outcome.ok) throw outcome.error.cause;
const result = outcome.value;
expect(paths).toEqual([
join("decoded", "res", "values-pt-rBR", "strings.xml"),
]);
expect(result.tables.map((table) => table.locale)).toEqual([
"pt-BR",
"pt-rBR",
]);
expect(result.tables.map((table) => table.total_count)).toEqual([1, 1]);
});
},
"decoded",
{
include_strings: true,
locales: ["de", "es", "it", "fr"],
string_tables_only: true,
},
);
if (!outcome.ok) throw outcome.error.cause;
expect(paths).toHaveLength(5);
expect(outcome.value.strings).toEqual([]);
expect(outcome.value.tables.map((table) => table.status)).toEqual(
Array.from({ length: 5 }, () => "present"),
);
expect(
outcome.value.tables.reduce((sum, table) => sum + table.strings.length, 0),
).toBe(MAX_AGGREGATE_STRINGS);
expect(outcome.value.coverage).toBe("complete");
});
describe("physical Apktool string facts", () => {
it.each([false, true])(
"reads one physical table for two caller-selected aliases (table-only: %s)",
async (tablesOnly) => {
const paths: string[] = [];
const outcome = await projectApktoolStringTables(
{
async readText(path) {
paths.push(path);
return textRead(xml(1));
},
},
"decoded",
{
include_strings: true,
locale: "pt-BR",
locales: ["pt-rBR"],
string_tables_only: tablesOnly,
},
);
if (!outcome.ok) throw outcome.error.cause;
const result = outcome.value;
expect(paths).toEqual([
join("decoded", "res", "values-pt-rBR", "strings.xml"),
]);
expect(result.tables.map((table) => table.locale)).toEqual([
"pt-BR",
"pt-rBR",
]);
expect(result.tables.map((table) => table.total_count)).toEqual([1, 1]);
},
);
it("counts complete source entries beyond the retained table prefix", async () => {
const outcome = await projectApktoolStringTables(
@@ -138,50 +174,71 @@ describe("physical Apktool string facts", () => {
});
describe("Apktool string projection admission", () => {
it("retains completed tables when cancellation arrives before the next selection", async () => {
const controller = new AbortController();
const reason = { selected: "caller" };
const paths: string[] = [];
const result = await projectApktoolStringTables(
{
async readText(path) {
paths.push(path);
controller.abort(reason);
return textRead(xml(1));
it.each([false, true])(
"retains completed tables when cancellation arrives before the next selection (table-only: %s)",
async (tablesOnly) => {
const controller = new AbortController();
const reason = { selected: "caller" };
const paths: string[] = [];
const result = await projectApktoolStringTables(
{
async readText(path) {
paths.push(path);
controller.abort(reason);
return textRead(xml(1));
},
},
},
"decoded",
{ include_strings: true, locales: ["de", "fr"] },
controller.signal,
);
if (result.ok) throw new Error("Expected cancellation");
expect(result.error.cause).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toHaveProperty("cause", reason);
expect(paths).toHaveLength(1);
expect(result.error.partial).toMatchObject({
coverage: "partial",
tables: [{ locale: null, total_count: 1 }],
});
expect(result.error.partial.limitations).toContain(
'String projection stopped at locale "de"; this selection and 1 remaining selections were not projected',
);
});
"decoded",
{
include_strings: true,
locales: ["de", "fr"],
string_tables_only: tablesOnly,
},
controller.signal,
);
if (result.ok) throw new Error("Expected cancellation");
expect(result.error.cause).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toHaveProperty("cause", reason);
expect(paths).toHaveLength(1);
expect(result.error.partial).toMatchObject({
coverage: "partial",
tables: [{ locale: null, total_count: 1 }],
});
expect(result.error.partial.strings).toHaveLength(tablesOnly ? 0 : 1);
expect(result.error.partial.limitations).toContain(
'String projection stopped at locale "de"; this selection and 1 remaining selections were not projected',
);
},
);
it("honors omitted strings without reading any selected table", async () => {
const outcome = await projectApktoolStringTables(
{
async readText() {
throw new Error("Unexpected read");
it.each([false, true])(
"honors omitted strings without reading any selected table (table-only: %s)",
async (tablesOnly) => {
const outcome = await projectApktoolStringTables(
{
async readText() {
throw new Error("Unexpected read");
},
},
},
"decoded",
{ include_strings: false, locale: "de", locales: ["fr"] },
);
expect(outcome).toEqual({
ok: true,
value: { strings: [], tables: [], limitations: [], coverage: "complete" },
});
});
"decoded",
{
include_strings: false,
locale: "de",
locales: ["fr"],
string_tables_only: tablesOnly,
},
);
expect(outcome).toEqual({
ok: true,
value: {
strings: [],
tables: [],
limitations: [],
coverage: "complete",
},
});
},
);
it("preserves the exact physical read failure", async () => {
const cause = new Error("Physical read and close failed");
+11 -5
View File
@@ -19,7 +19,7 @@ import type { ApktoolWorkspace } from "./ApktoolWorkspace.js";
const STRINGS_XML_MAX_BYTES = 8 * 1024 * 1024;
type Selection = Pick<
Extract<ApktoolRequest, { operation: "decode_android_resources" }>["input"],
"include_strings" | "locale" | "locales"
"include_strings" | "locale" | "locales" | "string_tables_only"
>;
type PresentTable = {
readonly status: "present";
@@ -152,7 +152,8 @@ export const projectApktoolStringTables = async (
"Aggregate string table projection stopped due to resource limits",
);
return {
strings: tables[0]?.strings ?? [],
strings:
input.string_tables_only === true ? [] : (tables[0]?.strings ?? []),
tables,
limitations: [...limitations],
coverage: tables.some((table) => table.coverage === "partial")
@@ -223,9 +224,14 @@ export const projectApktoolStringTables = async (
);
continue;
}
// Batch output includes the primary strings twice, in the legacy field and
// in string_tables. Charge that representation before selecting entries.
const copies = index === 0 && input.locales !== undefined ? 2 : 1;
// The default batch shape repeats primary entries in strings and tables;
// table-only output charges one copy. Account before selecting entries.
const copies =
index === 0 &&
input.locales !== undefined &&
input.string_tables_only !== true
? 2
: 1;
const remaining = Math.max(0, MAX_AGGREGATE_STRINGS - outputEntries);
const selected = fact.entries.slice(0, Math.floor(remaining / copies));
outputEntries += selected.length * copies;
@@ -32,6 +32,7 @@ const refusedPort: ApktoolResourceAnalysisPort = {
it.each([
[{ path: "/targets/app.apk", include_strings: "yes" }],
[{ path: "/targets/app.apk", string_tables_only: "yes" }],
[{ path: "/targets/app.apk", locale: "not a locale" }],
[{ path: "/targets/app.apk", locales: ["not a locale"] }],
[{ path: "/targets/app.apk", locales: [] }],
+5
View File
@@ -55,6 +55,8 @@ export const registerApktoolCommands = (
// `b+zh+Hans+CN` form at the CLI boundary.
options: z.object({
locale: apktoolInputSchemas.decode_android_resources.shape.locale,
"string-tables-only":
apktoolInputSchemas.decode_android_resources.shape.string_tables_only,
locales:
apktoolInputSchemas.decode_android_resources.shape.locales.describe(
"Repeat to project multiple localized string tables, e.g. --locales de --locales pt-BR",
@@ -74,6 +76,9 @@ export const registerApktoolCommands = (
execute(CLI_COMMANDS.decodeAndroidResources, "decode_android_resources", {
path: args.path,
include_strings: options.strings,
...(options["string-tables-only"] === undefined
? {}
: { string_tables_only: options["string-tables-only"] }),
...(options.locale === undefined ? {} : { locale: options.locale }),
...(options.locales === undefined ? {} : { locales: options.locales }),
}),
@@ -39,7 +39,11 @@ export const APKTOOL_TOOL_CONTRACTS = [
},
{
title: "Decode multiple locale string tables in one pass",
input: { path: "/targets/Example.apk", locales: ["de", "fr"] },
input: {
path: "/targets/Example.apk",
locales: ["de", "fr"],
string_tables_only: true,
},
},
],
},
+22 -7
View File
@@ -69,6 +69,12 @@ export const apktoolInputSchemas = {
.describe(
`Project res/values/strings.xml entries (bounded to ${String(MAX_STRINGS)} entries)`,
),
string_tables_only: z
.boolean()
.optional()
.describe(
"Return selected strings only in string_tables, leaving top-level strings empty. Omit to preserve the primary strings field.",
),
locale: androidLocaleQualifierSchema
.optional()
.describe(
@@ -178,15 +184,24 @@ export const apktoolResultSchemas = {
package_name: z.string().nullable(),
}),
manifest: z.string().nullable(),
strings: z.array(
z.strictObject({
name: z.string().min(1),
value: z.string(),
}),
),
strings: z
.array(
z.strictObject({
name: z.string().min(1),
value: z.string(),
}),
)
.describe(
"Primary string entries; empty when include_strings is false or string_tables_only is true. An unrequested field does not establish an empty table.",
),
locale: z.string().nullable(),
locales: z.array(z.string().min(1)),
string_tables: z.array(stringTableSchema).optional(),
string_tables: z
.array(stringTableSchema)
.optional()
.describe(
"Selected table facts, included when locales is supplied or string_tables_only is true",
),
decoded_file_count: z.number().int().nonnegative(),
coverage: z.enum(["complete", "partial"]),
}),
@@ -60,6 +60,29 @@ const OWNER_ADMITTED_LOCALES = [
"b+fil+Latn+419+ABCDEFGH",
] as const;
it("forwards the table-only string choice through the CLI", async () => {
const result = await invoke([
"decode-android-resources",
"/targets/app.apk",
"--string-tables-only",
"--locales",
"de",
"--json",
]);
expect(result.exitCode, result.stdout).toBe(0);
expect(result.requests).toEqual([
{
operation: "decode_android_resources",
input: {
path: "/targets/app.apk",
include_strings: true,
locales: ["de"],
string_tables_only: true,
},
},
]);
});
it("forwards every locale the apktool input owner admits", async () => {
for (const locale of OWNER_ADMITTED_LOCALES) {
const result = await invoke([
+14 -2
View File
@@ -63,14 +63,17 @@ const results: Readonly<Record<string, unknown>> = {
it("advertises exact valid Apktool schemas and records inline evidence through MCP", async () => {
const requestedLocales: (string | undefined)[] = [];
const tableOnlySelections: (boolean | 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")
if (request.operation === "decode_android_resources") {
requestedLocales.push(request.input.locale);
tableOnlySelections.push(request.input.string_tables_only);
}
return ok(createAnalysisExecution(results[request.operation], identity));
},
};
@@ -138,7 +141,16 @@ it("advertises exact valid Apktool schemas and records inline evidence through M
const batched = await client.callTool({
name: "decode_android_resources",
arguments: { path: "/targets/probe.apk", locales: ["de"] },
arguments: {
path: "/targets/probe.apk",
locales: ["de"],
string_tables_only: true,
},
});
expect(batched.isError).not.toBe(true);
expect(parseEvidence(batched.structuredContent).parameters).toMatchObject({
locales: ["de"],
string_tables_only: true,
});
expect(tableOnlySelections).toEqual([undefined, undefined, undefined, true]);
});