mirror of
https://github.com/morluto/rea.git
synced 2026-10-19 04:19:54 -05:00
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:
14 files changed
+3107
-82
No files matched your search
@@ -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.
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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: [] }],
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
Reference in new issue
Block a user