android-resource-shrink-verification
Prove artwork and drawables survive R8 resource shrinking in an Android release APK — why name-matching and byte-matching both give false "stripped" verdicts, and the aapt2 resource-table walk that ac
SKILL.mdskills/managed/android-resource-shrink-verification/SKILL.md
GitHubVerifying artwork survives resource shrinking
isShrinkResources = true can keep a resource entry while replacing its
contents with a tiny stub. Two obvious checks both produce confident false
verdicts, so go straight to the resource table.
Why the obvious checks lie
| method | typical output | why it is wrong |
|---|---|---|
grep APK entry names for art_empty_stats |
"0 of 18 present" | release renames files to res/tn.png |
| match source file sizes against APK entries | "kept 4, not found 19" | AGP re-encodes PNG to WebP, so sizes change |
A missing name is not evidence of removal when the toolchain renames by design — the same rule as R8 class renaming.
The check that settles it
Read the actual table, then follow each entry to its renamed file and look at the byte size. A stub is a few hundred bytes; real art is not.
import os, glob, subprocess, re, zipfile
sdk = os.path.expandvars(r"%LOCALAPPDATA%\Android\Sdk")
aapt2 = sorted(glob.glob(os.path.join(sdk, "build-tools", "*", "aapt2.exe")))[-1]
apk = "app/build/outputs/apk/release/app-release-unsigned.apk"
out = subprocess.run([aapt2, "dump", "resources", apk],
capture_output=True, text=True, errors="replace").stdout
z = zipfile.ZipFile(apk)
sizes = {n: z.getinfo(n).file_size for n in z.namelist()}
lines = out.splitlines()
wanted = ("art_empty_quests", "ic_rank_monarch") # names that MUST survive
for i, line in enumerate(lines):
m = re.search(r"(?:drawable|mipmap)[^/]*/(\w+)$", line.strip())
if not m or m.group(1) not in wanted:
continue
for j in range(i + 1, min(i + 4, len(lines))): # the file path follows the entry
f = re.search(r"(res/[\w\.]+)", lines[j])
if f:
print(f"{m.group(1):24} {f.group(1):16} {sizes.get(f.group(1), 0)} B")
break
Expected shape: raster art in the tens or hundreds of KB, vector drawables around 1 KB. Anything at a few hundred bytes for a photo-sized asset is a stub.
Note aapt2 dump resources prints the entry and its file on separate lines,
which is why the loop looks ahead a few lines rather than parsing one.
The risk that makes stripping real
The shrinker removes what it cannot see referenced. Static R.drawable.x is
visible; a name lookup is not:
rtk grep -rn "getIdentifier|resources.getDrawable" app/src/main --include=*.kt
No hits means no dynamic lookups, so nothing can be silently stripped. Hits
mean either tools:keep in a raw/keep.xml, or convert the call site to a
static reference — the latter is the real fix.
Pair it with the dex probe
Resources and code shrink separately. Check both before claiming a release is verified:
z = zipfile.ZipFile(apk)
dex = b"".join(z.read(n) for n in z.namelist() if n.endswith(".dex"))
for p in [b"$$serializer", b"YourDto", b"YourDatabase_Impl"]:
print(p.decode(), "KEPT" if p in dex else "MISSING", dex.count(p))
What this still does not prove
The app runs. Install a debug-signed copy of the release APK and open the screens that show the art — an empty-state illustration is only proven by being rendered. Say which of the two you did.