rea-reverse

Check an APK for Android's 16 KB Page Size Requirement

Written for REA 6.4.0Updated 9 min read
On this page

Short answer: an APK is 16 KB compatible when every 64-bit native library (lib/arm64-v8a/*.so, lib/x86_64/*.so) has all of its LOAD segments aligned to at least 16 KB (p_align >= 0x4000), and every uncompressed .so inside the APK starts at a ZIP offset that is a multiple of 16,384. If your APK has no lib/ directory at all, it ships no native code, and the requirement does not touch it.

You can check both conditions in about ten seconds with this site’s APK & AAB analyzer, which reads the file in your browser and never uploads it. Then confirm the result with Google’s own tools: zipalign and an ELF reader. This guide does both on four APKs I built for the purpose, so every output below is real.

Who this is for: Android developers shipping their own app (Kotlin, Java, Flutter, React Native, Unity or plain NDK) who got a 16 KB warning in Play Console, or want to know before they do.

What you will learn:

  • What the requirement actually checks, and why a pure Kotlin app usually passes
  • How to read the analyzer’s PLAY-02, PLAY-03 and PLAY-04 results
  • How to cross-check with zipalign -c -P 16 and llvm-readelf
  • How to fix it with NDK r28, the r27 flags, AGP and apksigner
  • What REA, the open-source project by morluto, can and cannot tell you here (version 6.4.0)

Only check builds you own or are authorized to examine. Release candidates of your own app are the intended target.

What the 16 KB requirement means

Android has historically used 4 KB memory pages. Newer 64-bit devices can run kernels with 16 KB pages. When the dynamic linker loads a .so, it maps each LOAD segment at a page boundary, so a library linked for 4 KB pages (p_align = 0x1000) cannot be loaded correctly on a 16 KB kernel. The app then fails to start or crashes on first use of the library.

Google’s 16 KB page sizes guide sets out what Play requires: apps that target Android 15 or higher must support 16 KB pages on 64-bit devices, and the developer documentation states that from February 1, 2027, updates that do not comply cannot be published. Earlier Play announcements and forum threads mention other dates (November 2025 with extensions), which probably describe a warning phase. Check your app’s policy status in Play Console for the date that applies to you.

Two separate things must be right:

  1. ELF alignment. Set by the linker when the .so is built. Only the library’s author can change it.
  2. ZIP alignment. Set when the APK is packaged. Uncompressed native libraries are mapped straight out of the APK, so their data has to start on a 16 KB boundary inside the ZIP. Compressed libraries are extracted at install time, so their offset does not matter.

Step 0: Does your app even have native code?

List the native libraries first:

unzip -l app-release.apk | grep '\.so$'

No output means no native code and nothing to fix. Many “pure” Kotlin apps still have native code, though, because an SDK brings it in: image loading, crypto, analytics, a database or an ML runtime. Framework apps always have some. Typical file names, as covered in the APK unpacking guide:

  • Flutter: libflutter.so and libapp.so
  • React Native: libhermes.so or libhermes_executor.so and the React Native libraries
  • Unity: libunity.so, plus libil2cpp.so for IL2CPP builds

Only 64-bit ABIs (arm64-v8a, x86_64) count for the 16 KB rules.

The test APKs

To have known answers, I compiled one tiny JNI library (libhello.so, five lines of C) with two NDK versions and packaged it four ways with Android build-tools 36.0.0 (aapt2, zipalign, apksigner), using a throwaway release key:

APK Library built with Packaging
old.apk NDK r27 (27.1) zipalign -p 4, signed with --alignment-preserved true
unaligned.apk NDK r28 (28.2) zipalign 4 (no page alignment), --alignment-preserved true
new.apk NDK r28 (28.2) zipalign -P 16 4, default apksigner
only32.apk NDK r28, armeabi-v7a only zipalign -P 16 4

The manifest sets minSdkVersion 24, targetSdkVersion 36 and android:extractNativeLibs="false", as the Android Gradle Plugin does for modern builds, and the libraries are stored uncompressed.

Step 1: Check the APK in your browser

Open the APK & AAB analyzer, choose the Google Play scenario, and pick your .apk or .aab. The page reads the ZIP central directory and the first bytes of each .so inside a Web Worker. You can watch the Network tab to confirm nothing is sent.

The three rules that matter here, with the evidence the analyzer produced for my samples (asOf 2026-10-14):

old.apk, an r27 library with 4 KB ZIP alignment, fails both 16 KB rules:

PLAY-02  fail  high  lib/arm64-v8a/libhello.so: PT_LOAD p_align=0x1000 (< 0x4000)
PLAY-03  fail  high  lib/arm64-v8a/libhello.so: data offset 4096 (mod 16384 = 4096)
note: Effective from 2027-02-01 (reject); shown as high risk until then.

unaligned.apk, a correctly linked r28 library packaged without page alignment, passes PLAY-02 and fails PLAY-03:

PLAY-02  pass
PLAY-03  fail  high  lib/arm64-v8a/libhello.so: data offset 728 (mod 16384 = 728)

new.apk passes PLAY-02, PLAY-03 and PLAY-04.

only32.apk passes both 16 KB rules, because its only library is 32-bit (its p_align is still 0x1000), and fails a different rule:

PLAY-04  fail  reject  lib/armeabi-v7a/ present, lib/arm64-v8a/ missing

How to read the severity

The analyzer has a date selector. Before 2027-02-01, PLAY-02 and PLAY-03 are shown as high risk with the note above. Set the date to 2027-02-01 and the same findings become reject. I ran both dates on all four files and the evidence lines did not change, only the severity. PLAY-04 has no future date: Play already requires 64-bit libraries, so it is a reject today.

What the rules check, from the tool’s source (src/tools/apk/rules/rules.ts, src/tools/apk/elf.ts):

  • PLAY-02 parses the ELF program headers of every 64-bit .so and requires every PT_LOAD segment to have p_align >= 0x4000. If the headers cannot be read, the result is Unknown, not a pass.
  • PLAY-03 applies only to APKs (Play builds the APKs from an AAB itself). For each 64-bit .so stored uncompressed, it reads the local file header and requires the data offset to be a multiple of 16,384. If the manifest sets android:extractNativeLibs="true", the libraries are extracted at install time and the rule passes.
  • PLAY-04 flags a 32-bit ABI directory without its 64-bit counterpart, and any .so whose ELF class or e_machine does not match its directory.

For an AAB, PLAY-02 reads base/lib/<abi>/*.so. Manifest-based rules show Unknown for AABs in the current version, because AAB manifests are protobuf, not binary XML.

Step 2: Cross-check with Google’s tools

Both conditions can be confirmed with the Android SDK and NDK.

ZIP alignment with zipalign

zipalign -c -P 16 -v 4 app-release.apk

-c checks without writing, -P 16 asks for 16 KB page alignment of uncompressed .so files, and 4 is the normal alignment for everything else. Real output from build-tools 36.0.0 on my samples (other entries trimmed):

Verifying alignment of old.apk (4)...
    4096 lib/arm64-v8a/libhello.so (BAD - 4096)
Verification FAILED

Verifying alignment of unaligned.apk (4)...
     728 lib/arm64-v8a/libhello.so (BAD - 728)
Verification FAILED

Verifying alignment of new.apk (4)...
   16384 lib/arm64-v8a/libhello.so (OK)
Verification successful

The offsets match the analyzer’s evidence exactly.

ELF alignment with readelf or objdump

The NDK ships llvm-readelf and llvm-objdump under toolchains/llvm/prebuilt/<host>/bin/. Extract the library and read its program headers:

unzip -o app-release.apk 'lib/arm64-v8a/*' -d /tmp/apk-libs
llvm-readelf -lW /tmp/apk-libs/lib/arm64-v8a/libhello.so

The NDK r27 build:

  LOAD   0x000000 0x0000000000000000 ... 0x0005b0 0x0005b0 R   0x1000
  LOAD   0x0005b0 0x00000000000015b0 ... 0x0000d0 0x0000d0 R E 0x1000
  LOAD   0x000680 0x0000000000002680 ... 0x0001b8 0x000980 RW  0x1000

The NDK r28 build of the same source:

  LOAD   0x000000 0x0000000000000000 ... 0x0005b0 0x0005b0 R   0x4000
  LOAD   0x0005b0 0x00000000000045b0 ... 0x0000d0 0x0000d0 R E 0x4000
  LOAD   0x000680 0x0000000000008680 ... 0x0001b8 0x000980 RW  0x4000

The last column is p_align. llvm-objdump -p shows the same thing as align 2**12 for the r27 build and align 2**14 for the r28 build, and 2**14 is the value Google’s check_elf_alignment.sh script (linked from the page-sizes guide) looks for.

You will also see a GNU_RELRO line. For the r28 build it starts at 0x8680 with a size of 0x980, so it ends at 0x9000, which is not a multiple of 0x4000. The analyzer deliberately does not check RELRO end alignment: the AOSP script does not check it either, and correctly built r28 libraries like this one would fail it.

Step 3: Fix it

Rebuild your own native code

  • NDK r28 or newer produces 16 KB aligned libraries by default. My r28 build above needed no flags.
  • NDK r27 needs an opt-in. With CMake, pass -DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON; with ndk-build, set APP_SUPPORT_FLEXIBLE_PAGE_SIZES := true in Application.mk. In r27’s own build/cmake/flags.cmake and build/core/build-binary.mk, that setting adds -Wl,-z,max-page-size=16384 for arm64-v8a and x86_64 only.
  • Any other toolchain: link with -Wl,-z,max-page-size=16384. Rebuilding the r27 sample with that one flag gave 0x4000 on every LOAD segment.
  • Do not set these to false on r28. In r28 the same variables work the other way: setting them to false adds -Wl,-z,max-page-size=4096 and gives you 4 KB libraries again.

The flexible-page-size option also defines __BIONIC_NO_PAGE_SIZE_MACRO, which removes the PAGE_SIZE macro. If your C or C++ code hardcodes 4096 or uses PAGE_SIZE, change it to getpagesize() or sysconf(_SC_PAGESIZE). Alignment flags do not fix code that assumes 4 KB pages at runtime.

Fix the packaging

  • Use Android Gradle Plugin 8.5.1 or newer, which aligns uncompressed native libraries to 16 KB.

  • If you cannot upgrade, compress the libraries instead. The installer then extracts them, at the cost of a larger install:

    android {
        packaging {
            jniLibs { useLegacyPackaging = true }
        }
    }
  • Watch your signing step. apksigner sign --help in build-tools 36.0.0 lists --lib-page-alignment with a default of 16384, and --alignment-preserved defaults to false. In my test, signing an APK whose library sat at offset 4096 moved it to 16384 unless I passed --alignment-preserved true. Older build-tools or custom signing pipelines may not do this, so run zipalign -c -P 16 -v 4 on the final signed artifact, not the intermediate one.

Third-party libraries (Flutter, React Native, Unity, SDKs)

You cannot relink a prebuilt .so. The fix is a newer version from its vendor:

  • Find which library failed from the analyzer’s evidence (lib/arm64-v8a/libxyz.so).
  • Map the file to the framework or SDK it belongs to (see Step 0), then upgrade to a release whose notes mention 16 KB page size support. Flutter, React Native and Unity have all published such releases. Check their release notes for the version that fits your project rather than relying on a version number from a blog post.
  • Rebuild, and run the check again. Upgrading one SDK sometimes leaves another one behind.

An unmaintained SDK with no 16 KB build has to be replaced. Patching alignment fields in someone else’s binary is not a fix: the linker has to lay out the segments.

To test on a real 16 KB environment, the page-sizes guide describes emulator system images with 16 KB pages. On a device or emulator, adb shell getconf PAGE_SIZE prints the page size in bytes.

Where REA fits, and where it stops

REA 6.4.0 has no 16 KB page size check. I searched the published 6.4.0 package (dist/) for page-size and alignment logic, and the Android tools do not read ELF p_align or ZIP data offsets. Use the analyzer and zipalign for the alignment question. REA is useful around it:

  • rea inspect-artifact app-release.apk --json inventories every ZIP entry. .so paths are classified as dynamic-library (classifyArtifactPath in dist/artifacts/inventory/ArtifactGraphConstruction.js), with declared_size and compressed_size. Equal sizes suggest an entry is stored uncompressed. For ZIP entries the inventory does not record a data offset (byteOffset: null in dist/artifacts/ZipArtifactReader.js), so it cannot replace zipalign.
  • rea inspect-android-package app-release.apk --json returns package_name, min_sdk, target_sdk, permissions and the decoded manifest (schema in dist/domain/android/androidAnalysis.js). The 16 KB rule applies from targetSdk 35 up, so this is how an agent confirms whether the app is in scope. It requires a caller-supplied JADX MCP JAR (REA_JADX_MCP_JAR); see the JADX MCP setup guide. Its result also says signature_verification: "not_performed".
  • To look inside a failing library, for example to find out which SDK built it, open it in Ghidra through REA as described in native binary analysis with REA and Ghidra.

FAQ

My app is pure Kotlin or Java. Do I need to do anything? Only if a dependency ships native code. Run Step 0. No .so files means nothing to fix.

Do 32-bit libraries need 16 KB alignment? No. The requirement concerns 64-bit devices and processes, and PLAY-02 only checks arm64-v8a and x86_64. You still need 64-bit versions of every 32-bit library (PLAY-04).

The analyzer shows “Unknown” for PLAY-02. The program headers of at least one library could not be read, for example because the file is damaged or its header table is unusually large. Unknown is not a pass. Check that library with llvm-readelf -lW.

Can I check an AAB? Yes, for ELF alignment (PLAY-02) and 64-bit coverage (PLAY-04). ZIP alignment (PLAY-03) applies to APKs, which Play generates from your bundle. To check your own bundle locally, build a universal APK with bundletool, as shown in the AAB to APK guide, and check that.

Next steps