Skip to content

Release builds

This page covers producing an installable Android/iOS artifact and what signing configuration already exists in the repository versus what you have to create yourself. It assumes you've already pointed the app at your own backend: see Connect to a self-hosted backend, since building a release binary bakes in whatever lib/config/api_config.dart says at build time, same as a debug build.

Both platforms read app identity and version from a few shared sources: applicationId io.bottlecrm (Android, android/app/build.gradle.kts) and bundle identifier io.bottlecrm (iOS, ios/Runner.xcodeproj/project.pbxproj) are the same string on both platforms; pubspec.yaml's version: 1.2.0+11 supplies both the build name (1.2.0) and build number (11) via flutter.versionName/flutter.versionCode on Android and $(FLUTTER_BUILD_NAME)/ $(FLUTTER_BUILD_NUMBER) on iOS. io.bottlecrm is BottleCRM's own identifier. It's also the package ID of BottleCRM's own Play Store listing referenced in mobile/README.md. If you're distributing your own build through your own Play Console/App Store Connect listing, you'll want your own identifier here, which has knock-on effects on the Google Sign-In client registrations covered on Connect to a self-hosted backend (the Android OAuth client and google-services.json's package_name are both tied to whatever applicationId you ship). For a sideloaded APK you build and install yourself, changing it isn't necessary.

Android

flutter build apk --release        # single APK, build/app/outputs/flutter-apk/app-release.apk
flutter build appbundle --release  # Play Store .aab, build/app/outputs/bundle/release/

Real minimum/target/compile SDK levels, from android/app/build.gradle.kts:

  • minSdk = 28 (Android 9). Set directly in this file, with the comment // Android 9 (API 28). mobile/README.md's "Android: API level 31+ (Android 12+)" and pubspec.yaml's flutter_launcher_icons.min_sdk_android: 31 (which only controls whether that tool generates adaptive icons, not the app's own floor) both say something different from the actual Gradle config. 28 is what the build enforces.
  • compileSdk = flutter.compileSdkVersion, targetSdk = flutter.targetSdkVersion. Both come from your installed Flutter SDK's defaults, not a number pinned in this project.

The build also requires ndkVersion = "28.2.13676358" (pinned in the same file, "Bumped to satisfy jni plugin requirement") and a JDK 17-compatible toolchain (compileOptions and the kotlin { compilerOptions { jvmTarget } } block both target Java/Kotlin 17). See Build and configure for the rest of the local environment needs.

android/app/build.gradle.kts applies the com.google.gms.google-services and com.google.firebase.crashlytics Gradle plugins unconditionally (classpath versions pinned in android/build.gradle.kts: com.google.gms:google-services:4.5.0, com.google.firebase:firebase-crashlytics-gradle:3.0.7). The google-services plugin's documented behavior is to fail the build outright if android/app/google-services.json isn't present. This repository ships only android/app/google-services.json.template. The real file is gitignored (**/google-services.json in mobile/.gitignore) and not present in a fresh clone. You need your own google-services.json from a Firebase/Google Cloud project before flutter build apk or flutter build appbundle will get past that plugin. See Connect to a self-hosted backend for what that file is for beyond just satisfying the build.

iOS

macOS and Xcode only.

flutter build ios --release              # produces build/ios/iphoneos/Runner.app; needs code signing
flutter build ios --release --no-codesign  # same, skipping signing (e.g. building without a team configured yet)

For an archive ready to upload to App Store Connect, flutter build ipa --release is the Flutter-managed equivalent of appbundle for iOS (it drives xcodebuild -archive and an export step, and needs valid signing to succeed. See below).

The real deployment target is IPHONEOS_DEPLOYMENT_TARGET = 12.0, set identically in all three build configurations in ios/Runner.xcodeproj/project.pbxproj (Debug, Release, Profile); mobile/README.md's "iOS: iOS 11.0+" doesn't match the project file.

Firebase/Crashlytics is deliberately not wired up for iOS in this codebase (main.dart skips Firebase.initializeApp outside TargetPlatform.android, with a comment noting there's no iOS GoogleService-Info.plist yet), so there's no equivalent of the Android google-services plugin gate blocking an iOS build, a plain flutter build ios --release --no-codesign will get through without any Firebase config. Getting past code signing is the separate concern covered next.

Signing

Android. android/app/build.gradle.kts loads a key.properties file from the Android project root, if one exists:

val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("key.properties")
if (keystorePropertiesFile.exists()) {
    keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}

It reads four properties from that file: keyAlias, keyPassword, storeFile, storePassword: to build a release signing config, and applies that to the release build type. key.properties doesn't ship in this repository. It's listed in android/.gitignore and there's no committed template for it the way there is for google-services.json. Critically, look at what happens to the release build type when the file is absent:

buildTypes {
    release {
        signingConfig = if (keystorePropertiesFile.exists())
            signingConfigs.getByName("release")
        else
            signingConfigs.getByName("debug")
    }
    ...
}

On a fresh clone, with no key.properties created, flutter build apk --release and flutter build appbundle --release fall back to the Android Gradle Plugin's built-in debug signing config, not a Flutter-provided key, and not one shared across installations. That config is backed by a debug.keystore the Android SDK tooling generates itself, per machine, the first time one is needed (conventionally at ~/.android/debug.keystore, with a fixed alias/password pair androiddebugkey/android): Flutter has no involvement in it beyond invoking Gradle, and two different machines will generate two different debug keystores. That has a concrete consequence beyond "not a real release key": a debug-signed release build from your laptop and one from your CI machine carry different signatures, and Android treats differently-signed builds as unrelated apps for update purposes. One can't silently update an install that came from the other, or from a build signed with a real upload key later. That's fine for a build you sideload once and don't expect to update; it is not a build you should distribute publicly, since anyone can reproduce a "signed" update off their own machine's debug keystore too. Before a release build is meant to go further than your own device, generate your own keystore. The standard keytool invocation is:

keytool -genkey -v -keystore <path-to-keystore>.jks -keyalg RSA -keysize 2048 \
  -validity 10000 -alias <your-key-alias>

And create android/key.properties (never commit it) with storeFile, storePassword, keyAlias, keyPassword pointing at it.

One more effect of that same conditional worth knowing about, on the debug build type:

debug {
    if (keystorePropertiesFile.exists()) {
        signingConfig = signingConfigs.getByName("release")
    }
}

If key.properties exists, debug builds are signed with your release key too, not the machine-local Android Gradle Plugin debug keystore described above. The code comment explains why: so a debug build installed for local testing has the same SHA-1 fingerprint as what you registered for Google Sign-In (see Connect to a self-hosted backend → Google Sign-In configuration), rather than needing a second, separate OAuth Android client just for debug installs.

iOS. ios/Runner.xcodeproj/project.pbxproj sets CODE_SIGN_IDENTITY[sdk=iphoneos*] = "iPhone Developer" for device builds at the project level, inherited by both targets. CODE_SIGN_STYLE = Automatic also appears in the file, but only on the RunnerTests target's three build configurations. The Runner app target and the project level don't set CODE_SIGN_STYLE at all, and the project's TargetAttributes block doesn't set a ProvisioningStyle for either target either. What is consistent across the whole file: there is no DEVELOPMENT_TEAM key anywhere in it (checked directly; it's simply absent), so regardless of what signing style Xcode shows you when you first open the project, you'll need to open ios/Runner.xcworkspace in Xcode, select the Runner target's Signing & Capabilities tab, and pick your Apple Developer team before flutter build ios (without --no-codesign) or flutter build ipa will succeed. There's no keystore-equivalent file to create by hand here, Xcode manages provisioning profiles itself once a team is selected.