Scan your AAB before you upload it: nine offline checks that catch Play rejections
The worst class of Android release bug is the one that builds fine, uploads fine, passes review, and then fails on a user’s device.
An activity with an <intent-filter> and no android:exported installs
nowhere on Android 12+. A foreground service missing its Android 14 permission
throws SecurityException the first time it starts. A native library that
isn’t 16 KB page aligned won’t load on an Android 15 device that uses 16 KB
pages. None of these break your CI. All of them are sitting in the .aab you
just built.
gplay preflight reads that artifact and tells you. Offline, in about a
second, with no credentials and no API calls.
gplay preflight --file app-release.aabWhy “check the bundle” is usually a lie
Section titled “Why “check the bundle” is usually a lie”Plenty of tooling claims to inspect an Android build. Most of it greps the compressed archive for strings.
That approach cannot distinguish android:debuggable="true" from
android:debuggable="false" — both contain the substring. It can’t see an
attribute that points at a resource. It reports android.permission.CAMERA in
a build that merely mentions the string in a comment inside a bundled
JavaScript file.
preflight decodes AndroidManifest.xml for real: binary AXML for APKs
and the aapt2 protobuf encoding for App Bundles, normalized into one typed
model. Attributes come back with their actual types — booleans as booleans,
integers as integers, resource references flagged as references.
That last case matters. If your manifest says
android:debuggable="@bool/isDebug", no static tool can tell you the answer.
preflight says exactly that instead of guessing, and you go check the build
variant yourself.
What the nine scanners find
Section titled “What the nine scanners find”manifest — the install-blockers
Section titled “manifest — the install-blockers”The ones that cost you a release cycle:
- Exported components without
android:exported. Required ontargetSdk31+. Missing it is an install failure, not a lint warning. - Foreground service types without their permission. Android 14 wants
FOREGROUND_SERVICE_DATA_SYNCfor adataSyncservice. Without it, the service crashes when it starts. - Exported providers granting URI permissions. Any installed app gets a path into your data.
debuggable,testOnly— Play rejects the upload outright fortestOnly.
permissions — the ones that need paperwork
Section titled “permissions — the ones that need paperwork”23 restricted permissions that require a Play declaration form (SMS and Call
Log groups, MANAGE_EXTERNAL_STORAGE, ACCESS_BACKGROUND_LOCATION,
QUERY_ALL_PACKAGES, SYSTEM_ALERT_WINDOW…), plus 18 sensitive ones that need
a Data safety disclosure. Each finding links to the relevant Play policy page,
so you’re not searching for which form.
It also catches the quiet ones: WRITE_EXTERNAL_STORAGE without a
maxSdkVersion on targetSdk 30+, READ_EXTERNAL_STORAGE on 33+, duplicates,
and permissions that no longer do anything.
native_libs — 16 KB pages, for real
Section titled “native_libs — 16 KB pages, for real”This one doesn’t guess. It opens every .so in the bundle with a real ELF
parser and reads the p_align field of each PT_LOAD program header.
Android 15 supports 16 KB memory pages, and Play requires alignment for it.
A misaligned library fails to load — and there’s no way to know from the
Gradle output. preflight reports it as an error on targetSdk 35+ and a
warning below.
Also: missing arm64-v8a, unstripped .debug_* sections eating your download
size, and extractNativeLibs="true".
metadata — actual pixels
Section titled “metadata — actual pixels”Point it at a Fastlane-style metadata directory:
gplay preflight --file app-release.aab --listings-dir ./fastlane/metadata/androidIt counts title, short description, full description and release notes in runes, not bytes — the way Play counts them, so an emoji costs one character, not four. Then it decodes each image and checks the real dimensions: icon 512×512, feature graphic 1024×500, screenshots at least 320px per side, no more than 2:1 aspect ratio, at least two phone screenshots.
Checking a screenshot by filename tells you nothing. Checking its header tells you everything.
secrets — including inside the dex
Section titled “secrets — including inside the dex”Private keys, AWS keys, Stripe sk_live_, GitHub and Slack tokens, SendGrid
keys, OpenAI and Anthropic keys, service-account JSON. Keystores and .pem
files that made it into the packaged assets. A .git/ directory or .env file
that leaked in from a source set.
Dex bytecode is scanned too, since that’s where hardcoded string literals actually live.
One deliberate choice: a Google API key (AIza…) is a warning, not an
error. Android apps embed Maps and Firebase keys by design; you cannot keep
them out of the binary, and flagging them as errors would make
--fail-on error useless on every real app. The fix is restricting the key to
your package name and signing certificate in Cloud Console — which is what the
hint says.
billing, privacy, policy, size
Section titled “billing, privacy, policy, size”billing flags third-party payment processors and a BILLING permission with
no implementation behind it. privacy inventories 40+ analytics, attribution
and ads SDKs and reconciles the AD_ID permission against targetSdk 33+.
policy checks the target API floor and restricted services (accessibility,
VPN, device admin, notification listener). size covers the download budget,
dex fragmentation, and where your bytes actually went.
Using it as a CI gate
Section titled “Using it as a CI gate”gplay preflight --file app-release.aab --fail-on errorExit 0 if nothing at or above that severity, 1 otherwise. Because it needs
no credentials, it runs before any secret is available — including in fork
pull requests, where your Play service account isn’t and shouldn’t be exposed.
- name: Offline preflight run: | gplay preflight \ --file app/build/outputs/bundle/release/app-release.aab \ --listings-dir fastlane/metadata/android \ --fail-on errorNarrow the gate if you only want to block on a subset:
gplay preflight --file app.aab --only manifest,permissions,native_libsgplay preflight --file app.aab --skip sizegplay preflight --list-scannersPiping it somewhere
Section titled “Piping it somewhere”# Just the blockersgplay preflight --file app.aab --output json \ | jq '[.findings[] | select(.severity=="error")]'Every finding carries a check, severity, message, entry (which file in
the bundle), a hint describing the fix, and a ref link to the Play policy
page where one applies. The report also names which scanners ran, which were
skipped, and why — so a skipped metadata scanner never looks like a clean
listing.
Where it sits in the pipeline
Section titled “Where it sits in the pipeline”| Command | Needs auth | Scope |
|---|---|---|
gplay preflight |
No | The artifact and listing files, offline |
gplay validate |
Yes | Release readiness: local checks plus live track and listing state |
gplay checks analyze |
Yes | Google Checks privacy and policy analysis, server-side |
gplay release --dry-run |
Yes | The full pipeline against a real edit, discarded before commit |
Run them in that order. The first one is free, needs nothing, and catches the most.
Honest limitations
Section titled “Honest limitations”R8 can rename or remove classes, so a missing SDK detection is not proof the
SDK is absent. Matches are high confidence; misses are not, and preflight
doesn’t pretend otherwise.
The manifest decoder relies on attribute names, which aapt2 emits for normal builds. A deliberately obfuscated APK that strips names and keeps only resource IDs falls back to heuristics — and the report says so rather than reporting a clean bill of health.
The target API floor is a constant that Google raises roughly every August.
--min-target-sdk overrides it without waiting for a gplay release.
Try it
Section titled “Try it”brew install tamtom/tap/gplaygplay preflight --file app-release.aabFull guide: Offline preflight. Command reference:
gplay preflight.
If you drive releases through an AI agent, the gplay-preflight skill teaches
it to run this before every upload and to report the hints rather than just the
messages:
npx skills add tamtom/gplay-cli-skills