cr: https://curogom.dev/android-build-system-practical-guide-4ae78c0a36ac
Android Build System Practical Guide (with Flutter)

Q. Have you ever experienced your Flutter app suddenly failing to build?
We’ve all been there — an app that built perfectly yesterday suddenly throws build errors today.
“Gradle sync failed”, “Duplicate class found”, “Cannot fit requested classes in a single dex file”… These error messages can leave you feeling helpless.
Gradle and Android SDK version issues are particularly frustrating. Even after scouring Stack Overflow for solutions, if you don’t understand why these problems occur, you’ll encounter them again.
In this article, I’ve compiled the essential Android build system knowledge every Flutter developer needs. Understanding these concepts will enable you to solve over 90% of build errors on your own.
Q. What is Gradle and why is it important?
Gradle is Android’s build tool
Gradle is an automated build tool that converts source code into APK or AAB files. Since Flutter apps are ultimately Android apps, they use Gradle too.
Let’s look at the Android structure in a Flutter project:
android/
├── app/
│ ├── src/
│ │ ├── main/
│ │ │ ├── AndroidManifest.xml
│ │ │ ├── kotlin/.../MainActivity.kt
│ │ │ └── res/
│ │ ├── debug/
│ │ └── profile/
│ └── build.gradle # App-level Gradle settings
├── gradle/
│ └── wrapper/
│ └── gradle-wrapper.properties # Gradle version definition
├── build.gradle # Project-level Gradle settings
├── settings.gradle
└── local.properties
Understanding Gradle and AGP (Android Gradle Plugin) versions
This is crucial, yet many developers find it confusing.
- Gradle: The build tool version itself
- AGP (Android Gradle Plugin): Plugin version for building Android apps
These two must be compatible!
Check Gradle version in gradle-wrapper.properties:
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-all.zip
Check AGP version in android/build.gradle:
buildscript {
dependencies {
classpath 'com.android.tools.build:gradle:8.2.0' // AGP version
}
}
Why use Gradle Wrapper?
What’s so special about gradlew (Gradle Wrapper)?
Many developers don’t understand why we use ./gradlew
instead of gradle
.
Benefits of Gradle Wrapper:
Ensures version consistency
- All team members use the same Gradle version
- Prevents “works on my machine” problems
No Gradle installation required
- Automatically downloads the required Gradle version
- Works in CI/CD environments without separate installation
Easy version management
- Just modify the
gradle-wrapper.properties
file
How to use:
# Mac/Linux
./gradlew clean
./gradlew build
# Windows
gradlew.bat clean
gradlew.bat build# Wrong way (uses system gradle)
gradle clean # Don't do this!
Upgrading Gradle version
# Upgrade Gradle version through wrapper
# Important: You need to run this command TWICE!
./gradlew wrapper --gradle-version=8.2
./gradlew wrapper --gradle-version=8.2 # Second run
# Or directly modify gradle-wrapper.properties
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-all.zip
Why run it twice? According to Google’s Android developer documentation, to upgrade both Gradle and the Gradle Wrapper itself, you need to run the wrapper command twice. The first run updates the Wrapper itself, and the second run updates Gradle with the new Wrapper.
This ensures everyone who clones the project has the same build environment.
Q. How do I handle Google Play’s API level requirements?
Big changes coming August 31, 2025
Google Play requires yearly target API level increases. Starting August 31, 2025, all new apps and updates must target Android 15 (API level 35) or higher.
Configure in android/app/build.gradle:
android {
compileSdkVersion 35 // SDK used for compilation
defaultConfig {
applicationId "com.example.myapp"
minSdkVersion 21 // Minimum supported Android version
targetSdkVersion 35 // Target Android version (required by Aug 31, 2025)
versionCode 1
versionName "1.0.0"
}
}
Understanding each SDK version
minSdkVersion (Minimum SDK version)
- Minimum Android version your app can run on
- Flutter default is 21 (Android 5.0)
- Lower = more device support, but limited API availability
targetSdkVersion (Target SDK version)
- Android version your app is tested and optimized for
- Applies new Android security and performance features
- Must be updated periodically per Google Play policy
compileSdkVersion (Compile SDK version)
- Android SDK version used for compilation
- Usually set equal to or higher than targetSdkVersion
- Must be raised to use newer APIs
Using Flutter’s automatic SDK version settings
New feature in Flutter 3.16+!
Flutter now automatically sets recommended Android SDK versions for you.
Use in android/app/build.gradle:
android {
// Use Flutter's recommended versions instead of hardcoding
compileSdkVersion flutter.compileSdkVersion
defaultConfig {
applicationId "com.example.myapp"
minSdkVersion flutter.minSdkVersion
targetSdkVersion flutter.targetSdkVersion
versionCode 1
versionName "1.0.0"
}
}
Benefits
- Automatically applies latest recommended versions when upgrading Flutter
- Automatically responds to Google Play policy changes
- No need to manually manage versions
When manual configuration is needed
android {
// Specify directly if you need specific versions
compileSdkVersion 35
defaultConfig {
// If you need a lower minimum version
minSdkVersion 19 // Lower than Flutter default // Set directly for Google Play requirements
targetSdkVersion 35
}
}
Check current Flutter version defaults
# Check Flutter SDK default values
cat $FLUTTER_ROOT/packages/flutter_tools/gradle/src/main/groovy/flutter.groovy | grep -E "(compileSdk|minSdk|targetSdk)"
This way you can use stable SDK versions tested by the Flutter team while still having the flexibility to customize when needed.
Considerations when updating to API 35
Check new permission models
- Especially changes to notification, location, and photo permissions
Background work restrictions
- Stricter limitations due to battery optimization
Testing is essential
- Test on actual Android 15 devices or emulators
Q. How do I use Build Variants and Product Flavors?
For separating development/staging/production environments
Many developers use development and production servers, but manually modifying code for each build is risky.
Add flavors to android/app/build.gradle:
android {
flavorDimensions "environment"
productFlavors {
dev {
dimension "environment"
applicationIdSuffix ".dev"
versionNameSuffix "-dev" // Define development server URL with buildConfigField
buildConfigField "String", "API_URL", '"https://dev-api.example.com"'
} staging {
dimension "environment"
applicationIdSuffix ".staging"
versionNameSuffix "-staging" buildConfigField "String", "API_URL", '"https://staging-api.example.com"'
} prod {
dimension "environment" buildConfigField "String", "API_URL", '"https://api.example.com"'
}
}
}
Flavor-specific folder structure and AndroidManifest.xml usage
You can create separate folders for each flavor to configure different resources:
android/app/src/
├── main/
│ ├── AndroidManifest.xml # Base Manifest
│ ├── res/
│ │ ├── mipmap-hdpi/
│ │ │ └── ic_launcher.png
│ │ └── values/
│ │ └── strings.xml
│ └── kotlin/
├── dev/
│ ├── AndroidManifest.xml # Dev-specific Manifest (optional)
│ └── res/
│ ├── mipmap-hdpi/
│ │ └── ic_launcher.png # Dev icon
│ └── values/
│ └── strings.xml # Dev app name
├── staging/
│ └── res/
└── prod/
└── res/
Changing app name per flavor
main/res/values/strings.xml (default):
<resources>
<string name="app_name">My App</string>
</resources>
dev/res/values/strings.xml (development):
<resources>
<string name="app_name">My App (Dev)</string>
</resources>
staging/res/values/strings.xml (staging):
<resources>
<string name="app_name">My App (Staging)</string>
</resources>
Changing icons per flavor
Place different icons in each flavor’s res/mipmap-*
directories for automatic application:
dev/res/mipmap-hdpi/ic_launcher.png
→ Development iconstaging/res/mipmap-hdpi/ic_launcher.png
→ Staging icon
Using flavor-specific AndroidManifest.xml
Add specific permissions or settings with dev/AndroidManifest.xml:
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Debugging permission needed only in dev environment -->
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<application>
<!-- Allow HTTP only in development -->
<meta-data
android:name="android.network-security-config"
android:resource="@xml/network_security_config_dev" />
</application>
</manifest>
Understanding merge rules
AndroidManifest.xml files are merged in this order:
main/AndroidManifest.xml
(base)<flavor>/AndroidManifest.xml
(flavor-specific)- Build type Manifest (debug/release)
When conflicts occur, use tools:replace
to override.
Run with Flutter:
# Run in development environment (uses dev icon and name)
flutter run --flavor dev
# Build for production (uses prod settings)
flutter build apk --flavor prod
To access from Dart code, additional setup is needed. Usually the flutter_flavor
package is used.
Q. How do I set and change the Package Name?
Package Name is your app’s unique identifier
Package Name (Application ID) is the unique ID that identifies your app on Google Play Store. Once published, it can never be changed, so choose carefully.
Check default Package Name:
// android/app/build.gradle
defaultConfig {
applicationId "com.example.myapp" // This is the Package Name
}
How to change Package Name
- Modify build.gradle
defaultConfig { applicationId "com.mycompany.awesomeapp" // Change to desired name }
Change MainActivity package path
android/app/src/main/kotlin/com/example/myapp/ → android/app/src/main/kotlin/com/mycompany/awesomeapp
1. Modify MainActivity.kt ```kotlin package com.mycompany.awesomeapp // Change package name
2. import io.flutter.embedding.android.FlutterActivity
3. class MainActivity: FlutterActivity() { }
4. **No need to modify AndroidManifest.xml**
- If it shows `.MainActivity`, it automatically follows applicationId
### Package Name naming conventions- Reverse domain format recommended: `com.companyname.appname`
- Use lowercase only
- No special characters (underscore allowed)
- No reserved words (java, android, etc.)## Q. How do I resolve Multidex errors?### "Cannot fit requested classes in a single dex file" errorAndroid apps can only contain 65,536 methods by default. Exceeding this requires Multidex.**Good news: Recent Flutter versions handle this automatically!**
Flutter 2.10+ automatically enables Multidex when minSdkVersion is 21 or higher.If you still encounter issues, manually configure in **android/app/build.gradle**:
```gradle
android {
defaultConfig {
minSdkVersion 21 // Automatic multidex support for 21+
multiDexEnabled true // Explicitly enable multidex
}
}dependencies {
implementation 'androidx.multidex:multidex:2.0.1' // Only needed for minSdk < 21
}
Additional setup needed for minSdkVersion below 21:
// MainActivity.kt
import androidx.multidex.MultiDexApplication
class MyApplication : MultiDexApplication() {
// ...
}
Q. What is Desugaring and when do I need it?
Using modern Java features on older Android versions
Desugaring is a technology that allows you to use Java 8+ features on older Android versions.
Get CuroGom’s stories in your inbox
Join Medium for free to get updates from this writer.
Why is it needed?
- Limited Java 8 feature support on Android below 7.0 (API 24)
- Cannot use useful features like
LocalDateTime
,Stream API
- Many modern libraries require Java 8+ features
Enabling Desugaring
Add to android/app/build.gradle:
android {
compileOptions {
// Enable Java 8 support
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
// Enable Desugaring
coreLibraryDesugaringEnabled true
}
}dependencies {
// Add Desugaring library
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4'
}
Now available features
// Native plugins using Java 8 date/time APIs
// now work properly in Dart too
// Example: In native code
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;LocalDateTime now = LocalDateTime.now();
String formatted = now.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME);
Considerations
- APK size increases slightly (usually 100–200KB)
- Unnecessary if minSdkVersion is 21+ and you don’t use Java 8 features
- May require some ProGuard/R8 rule adjustments
Q. How do I manage Android permissions?
Two types of permissions
Android classifies permissions into two categories:
Normal Permissions
- Granted automatically at install
- Internet, vibration, etc.
Dangerous Permissions
- Require user consent at runtime
- Camera, location, storage, etc.
Declare permissions in AndroidManifest.xml:
<manifest>
<!-- Normal permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.VIBRATE" />
<!-- Dangerous permissions -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <!-- New permissions for Android 13+ -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
</manifest>
Android 13+ permission changes
Important changes from Android 13 (API 33):
- Notification permission now requires runtime permission
- Before: Notifications allowed automatically
- Now: Must explicitly request user permission
- Media permissions are more granular
- Before: READ_EXTERNAL_STORAGE for all file access
- Now: Separate permissions for photos, videos, and audio
Handle in Flutter with permission_handler
package:
// Request notification permission (Android 13+)
if (Platform.isAndroid) {
final androidInfo = await DeviceInfoPlugin().androidInfo;
if (androidInfo.version.sdkInt >= 33) {
final status = await Permission.notification.request();
if (status.isDenied) {
// Handle permission denial
}
}
}
Q. How do I manage Keystore and app signing?
Keystore is your app’s identity
Keystore is a certificate used to sign your app. If lost, you cannot update your app, so keep it safe.
Creating a Keystore
keytool -genkey -v -keystore ~/upload-keystore.jks \
-keyalg RSA -keysize 2048 -validity 10000 \
-alias upload
Create key.properties file
android/key.properties (Don’t add to Git!):
storePassword=<password>
keyPassword=<password>
keyAlias=upload
storeFile=<keystore file path>
Configure signing in build.gradle
def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties')
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
android {
signingConfigs {
release {
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null
storePassword keystoreProperties['storePassword']
}
} buildTypes {
release {
signingConfig signingConfigs.release
}
}
}
Q. How do I solve Google Play App Signing and SHA-1 issues?
This really troubles many developers
When using Google Play App Signing, Google re-signs the final APK. This changes the SHA-1 fingerprint, causing issues with Kakao login, Google login, and other services.
Types of SHA-1 keys
- Debug certificate SHA-1: Used during development
- Upload certificate SHA-1: Used when uploading to Play Console
- App Signing certificate SHA-1: Final SHA-1 after Google re-signs
How to check each SHA-1
Check Debug SHA-1:
keytool -list -v -keystore ~/.android/debug.keystore \
-alias androiddebugkey -storepass android -keypass android
Check Upload SHA-1:
keytool -list -v -keystore upload-keystore.jks \
-alias upload
Check App Signing SHA-1:
- Access Play Console
- Select app → Setup → App integrity
- Check SHA-1 in “App signing” tab
Configuring social login
Each service requires SHA-1 registration:
Kakao Developer Console:
- Platform → Android → Register key hashes
- Add Debug, Upload, and App Signing SHA-1s
Google Firebase Console:
- Project settings → Android app
- Add all to SHA certificate fingerprints
This ensures proper operation in development/test/production environments.
Q. How do I resolve AndroidManifest.xml conflicts?
When plugins conflict
Conflicts occur when multiple plugins declare the same permissions or settings.
Common conflict resolution:
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Override with tools:replace -->
<application
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
tools:replace="android:label,android:icon"> <!-- Specify merge rules with tools:node -->
<activity
android:name=".MainActivity"
tools:node="merge">
</activity> <!-- Remove specific elements -->
<uses-library
android:name="org.apache.http.legacy"
android:required="false"
tools:node="remove" />
</application>
</manifest>
Merge rule options
tools:replace
: Override the attributetools:remove
: Remove the elementtools:node="merge"
: Merge (default)tools:node="remove"
: Removetools:node="removeAll"
: Remove all elements of same type
Q. How do I configure Firebase?
The location of google-services.json is crucial
To use Firebase, the google-services.json
file must be in the correct location.
android/
└── app/
├── src/
└── google-services.json // Here!
build.gradle configuration
android/build.gradle (project level):
buildscript {
dependencies {
classpath 'com.google.gms:google-services:4.4.0'
}
}
android/app/build.gradle (app level):
apply plugin: 'com.google.gms.google-services' // Add at the bottom
dependencies {
implementation platform('com.google.firebase:firebase-bom:32.7.0')
implementation 'com.google.firebase:firebase-analytics'
}
Additional setup for Push notifications
AndroidManifest.xml:
<service
android:name=".MyFirebaseMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<!-- Default notification icon and color -->
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/ic_notification" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_color"
android:resource="@color/colorAccent" />
Q. How do I solve common build errors?
1. “Gradle sync failed” error
Cause: Usually version compatibility issues
Solution:
# 1. Clear cache
cd android
./gradlew clean
# 2. Clean Flutter cache
flutter clean
flutter pub get# 3. Completely delete Gradle cache (last resort)
rm -rf ~/.gradle/caches/
2. “Duplicate class” error
Cause: Different versions of same library included multiple times
Solution:
// android/app/build.gradle
android {
packagingOptions {
exclude 'META-INF/DEPENDENCIES'
exclude 'META-INF/LICENSE'
exclude 'META-INF/LICENSE.txt'
exclude 'META-INF/NOTICE'
exclude 'META-INF/NOTICE.txt'
}
}
// Exclude specific libraries
configurations {
all {
exclude group: 'com.google.guava', module: 'listenablefuture'
}
}
3. “SDK location not found” error
Cause: Missing local.properties file
Solution:
# Create android/local.properties
sdk.dir=/Users/username/Library/Android/sdk # Mac
sdk.dir=C:\\Users\\username\\AppData\\Local\\Android\\sdk # Windows
4. “Namespace not specified” error
Cause: Namespace required in AGP 7.0+
Solution:
// android/app/build.gradle
android {
namespace 'com.example.myapp' // Same as applicationId
}
Q. To summarize?
The Android build system may seem complex, but understanding key concepts allows you to solve most problems.
Key points to remember
- Check Gradle and AGP version compatibility
- targetSdkVersion 35 required by August 31, 2025
- Choose Package Name carefully (cannot be changed)
- Keep Keystore safe
- Manage SHA-1 when using Google Play App Signing
- Handle Android 13+ permission changes
Now you can systematically solve build errors without panic. Make it a habit to read error messages carefully and check version compatibility first.
Coming next iOS Build System Practical Guide — From Xcode Project Structure to App Store Builds
Please share your Android build problems and solutions in the comments to help others.
Series: Essential Native Development Knowledge for Flutter Developers #2
ไม่มีความคิดเห็น:
แสดงความคิดเห็น