Android 16 KB fix map

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 --json

It 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 codeMeaning
0all 64-bit libs aligned
1at least one unaligned lib
2usage, input or corrupt-lib error
3readable, 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())
Need the full map plus the fix guide?

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)