Skip to content

Setup guides · Flutter

Google Play closed testing for Flutter apps

From flutter build appbundle to Apply for production: how to build and sign a release bundle, set up the closed track, keep 12 testers opted in for 14 days and avoid the Flutter mistakes that cost a week.

Build
flutter build appbundle
Version code
pubspec.yaml → version: 1.0.0+1
Target API
targetSdk ≥ 36

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.

  1. Set the application ID

    In android/app/build.gradle.kts, give applicationId a unique reverse-domain value such as com.yourcompany.app. It can't be changed after the first upload to Google Play.

  2. Check the INTERNET permission

    If the app talks to the network, android/app/src/main/AndroidManifest.xml must declare android.permission.INTERNET. The debug and profile manifests include it; the release one only has it if you add it.

  3. Build the bundle

    Run the command below from the project root. Flutter writes the bundle to build/app/outputs/bundle/release/.

Terminal
flutter build appbundle

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

Terminal (macOS / Linux)
keytool -genkey -v -keystore ~/upload-keystore.jks -keyalg RSA \
        -storetype JKS -keysize 2048 -validity 10000 -alias upload

Then create android/key.properties with the passwords and the path to the keystore. Keep it, and the .jks file, out of version control:

android/key.properties
storePassword=<keystore password>
keyPassword=<key password>
keyAlias=upload
storeFile=/Users/<you>/upload-keystore.jks

Load the file in android/app/build.gradle.kts and point the release build type at the new signing config:

android/app/build.gradle.kts
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:

pubspec.yaml
version: 1.0.0+1

1.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 +N in pubspec.yaml before every build you plan to upload.
  • Obfuscation without symbols. --obfuscate only 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 release build type still uses the debug signing config, Play Console refuses the upload.
  • Plugins with old native code. Plugins that ship .so libraries 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.
Terminal
flutter build appbundle --obfuscate --split-debug-info=build/symbols

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

  1. Keep the Dart symbols

    A build made with --split-debug-info writes one symbols file per architecture, such as app.android-arm64.symbols. Store the folder next to the version code it belongs to: a file from another build can't decode the trace.

  2. Decode Dart traces

    Save the trace to a file and pass it to flutter symbolize together with the symbols file for the device's architecture. Most current phones are arm64.

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

Terminal
flutter symbolize -i crash.txt -d build/symbols/app.android-arm64.symbols

Obfuscation 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 .aab and 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.

Open the Play Console steps

Ship fixes to testers during the test

In Flutter: raise the build number, rebuild and upload the new bundle to the same closed track.

Terminal
flutter build appbundle --build-name=1.0.1 --build-number=2

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

Terminal
flutter build apk
flutter install

To 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 targetSdk at 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.

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

Need 12 real testers for 14 days?

Add your app and closed testing link, pick Starter (15 testers) or Pro (25 testers), and follow every tester's activity day by day in your panel.

TestBunny isn't affiliated with Google. Testers earn credits, never cash. Nobody can guarantee Google's production access decision, and we don't.