Skip to content

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.

Terminal window
gplay preflight --file app-release.aab

Why “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.

The ones that cost you a release cycle:

  • Exported components without android:exported. Required on targetSdk 31+. Missing it is an install failure, not a lint warning.
  • Foreground service types without their permission. Android 14 wants FOREGROUND_SERVICE_DATA_SYNC for a dataSync service. 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 for testOnly.

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.

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".

Point it at a Fastlane-style metadata directory:

Terminal window
gplay preflight --file app-release.aab --listings-dir ./fastlane/metadata/android

It 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.

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

Terminal window
gplay preflight --file app-release.aab --fail-on error

Exit 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 error

Narrow the gate if you only want to block on a subset:

Terminal window
gplay preflight --file app.aab --only manifest,permissions,native_libs
gplay preflight --file app.aab --skip size
gplay preflight --list-scanners
Terminal window
# Just the blockers
gplay 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.

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.

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.

Terminal window
brew install tamtom/tap/gplay
gplay preflight --file app-release.aab

Full 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:

Terminal window
npx skills add tamtom/gplay-cli-skills