Last year I uploaded an AAB to the Play Console, waited four days for review, and got rejected.
The reason: targetSdkVersion was one level below the new minimum. Google had bumped the requirement that month. I hadn't noticed.
I fixed it, re-uploaded, waited another three days. That's a week lost for a one-line fix in my build config.
The frustrating part: I could have caught that before uploading. The information was right there in the manifest. I just didn't check.
The pattern
Most Play Store rejections follow the same pattern:
- Upload your AAB
- Wait 1-7 days for review
- Get a short rejection email
- Fix something you could have checked locally
- Re-upload, wait again
And the rejection reasons are almost always predictable. targetSdk below the minimum. A restricted permission you didn't declare properly. A third-party billing SDK that violates Play's billing
policy. A hardcoded API key in the bundle.
None of these require Google's review to catch. They're all checkable from the file itself.
gpc preflight
$ gpc preflight app.aab
9 scanners run against your AAB file. Entirely offline. No API calls, no credentials, no network.
Here's what each scanner checks:
- Manifest validation
The big one. Parses your AndroidManifest.xml from the bundle and checks:
- targetSdkVersion below Google's current minimum (critical)
- debuggable flag left on in a release build
- testOnly flag set
- Missing android:exported on components (required since API 31)
- Missing foregroundServiceType (required since API 34)
- Cleartext HTTP traffic enabled
✗ CRITICAL targetSdkVersion 33 is below minimum 35
Google Play requires targetSdkVersion >= 35 for new app updates.
→ Update targetSdkVersion in your build.gradle to 35 or higher.
This single check would have saved me that week.
- Permissions audit
Flags 16+ restricted Google Play permissions:
- SMS and call log access (heavily restricted since 2019)
- QUERY_ALL_PACKAGES (requires declaration)
- Background location
- Accessibility service
- VPN service
- INSTALL_PACKAGES
- REQUEST_DELETE_PACKAGES
Each finding includes a note about what data safety declarations you'll need.
- Native library architecture
Checks your native libraries for 64-bit compliance:
- arm64-v8a is required
- x86_64 recommended
- Warns if native libs total exceeds 150MB uncompressed
- Metadata validation
If you pass --metadata
, it checks your Fastlane-format store listings:- Title under 30 characters
- Short description under 80 characters
- Full description under 4,000 characters
- At least 2 phone screenshots
- Privacy policy URL present
- Secrets detection
Scans your source directory (with --source
) for hardcoded credentials:- AWS access keys (AKIA...)
- Google API keys (AIza...)
- Stripe live keys (sk_live_...)
- RSA/EC/DSA private keys
- Firebase config keys
- Generic bearer tokens
You'd be surprised how often these end up in a bundle.
- Billing compliance
Detects non-Play billing SDKs that violate Google's billing policy:
- Stripe SDK
- Braintree
- PayPal
- Razorpay
- Adyen
- Square
These are flagged as warnings. Some apps have exemptions, but most don't.
- Privacy and tracking
Identifies tracking SDKs and cross-references with your permissions:
- Facebook SDK, Adjust, AppsFlyer, Amplitude, Mixpanel, Branch, CleverTap
- Advertising ID usage
- Data collection indicators vs declared permissions
- Policy heuristics
Pattern-matches for common policy traps:
- COPPA/Families indicators (child-targeted features + data collection)
- Financial app indicators (SMS access + autofill)
- Health app indicators (body sensor permissions)
- User-generated content indicators
- System alert window (overlay) permission
- Size analysis
Breaks down your bundle size by category:
ℹ INFO Total size: 64.8 MB compressed, 212.6 MB uncompressed
909 files. Breakdown: native-libs: 31.1 MB, dex: 7.2 MB,
assets: 1.1 MB, resources: 0.8 MB, signing: 0.1 MB
Warns if total download size exceeds the configurable limit (default: 150 MB). Flags individual native libs over 50 MB and assets over 30 MB.
Severity levels
Every finding has a severity:
┌──────────┬───────────────┬───────────────────────────────────────┐
│ Level │ Icon │ Meaning │
├──────────┼───────────────┼───────────────────────────────────────┤
│ critical │ ✗ (red, bold) │ Will almost certainly cause rejection │
├──────────┼───────────────┼───────────────────────────────────────┤
│ error │ ✗ (red) │ Likely to cause rejection │
├──────────┼───────────────┼───────────────────────────────────────┤
│ warning │ ⚠ (yellow) │ Worth reviewing, may cause issues │
├──────────┼───────────────┼───────────────────────────────────────┤
│ info │ ℹ (gray) │ Informational, no action needed │
└──────────┴───────────────┴───────────────────────────────────────┘
By default, the command exits code 6 if any finding is error or higher. Override with --fail-on:
gpc preflight app.aab --fail-on critical # only fail on critical
gpc preflight app.aab --fail-on warning # strict mode
In CI
Add one step before your upload:
name: Pre-submission check
run: gpc preflight app.aabname: Upload to Play Store
run: gpc releases upload app.aab --track internal
If preflight fails (exit code 6), the upload never happens. You find out in your CI logs instead of in a rejection email three days later.
Configuration
For teams, drop a .preflightrc.json in your project root:
{
"failOn": "error",
"targetSdkMinimum": 35,
"maxDownloadSizeMb": 150,
"allowedPermissions": ["android.permission.CAMERA"],
"disabledRules": ["billing-third-party"],
"severityOverrides": {
"size-total-warning": "info"
}
}
Whitelist permissions your app legitimately needs. Disable rules that don't apply. Override severities for your specific situation.
What it doesn't catch
To be clear about the limits:
- It can't check your app's runtime behavior
- It can't verify your data safety form matches reality
- It can't predict policy changes Google hasn't announced
- It can't check things that require running the app (ANR patterns, performance)
It checks what's checkable from the file and your source. The static stuff. The things that are embarrassing to get rejected for because they were right there the whole time.
Run specific scanners
Don't need all 9? Run individual scanners:
gpc preflight manifest app.aab # manifest only
gpc preflight permissions app.aab # permissions only
gpc preflight metadata ./fastlane # metadata only
gpc preflight codescan ./src # secrets + billing + privacy
Quick context on GPC
If you're new here: GPC is a CLI that covers the entire Google Play Developer API. 208 endpoints. 7 TypeScript packages. 1,863 tests.
Previous articles in this series:
- Part 1: I built a CLI that covers the entire Google Play Developer API
- Part 2: I replaced 4 Play Console tabs with one terminal command
Try it
# npm
npm install -g @gpc-cli/cli
# Homebrew
brew install yasserstudio/tap/gpc
# Standalone binary (no Node.js required)
curl -fsSL https://raw.githubusercontent.com/yasserstudio/gpc/main/scripts/install.sh | sh
Then:
gpc preflight your-app.aab
gpc preflight your-app.aab --source ./src --metadata ./fastlane
Docs | GitHub | Preflight docs
Free to use. Code is on GitHub.
What's your pre-submission process look like? Manual checklist, automated, or just upload and hope for the best?
Top comments (0)