gplay preflight — offline build scanning
gplay preflight inspects a local .aab or .apk and reports findings. It
makes no API calls and needs no credentials, so it works on any machine, in
any CI job, before the artifact has ever touched Play.
Reach for it before gplay validate (which needs auth and hits the API) and
before any upload. It catches the class of problem that Play rejects hours
later, or that fails at install time on a user's device.
Preconditions
- A built
.aabor.apkon disk. - Nothing else. No
gplay auth login, no--package, no network.
Basic usage
# Scan a bundle
gplay preflight --file app-release.aab
# Also validate the store listing in the same pass
gplay preflight --file app-release.aab --listings-dir ./fastlane/metadata/android
# Machine-readable
gplay preflight --file app-release.aab --output json --pretty
# See what scanners exist
gplay preflight --list-scanners
How preflight reads the build
preflight fully decodes AndroidManifest.xml — binary AXML for APKs, aapt2
protobuf for App Bundles — so findings reflect real, typed attribute values
rather than substring guesses.
This matters when you interpret results: an attribute pointing at a resource
reference (for example android:debuggable="@bool/isDebug") is reported as
not statically determinable, not as false. Do not read a missing finding as
proof of absence in that case.
Aggressive code shrinking can rename or remove classes, so SDK detection misses are not proof an SDK is absent. Matches, however, are high confidence.
The nine scanners
Every scanner has an ID. Select with --only or exclude with --skip
(comma-separated).
manifest
Manifest-level correctness and release-blocking flags.
android:debuggable="true"— error. Ships a debuggable build.android:testOnly="true"— error. Play rejects the upload outright.- Component with an
<intent-filter>and noandroid:exportedontargetSdk31+ — error. This is an install failure on Android 12+, not a warning. Launcher activities are exempt. - Exported
<provider>withgrantUriPermissions— error. Any app can reach the granted URIs. - Foreground service type without its matching permission on
targetSdk34+ — Android 14 throwsSecurityExceptionat runtime. AdataSynctype needsFOREGROUND_SERVICE_DATA_SYNC. usesCleartextTraffic="true"— warning, downgraded to info when anetworkSecurityConfigis present.allowBackup,requestLegacyExternalStorage, package/version sanity.
permissions
- 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,BIND_ACCESSIBILITY_SERVICE,USE_FULL_SCREEN_INTENT,SCHEDULE_EXACT_ALARM, and others. Each finding carries the Play policy documentation link. - Sensitive permissions that need a Data safety disclosure (info level).
- Legacy storage:
WRITE_EXTERNAL_STORAGEwithoutmaxSdkVersionontargetSdk30+,READ_EXTERNAL_STORAGEon 33+. - Duplicates and deprecated permissions.
native_libs
- Missing
arm64-v8a— error. Play has required 64-bit since 2019. - 16 KB memory page alignment, read from real ELF program headers. Error on
targetSdk35+, warning below. Android 15 devices with 16 KB pages will not load misaligned.sofiles. x86withoutx86_64, barearmeabi.- Unstripped
.debug_*/.symtabsections — wasted download size. extractNativeLibs="true"— larger install footprint.
metadata
Requires --listings-dir. Skipped otherwise. Expects a Fastlane-style
layout (<dir>/<locale>/title.txt, <dir>/<locale>/images/...).
- Title ≤ 30, short description ≤ 80, full description ≤ 4000, release notes ≤ 500 characters — counted in runes, not bytes.
- Real pixel dimensions: icon 512×512, feature graphic 1024×500, promo graphic 180×120, TV banner 1280×720.
- Screenshots: min 320px per side, max 3840px, max 2:1 aspect ratio, at least 2 phone screenshots, at most 8 per form factor, max 8 MB each.
secrets
Errors: private key blocks, AWS access keys, Stripe sk_live_, GitHub tokens,
Slack tokens and webhooks, SendGrid keys, Google OAuth client secrets,
OpenAI/Anthropic keys, service-account JSON, shipped keystores (.jks,
.keystore, .p12, .pfx, .pem, .ppk), and .git/ or .env leakage.
Warnings: Google API keys (AIza…) and JWTs.
Google API keys are a warning, not an error, on purpose. Android apps embed Maps and Firebase keys by design. The fix is restricting the key to your package name and signing certificate in Cloud Console — not removing it from the binary. Do not tell the user to delete it.
Dex bytecode is scanned too, so hardcoded string literals are caught.
billing
Third-party payment processors (Stripe, Braintree, PayPal, Adyen, Razorpay…)
present in the build — a warning on its own, downgraded to info when Play
Billing is also present. Also flags com.android.vending.BILLING declared with
no billing implementation, and billing wrappers (RevenueCat, Adapty,
Qonversion) shipped without the Play Billing Library.
privacy
Inventory of 40+ analytics, attribution, and ads SDKs (info). Reconciles the
AD_ID permission: warns when an ads/tracking SDK is present on targetSdk
33+ but the permission is missing, and notes the reverse case.
policy
targetSdkVersionbelow Play's floor. Override with--min-target-sdkwhen Google's annual bump lands before a new gplay release.- Restricted services: accessibility, VPN, device admin, notification listener.
- APK-vs-AAB upload format.
size
Download size budget (--max-size), per-dex budget (--max-dex), dex
fragmentation, and a payload breakdown by bucket with the largest entries.
CI gating
# Block only on hard blockers
gplay preflight --file app-release.aab --fail-on error
# Stricter: warnings fail too
gplay preflight --file app-release.aab --fail-on warning
# Narrow the gate to the scanners you care about
gplay preflight --file app-release.aab \
--only manifest,permissions,native_libs,secrets \
--fail-on warning
# Faster: skip the secrets scan on very large builds
gplay preflight --file app-release.aab --skip-secrets
Exit codes:
| Code | Meaning |
|---|---|
| 0 | No findings at or above --fail-on |
| 1 | Findings at or above --fail-on |
GitHub Actions:
- name: Offline preflight
run: |
gplay preflight \
--file app/build/outputs/bundle/release/app-release.aab \
--listings-dir fastlane/metadata/android \
--fail-on error
JSON output
--output json emits the full report: format, package, version code and name,
min/target SDK, per-scanner run status, and every finding with its check,
severity, message, entry, hint, and (where one applies) a ref policy
link.
# Just the errors
gplay preflight --file app.aab --output json | jq '[.findings[] | select(.severity=="error")]'
# Which scanners were skipped and why
gplay preflight --file app.aab --output json | jq '.scanners[] | select(.skipped)'
Where preflight fits
| Tool | Needs auth | Scope |
|---|---|---|
gplay preflight |
No | The artifact itself, offline. Manifest, libs, secrets, listing files |
gplay validate |
Yes | Release readiness: local checks plus live track and listing state |
gplay checks analyze |
Yes | Google Checks privacy/policy analysis, server-side and async |
gplay release --dry-run |
Yes | Full pipeline against a real edit, discarded before commit |
Run them in that order. preflight is the cheapest and catches the most.
Agent behavior
- Run
gplay preflight --file <artifact>before any upload or release command, and report findings before proceeding. - Pass
--listings-dirwhenever a metadata directory exists — otherwise themetadatascanner is skipped and listing problems go unreported. - Treat
errorfindings as blocking. Do not upload past them without the user explicitly saying to. - Report the
hintandreffields, not just the message. The hint says what to actually change. - Never advise deleting a Google API key found by the secrets scanner. Advise restricting it.
- A missing finding is not proof of compliance when the manifest could not be decoded or the build is heavily obfuscated. Say so rather than claiming the build is clean.
- Confirm flags with
gplay preflight --helpbefore constructing a command. --onlyand--skiptake comma-separated scanner IDs; get the current list fromgplay preflight --list-scanners.
Notes
preflightreads the archive once and streams large entries in bounded chunks, so memory stays flat on big bundles.- Scanner IDs are stable; finding
checknames are preserved across versions for JSON consumers. - The
policyscanner's target API floor is a constant that Google raises each August. If it looks stale, pass--min-target-sdk.