AX notes · Measurement

Pre-gzipping Android assets, measured: zero APK savings, and .pack.gz became .pack with a FileNotFoundException

Our pre-gzipped level packs were each 18 bytes larger than the APK's own compressed copies, and the build dropped the trailing .gz, so the files could not be opened on the device.

By SugarMount Android development 7 min read Last updated

The five pre-gzipped level packs totaled 705,234 bytes. The same packs shipped as plain text take 705,144 bytes compressed inside the release APK. The "4.4×" that justified gzip at design time compared raw files with gz files; the draft never compared gz with what the APK does on its own. The choice surfaced at 22:28 on October 3, 2026 (KST), when the 1,000-level update of Arrow Knots died with FileNotFoundException right after launch on our S21. Every JVM test that read the same packs had passed.

Sources: the game repository's git history (f5b50c0, e3c7781, 4d46413), the monkey log and APK listing in that day's agent session record, and compressed sizes read with Python's zipfile from the release and debug APKs built on October 28, 2026. Checked October 30, 2026.

Contents (9)
  1. 705,234 bytes as gz files, 705,144 inside the APK
  2. The design's 4.4× compared the wrong pair
  3. The APK listing had only arrows-p1.pack
  4. The tests opened the same files through a different function
  5. Plain packs, and what renaming the extension would have kept
  6. What the 19-line name test doesn't look at
  7. Checking asset names and sizes in your own APK
  8. Still to do: .pack.gz left in the spec
  9. FAQ

705,234 bytes as gz files, 705,144 inside the APK

We opened the release APK built on October 28, 2026, read the raw and compressed size of each of the five level packs, and set them next to the .gz files the repository held until commit 4d46413. The pack contents haven't changed since 4d46413, and each old .gz, decompressed, has the same SHA-1 as its plain pack, so these are the same bytes.

PackRawOld .gz fileCompressed in release APKDifference
p1 (1-200)122,96625,89625,87818
p2 (201-500)524,852111,113111,09518
p3 (501-700)679,344147,461147,44318
p4 (701-1000)1,397,319310,108310,09018
endless (1001-1100)492,814110,656110,63818
Total3,217,295705,234705,14490

Every pack differs by exactly 18 bytes. A gzip file wraps deflate data in a 10-byte header and an 8-byte trailer (CRC32 and original length), so the compressed bodies are the same size and the gap is the gzip wrapper. Don't pre-gzip text assets.

Bar chart: the five level packs are 3,217,295 bytes raw, 1,195,288 bytes compressed in the debug APK, 705,234 bytes as the old gz files, and 705,144 bytes compressed in the release APK.
The release APK's deflate alone came out 90 bytes smaller than the pre-gzipped files.

In the debug APK the same packs take 1,195,288 bytes, about 1.7 times the release figure. We haven't checked why. Measure asset sizes in a release APK.

The design's 4.4× compared the wrong pair

The case for gzip was one line in a design draft an agent wrote that afternoon: "300 levels = 535 KB raw / 121 KB gz (4.4×)", with an estimate of 0.7 to 0.85 MB gzipped for 1,000 levels. Raw sizes never ship, since the APK compresses the plain packs too. The pair to compare was the gz files against the plain packs as the APK compresses them.

The format went into the update spec committed at 17:18 (f5b50c0). Its commit message says 24 agents cross-checked it. It put the packs in assets as .pack.gz and had the app read them with GZIPInputStream. At 18:32 commit e3c7781 generated the five packs in that format. That commit was part of a 41-agent workflow (implementation, review and merges) that finished at 22:27 with "none failed". Why that "done" meant JVM tests and a debug build is covered in "Use hundreds of agents" in practice.

The APK listing had only arrows-p1.pack

The fresh-install monkey test caught it first; it ran because the operator's first request said to install on the S21 and run E2E. The script clears app data and sends 800 random events with seeds 7, 21 and 99. Seeds 7 and 21 each logged two fatal exceptions, and every one that made it into the log had the same message:

E AndroidRuntime: java.lang.RuntimeException: Unable to start activity
  ComponentInfo{…/sugar.arrows.MainActivity}:
  java.io.FileNotFoundException: arrows-p1.pack.gz

Thirty seconds later the agent listed the assets inside the debug APK. The source folder had arrows-p1.pack.gz at 25,896 bytes; the APK had assets/arrows-p1.pack at 122,966 bytes. All five packs looked like that: no .gz in the name, stored at their decompressed size. The commit message attributes this to aapt.

Step diagram for October 3: at 17:18 the spec picks .pack.gz and GZIPInputStream, at 18:32 five packs are generated, at 22:27 the 41-agent workflow finishes with no failures, at 22:28 the S21 monkey run hits FileNotFoundException, at 22:29 the name test goes red, at 22:31 commit 4d46413, at 22:34 three monkey seeds show 0 fatal.
A build with every JVM test green died on its first real-device launch; the fix took three and a half minutes.

The tests opened the same files through a different function

The app and the tests share PackStore, the class that reads packs, but each passes in its own way to open a file. MainActivity uses PackStore(open = assets::open). ProgressTest uses PackStore(open = { FileInputStream(File(ASSETS, it)) }), where ASSETS is src/main/assets. The tests read the repository files as they are, so they see the .gz name and the gzip bytes. The generator module's test helper Shipped even carried the comment "read the way the app and tools read them". The app reads through AssetManager.

Plain packs, and what renaming the extension would have kept

At 22:31, 4d46413 decompressed the packs into plain .pack files and removed GZIPInputStream from Levels.kt. The generator's writeGz became writePack, which writes text. The same commit deleted arrows-v2.pack (534,864 bytes), an old pack the app no longer read; two tests still did, and were moved to the new packs. Before the commit, 256 tests passed (engine 83, generator 40, app 133). At 22:34 the reinstalled app on the S21 had 0 fatal exceptions on all three monkey seeds and got through the tutorial to level 1.

Two other fixes were possible. Renaming the extension to something else would keep the build away from the name, so gzip could stay. But the table shows there is no size to gain, and all that would remain is GZIPInputStream plus the mtime-0 header the generator wrote to keep builds byte-identical. Keeping .gz in the source and having only the app open .pack would work on a device, but the tests would go on reading a different name and format than the app, which is how this crash got through. Neither alternative was tried in the repository; this comparison is our judgment while writing the note.

What the 19-line name test doesn't look at

The body of AssetNamesTest, added in 4d46413, is two statements. It was run before the fix and failed at 22:29 with no asset name ends in gz FAILED.

val gz = File("src/main/assets").listFiles().orEmpty().map { it.name }.filter { it.endsWith(".gz") }
assertTrue(gz.isEmpty(), "aapt renames these on install: $gz")

listFiles() doesn't descend into subfolders, so a file like assets/levels/x.gz slips past. Other source sets such as src/debug/assets, and the assets of the other app module in the same repository, aren't checked either. The "on install" in the message is also wrong: the APK listing showed .pack before anything was installed, so the rename happens at build time. We haven't confirmed which step of AGP 9.4.1 does it. The size result comes from 1,100 levels of text packs only; files that deflate can't shrink are outside it.

Checking asset names and sizes in your own APK

  1. Put the release APK listing next to the source folder: unzip -l app-release.apk | grep assets/ and ls src/main/assets. If names or counts differ, find the code that opens those files.
  2. Add a JVM test that fails on any asset name ending in .gz, and watch it fail before you fix anything. If you have subfolders, use walkTopDown().
  3. Decide whether to pre-compress from the compressed sizes inside the release APK. In Python, add up compress_size over zipfile.ZipFile(apk).infolist() and compare with your gz files.
  4. Open the first screen on a real device from a fresh install with app data cleared. The script is in the fresh-install monkey test note.

Still to do: .pack.gz left in the spec

As of HEAD on October 29, 2026 (2310d1f), the update spec docs/09-parts-1000-spec.md still names .pack.gz five times, on four lines, and still says the packs are read with GZIPInputStream. The helper that reads the plain packs is still called Shipped.gzLines. When the spec gets fixed, we'll also decide how far AssetNamesTest should look.

FAQ

Why does opening a .gz file from Android assets throw FileNotFoundException?

Because this project's build (AGP 9.4.1) dropped the trailing .gz and stored the file decompressed: the APK listing showed a decompressed x.pack instead of x.pack.gz. Open the name without .gz and drop GZIPInputStream.

Does pre-gzipping assets make the APK smaller?

Not for our 1,100 levels of text packs. The release APK's deflate came to 705,144 bytes; the pre-gzipped files were 705,234 bytes, larger by the 18-byte gzip header and trailer per pack.

Can a JVM unit test catch this kind of packaging problem?

It can enforce the naming rule. Whether AssetManager actually opens the file is only confirmed by running the app on a device or emulator.

This note is written from values SugarMount measured itself; an AI coding agent was used to draft it.

All notes on Android development · All AX notes