How to check any APK, AAB or AAR for 16 KB page-size alignment
Google Play requires native code to be compatible with 16 KB pages. Compatibility needs every 64-bit .so to have all PT_LOAD segments aligned to at least 16384 bytes (and, for uncompressed libs in an APK, a zip offset divisible by 16384).
1. Get the checker
Download check16k.py (Python 3 standard library only; reads ELF headers directly, no Android SDK needed). Source is below and free to use.
2. Run it
python3 check16k.py app-release.aab
python3 check16k.py some-library.aar
python3 check16k.py libfoo.so --jsonIt accepts .so, .apk, .aab, .aar and .zip. Only arm64-v8a and x86_64 libs are checked (the ABIs the rule covers). Per library it reports the minimum p_align.
| Exit code | Meaning |
|---|---|
| 0 | all 64-bit libs aligned |
| 1 | at least one unaligned lib |
| 2 | usage, input or corrupt-lib error |
| 3 | readable, but no arm64-v8a/x86_64 .so found |
3. If a library is offending
Find it on the library list for the version to upgrade to and the Gradle line. Then confirm the resolved version with ./gradlew :app:dependencyInsight and re-run the checker on the rebuilt bundle.
What it does not check: RELRO and similar linker details, bundle configuration, and your own native code's build flags (use NDK r28+ or -Wl,-z,max-page-size=16384).
Source
#!/usr/bin/env python3
"""check16k.py - measure Android 16 KB page-size ELF alignment (no Android SDK needed).
Reads every 64-bit little-endian .so (arm64-v8a / x86_64 only; the ABIs Play's 16 KB rule covers)
from .so / .apk / .aab / .aar / .zip inputs and reports the MINIMUM PT_LOAD p_align per lib
(every LOAD segment must be >= 16384). For .apk, stored (uncompressed) libs also need
zip-offset % 16384 == 0. Use --json for machine-readable output.
Exit codes: 0 all 64-bit libs aligned | 1 at least one unaligned lib | 2 usage/input/corrupt-lib error
(3 = inputs read fine but no arm64-v8a/x86_64 .so found). Non-ELF / 32-bit .so inputs -> 2 with a message.
"""
import argparse, io, json, struct, sys, zipfile
ABIS = ("arm64-v8a", "x86_64")
class NotApplicable(Exception):
"""Input is readable but not a 64-bit little-endian ELF with PT_LOAD; str(e) says why."""
def load_align(b):
"""min p_align over PT_LOAD of an ELF64-LE blob; raises NotApplicable otherwise."""
if len(b) < 0x40 or b[:4] != b"\x7fELF":
raise NotApplicable("not an ELF file (bad magic)")
if b[4] == 1:
raise NotApplicable("32-bit ELF: the 16 KB rule covers 64-bit (arm64-v8a/x86_64) only")
if b[4] != 2 or b[5] != 1:
raise NotApplicable("not a 64-bit little-endian ELF")
phoff, = struct.unpack_from("<Q", b, 0x20)
phentsize, phnum = struct.unpack_from("<HH", b, 0x36)
al = []
for i in range(phnum):
o = phoff + i * phentsize
if o + 0x38 > len(b):
break
if struct.unpack_from("<I", b, o)[0] == 1:
al.append(struct.unpack_from("<Q", b, o + 0x30)[0])
if not al:
raise NotApplicable("ELF has no PT_LOAD segment")
return min(al)
def abi_of(name):
for a in ABIS:
if f"/{a}/" in name or f"android.{a}/" in name or name.startswith(a + "/"):
return a
return None
def measure_zip(z, is_apk=False, raw=None, errors=None, skipped=None):
out = []
for i in z.infolist():
if not i.filename.endswith(".so"):
continue
abi = abi_of(i.filename)
if not abi or i.filename.startswith("assets/") or "/assets/" in i.filename: # assets/ .so are not loaded by the system linker
abi = None
if skipped is not None:
skipped.append(i.filename)
continue
try:
a = load_align(z.read(i))
except NotApplicable as e:
if errors is not None:
errors.append(f"{i.filename}: {e}")
continue
off = None
if is_apk and i.compress_type == 0 and raw is not None:
nlen, elen = struct.unpack_from("<HH", raw, i.header_offset + 26)
off = i.header_offset + 30 + nlen + elen
out.append({"lib": i.filename, "abi": abi, "p_align": a, "zip_off_mod_16k": None if off is None else off % 16384})
return out
def measure(path, errors=None, skipped=None):
"""Returns result rows. Raises OSError / zipfile.BadZipFile / NotApplicable for bad inputs (single .so)."""
if path.endswith(".so"):
with open(path, "rb") as fh:
a = load_align(fh.read())
return [{"lib": path, "abi": "?", "p_align": a, "zip_off_mod_16k": None}]
raw = None
if path.endswith(".apk"):
with open(path, "rb") as fh:
raw = fh.read()
with zipfile.ZipFile(path) as z:
return measure_zip(z, path.endswith(".apk"), raw, errors, skipped)
def ok(r):
return r["p_align"] >= 16384 and r["zip_off_mod_16k"] in (None, 0)
def main(argv=None):
p = argparse.ArgumentParser(prog="check16k.py", description=__doc__.split("\n\n")[0],
epilog=__doc__.split("\n\n", 1)[1], formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("files", nargs="*", metavar="FILE", help=".so/.apk/.aab/.aar/.zip")
p.add_argument("--json", action="store_true", help="emit JSON instead of text")
args = p.parse_args(argv)
if not args.files:
p.print_usage(sys.stderr)
print("error: no input files (try: check16k.py app-release.aab)", file=sys.stderr)
return 2
res, errors, skipped = {}, [], []
for f in args.files:
try:
res[f] = measure(f, errors, skipped)
except zipfile.BadZipFile:
print(f"error: {f}: not a valid zip/APK/AAB/AAR (and not a .so)", file=sys.stderr)
return 2
except NotApplicable as e:
print(f"error: {f}: {e}", file=sys.stderr)
return 2
except OSError as e:
print(f"error: {f}: {e}", file=sys.stderr)
return 2
allr = [r for v in res.values() for r in v]
if args.json:
print(json.dumps({f: [dict(r, aligned=ok(r)) for r in v] for f, v in res.items()}, indent=1))
else:
for f, v in res.items():
for r in v:
print(f"{'ALIGNED ' if ok(r) else 'UNALIGNED'} p_align=2**{r['p_align'].bit_length()-1} {r['lib']}"
+ ("" if r["zip_off_mod_16k"] is None else f" zip_off%16k={r['zip_off_mod_16k']}"))
n = sum(not ok(r) for r in allr)
print(f"{len(allr)} libs checked (ABIs: {', '.join(sorted({r['abi'] for r in allr}))}), {n} unaligned"
+ (f"; {len(skipped)} .so ignored (other ABIs or under assets/)" if skipped else ""))
print("NOT CHECKED: RELRO segment alignment, AAB BundleConfig page alignment, runtime behavior. A green result is not a full 16 KB audit (see GUIDE.md).")
for e in errors:
print(f"error: {e}", file=sys.stderr)
if errors:
return 2
if not allr:
print("error: no arm64-v8a/x86_64 .so found in input"
+ (f" ({len(skipped)} .so in other ABIs or under assets/ ignored)" if skipped else "")
+ "; nothing to check (a pure-Java app is not affected)", file=sys.stderr)
return 3
return 1 if any(not ok(r) for r in allr) else 0
if __name__ == "__main__":
sys.exit(main())
The paid kit ($12, v2) contains the complete measured fix map for all 80+ libraries as JSON and a table, every raw per-version measurement, the spot-check evidence, and the step-by-step guide for Play's 16 KB requirement. This page and the checker script stay free.
Get the kit ($12)