Build a release app bundle
Google Play only accepts Android App Bundles (.aab) for new apps. Flutter builds one with a single command, and release mode is the default. Do two checks first; both are hard to fix once testers have the app.
Set the application ID
In
android/app/build.gradle.kts, giveapplicationIda unique reverse-domain value such ascom.yourcompany.app. It can't be changed after the first upload to Google Play.Check the INTERNET permission
If the app talks to the network,
android/app/src/main/AndroidManifest.xmlmust declareandroid.permission.INTERNET. The debug and profile manifests include it; the release one only has it if you add it.Build the bundle
Run the command below from the project root. Flutter writes the bundle to
build/app/outputs/bundle/release/.
flutter build appbundleUsing flavors? Build the one you want to publish, for example flutter build appbundle --flavor production, and check that it uses the production package name and API keys.
Sign it: upload key and Play App Signing
Play App Signing is required for new apps: Google keeps the app signing key, and you sign every upload with your own upload key. If the upload key is ever lost, you can ask Google to reset it; your app isn't lost with it. Create the key with keytool:
keytool -genkey -v -keystore ~/upload-keystore.jks -keyalg RSA \
-storetype JKS -keysize 2048 -validity 10000 -alias uploadThen create android/key.properties with the passwords and the path to the keystore. Keep it, and the .jks file, out of version control:
storePassword=<keystore password>
keyPassword=<key password>
keyAlias=upload
storeFile=/Users/<you>/upload-keystore.jksLoad the file in android/app/build.gradle.kts and point the release build type at the new signing config:
import java.util.Properties
import java.io.FileInputStream
val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("key.properties")
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}
android {
// ...
signingConfigs {
create("release") {
keyAlias = keystoreProperties.getProperty("keyAlias")
keyPassword = keystoreProperties.getProperty("keyPassword")
storeFile = keystoreProperties.getProperty("storeFile")?.let { file(it) }
storePassword = keystoreProperties.getProperty("storePassword")
}
}
buildTypes {
release {
signingConfig = signingConfigs.getByName("release")
}
}
}Older projects use build.gradle (Groovy) instead of build.gradle.kts; the fields are the same. Run flutter clean after changing Gradle files.
Version code and target API level
Every upload needs a higher version code than any bundle you have uploaded before, on any track. In Flutter it's the number after the + in pubspec.yaml:
version: 1.0.0+11.0.0 becomes the version name users see and 1 is the version code. Raise it to +2, +3 and so on, or override both at build time with --build-name and --build-number.
Since 31 August 2026, new apps and updates must target Android 16 (API level 36) or higher. The Gradle file uses targetSdk = flutter.targetSdkVersion, so upgrading Flutter is the usual way to raise it. If your Flutter version is older, set targetSdk = 36 yourself and test on a recent Android version before uploading.
Flutter pitfalls that delay closed testing
- Reused build number. Play Console rejects a bundle whose version code was already used. Bump the
+Ninpubspec.yamlbefore every build you plan to upload. - Obfuscation without symbols.
--obfuscateonly works together with--split-debug-info. Keep the symbols directory of every uploaded build; without it, crash stack traces from testers can't be read. - Wrong flavor. With flavors it's easy to upload the staging build with staging endpoints. Testers then report bugs that production users would never see.
- No network in release. A missing INTERNET permission in the main manifest makes the app work in debug and fail quietly for testers.
- Debug-signed bundle. If the
releasebuild type still uses the debug signing config, Play Console refuses the upload. - Plugins with old native code. Plugins that ship
.solibraries must support 16 KB memory pages. Play Console warns about it, and from 1 February 2027 updates without support can't be released. Update plugins and the Flutter SDK before the test, not in the middle of it.
flutter build appbundle --obfuscate --split-debug-info=build/symbolsRead crash reports from your testers
Testers paste stack traces into bug reports, and Play Console collects crashes under Monitor and improve → Android vitals → Crashes & ANRs. A Flutter trace has two halves: the Dart part, which --obfuscate turns into short meaningless names, and the Android part, which R8 shrinks. Flutter always enables code shrinking in release builds, so plan for both.
Keep the Dart symbols
A build made with
--split-debug-infowrites one symbols file per architecture, such asapp.android-arm64.symbols. Store the folder next to the version code it belongs to: a file from another build can't decode the trace.Decode Dart traces
Save the trace to a file and pass it to
flutter symbolizetogether with the symbols file for the device's architecture. Most current phones are arm64.Let Play Console handle the Java side
With Android Gradle plugin 4.1 or later, the R8 mapping file travels inside the
.aab, so Play Console deobfuscates Java and Kotlin frames from plugins without a separate upload.
flutter symbolize -i crash.txt -d build/symbols/app.android-arm64.symbolsObfuscation renames symbols; it doesn't encrypt anything and isn't a place for secrets. Code that compares runtimeType.toString() with a class name breaks under obfuscation, while enum names stay readable.
Play Console in short: from closed test to production
Once your Flutter bundle is built and signed, the rest happens in Play Console and works the same for every framework. This is the checklist; the full Play Console walkthrough covers every menu and screen.
- Finish app setup on the Dashboard: privacy policy, app access, ads, content rating, target audience, data safety and store listing.
- Create a closed testing release under Test and release → Testing → Closed testing: choose your testers' countries, upload the
.aaband send it for review. - Add testers with a Google Group on the Testers tab and share the opt-in link. It works once the track shows Published.
- Keep at least 12 testers opted in for 14 days in a row. Internal testing doesn't count, and someone who leaves and rejoins starts their 14 days again.
- Ship at least one update during the test and write down what testers found and what you changed.
- Apply for production from the Dashboard and answer the three sections about your test, your app and your readiness. The decision is Google's; nobody can guarantee approval.
Plan for drop-outs: recruit more than 12 testers so one person leaving doesn't push back your timeline. See the full Play Console steps.
The full Play Console walkthrough
Closed track, Google Group and opt-in link, the 12 testers / 14 days rule and the production application, step by step on one page.
Ship fixes to testers during the test
In Flutter: raise the build number, rebuild and upload the new bundle to the same closed track.
flutter build appbundle --build-name=1.0.1 --build-number=2Before every upload, run the release build on a real phone. flutter install puts the release APK on a connected device, which catches missing permissions, wrong flavors and release-only crashes in minutes:
flutter build apk
flutter installTo check the exact bundle you are about to upload, turn it into device APKs with Google's bundletool, or upload it to the closed track and install it from Google Play with a tester account. That way you test what testers get, including Play's per-device split APKs.
- One focused change per update. Small releases make it clear which fix answered which tester report, and they give you concrete examples for the production access form.
- Don't switch Flutter versions mid-test unless a tester-reported bug requires it. An SDK upgrade changes the engine, every plugin's native code and possibly
targetSdkat the same time. - Leave time for review. Each new release on the closed track is sent to Google before testers get it, so don't plan your last fix for the final day.
Frequently asked questions
Do I need --obfuscate for a closed test?
No. Obfuscation is optional and only makes Dart symbols harder to read. If you use it, always pair it with --split-debug-info and archive the symbols, or the crashes testers send become unreadable.
Which version code does Google Play see?
The number after the + in pubspec.yaml, or the value of --build-number when you pass one. Flutter uses the build name as versionName and the build number as versionCode.
Can I give testers an APK instead?
Not through Google Play: new apps must be uploaded as an .aab, and only installs from the closed track count toward the 12 testers. flutter build apk --split-per-abi is still handy for quick checks on your own phones.
Why does Play Console warn about 16 KB memory pages?
Usually a plugin ships native .so libraries that aren't aligned for 16 KB pages. Update the plugin and Flutter, rebuild, and verify on an Android emulator with a 16 KB page size system image before the next upload.
Sources
Technical details were checked against the official documentation below. Menus and defaults change between versions; when in doubt, the official page wins.
- Flutter: Build and release an Android app
- Flutter: Obfuscate Dart code
- Android Developers: Sign your app
- Android Developers: Target API level requirements
- Android Developers: Support 16 KB page sizes
- Android Developers: bundletool
- Play Console Help: App testing requirements for new personal developer accounts
- Play Console Help: Set up an open, closed, or internal test
- Play Console Help: Deobfuscate or symbolicate crash stack traces
New to the 12 testers rule?
The general guide explains Google's requirement, how a TestBunny campaign runs day by day and what we can and can't guarantee.
Read the general guide