Gradle and Android Build Interview Questions
Build system questions: how Gradle builds an Android app, build types and flavours, version catalogs, compileSdk, minSdk and targetSdk, R8 and ProGuard, the configuration cache, baseline profiles and faster builds.
By Vivek Choudhary, Android engineer · Updated October 2026 · How answers are checked
All 50 questions
- What is Gradle? How does the Android build system work at a high level? Most asked
- What is the difference between APK and AAB? Why does Google prefer AAB? Most asked
- What is R8? How does it differ from ProGuard? What does it do to your app? Most asked
- How do you write ProGuard/R8 keep rules? When does R8 break your app? Most asked
- What are build types in Android? How do debug and release differ? Most asked
- What are product flavors in Android? How do they combine with build types? Most asked
- Scenario: Your APK is 80MB and the team wants it under 30MB. Walk through your reduction strategy.
- What is KAPT vs KSP? Why is KSP faster and when should you migrate? Most asked
- What is the Version Catalog (libs.versions.toml)? Why is it better than declaring dependencies inline? Most asked
- Scenario: Your CI build takes 20 minutes. How do you systematically reduce Gradle build time?
- What is the difference between implementation, api, compileOnly, and runtimeOnly in Gradle? Most asked
- What are Convention Plugins (build-logic)? How do they eliminate build script duplication in multi-module projects?
- What are compileSdk, minSdk, and targetSdk? What happens if you set them wrong? Most asked
- Scenario: Your release app crashes but debug works fine. How do you diagnose R8-related crashes?
- What is versionCode vs versionName? How do you automate version management? Most asked
- Scenario: Set up a complete signing configuration for release builds without hardcoding credentials in build files.
- What is BuildConfig? What can you store in it and how do you access it? Most asked
- What are Dynamic Feature Modules? When should you use them?
- What is Lint in Android? How do you configure custom Lint rules? Most asked
- Scenario: Set up a multi-module Android project with shared build logic, Version Catalog, and a CI pipeline that builds and runs tests.
- What is the Gradle configuration cache? What breaks it and how do you fix it? Most asked
- What is the difference between a library module and an application module in Android? Most asked
- Scenario: How do you measure and track APK/AAB size changes in CI to prevent accidental size regressions?
- What is R8 Full Mode? How does it differ from standard R8?
- Scenario: Conduct a build system code review. What 8 things do you check?
- What is Gradle's task graph? How does task dependency work? Most asked
- How does dependency resolution work in Gradle? What is dependency conflict resolution? Most asked
- Scenario: How do you set up Gradle to publish an Android library to Maven Central or GitHub Packages?
- What is the Gradle version catalog's bundle feature? How do you use it?
- Scenario: How do you configure a multi-flavour CI pipeline that builds, tests, and uploads different variants to different channels?
- What is the 64K method limit in Android? How do you enable and configure multidex? Most asked
- How do you use resource qualifiers and density-specific assets? How does this affect APK size? Most asked
- Scenario: How do you implement baseline profiles to speed up app startup and reduce jank?
- What is the Android Gradle Plugin (AGP)? How do you upgrade it safely?
- Scenario: How do you set up a remote Gradle build cache to share build outputs across the whole team?
- What is the difference between debuggable and non-debuggable builds? What are the security implications? Most asked
- Scenario: How do you configure obfuscation to protect your app against reverse engineering while keeping it functional?
- How do you use source sets in Android? How do flavors and build types contribute their own source sets? Most asked
- Scenario: Your build script has grown to 200 lines of complex logic. How do you refactor it to be maintainable?
- What are the most useful Gradle command-line options for Android development? Most asked
- What is Gradle's Kotlin DSL vs Groovy DSL? Why is Kotlin DSL now preferred?
- Scenario: How do you use the Macrobenchmark library to measure and improve app performance?
- How do you manage secrets and API keys in Android builds? What are the safe and unsafe approaches? Most asked
- Scenario: Your app uses a native (.so) library. How do you integrate, package, and debug native code in the Android build?
- Scenario: You are the build engineer onboarding a new Android project. What does your ideal Gradle setup look like?
- What is the Android namespace in build.gradle.kts? How does it replace the package attribute in AndroidManifest? Most asked
- Scenario: How do you detect and fix memory leaks at the build level using LeakCanary in development builds only?
- What is nonTransitiveRClass and how does it speed up multi-module builds?
- Scenario: A third-party library you depend on has a known vulnerability. How do you handle it before the library author fixes it?
- Scenario: You are joining a team with a slow, messy build system. Write your action plan to fix it over 3 months.
1. What is Gradle? How does the Android build system work at a high level?
Gradle is the build automation tool for Android. It takes your source code, resources, and dependencies, then compiles, optimises, and packages them into an APK or AAB. Understanding the build pipeline helps you diagnose slow builds and misconfigured outputs.
// Build pipeline (simplified): // Source code (.kt/.java) // → Kotlin/Java Compiler → .class files (bytecode) // → D8 (dex compiler) → .dex files (Dalvik bytecode) // → R8 (if enabled) → shrink + obfuscate + optimise .dex // → Packager → APK or AAB // → zipalign + sign → release-ready artifact // Two build scripts in every Android project: // settings.gradle.kts — project structure pluginManagement { repositories { google(); mavenCentral() } } include(":app", ":core:network", ":feature:home") // app/build.gradle.kts — module configuration plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) } android { compileSdk = 35 defaultConfig { applicationId = "com.example.app" minSdk = 24; targetSdk = 35 versionCode = 1; versionName = "1.0" } buildTypes { release { isMinifyEnabled = true } } } dependencies { implementation(libs.androidx.core.ktx) } // Gradle wrapper (gradlew) — pins Gradle version for the project // gradle/wrapper/gradle-wrapper.properties defines the exact Gradle version // Always commit the wrapper — ensures every developer uses the same build
Gradle builds proceed in three phases: initialisation, configuration, and execution. During initialisation, Gradle reads settings.gradle.kts to discover which modules (projects) exist. During configuration, every module's build.gradle.kts is evaluated — all tasks are created and their dependencies are wired together into a directed acyclic graph (DAG). During execution, only the tasks needed for the requested output are run, in the order their dependencies require. The configuration phase runs even for tasks you never execute, which is why a large project with many modules can have a slow configuration time even when individual tasks are fast.
The Android Gradle Plugin (AGP) sits on top of Gradle and adds Android-specific tasks: compileDebugKotlin, mergeDebugResources, packageDebugApk, and many others. AGP wires these tasks together automatically based on your build config — you don't call them directly, you call high-level tasks like assembleDebug which AGP breaks down into the correct sequence of lower-level tasks. Understanding that assembleDebug is just a task that depends on dozens of other tasks (not a monolithic build command) is the mental model that enables intelligent build optimisation: you can profile which task in the chain is slow, cache its output, and skip it when inputs haven't changed.
The Gradle wrapper (gradlew) pins a specific Gradle version to your project, ensuring every developer and CI machine uses the same version without manual installation. The gradle-wrapper.properties file specifies the Gradle distribution URL — commit this file to version control. Never run gradle directly (the system installation); always use ./gradlew. This ensures reproducible builds regardless of what version of Gradle is installed globally on the machine. When upgrading Gradle, update the wrapper properties file and test the build thoroughly before merging — Gradle minor versions occasionally deprecate APIs used by plugins that haven't yet upgraded.
- Gradle orchestrates: compiling Kotlin, merging resources, running R8, and packaging
- D8: the dex compiler — converts Java bytecode to Android's Dalvik bytecode
- R8: runs after D8 — shrinks, obfuscates, and optimises (replaces old ProGuard)
- settings.gradle.kts: declares project structure and module graph
- Gradle wrapper: pins Gradle version — everyone on the team uses the same build tool
"The pipeline in one sentence: Kotlin source → bytecode → dex → (R8 shrinks/obfuscates) → packaged into APK/AAB → signed. Knowing where in this pipeline a problem occurs tells you which tool to investigate — compile error = Kotlin compiler, missing class in release = R8 shrinking too aggressively."
2. What is the difference between APK and AAB? Why does Google prefer AAB?
APK (Android Package) is the traditional installable file -- it contains code, resources, and native libraries for every device configuration. AAB (Android App Bundle) is a publishing format: you upload it to Play Store, and Play generates device-specific APKs from it. Users only download the code and resources their specific device needs -- typically 20-40% smaller than a universal APK.
// Build APK -- for direct distribution or testing // ./gradlew assembleRelease // Build AAB -- for Play Store (mandatory since August 2021) // ./gradlew bundleRelease // bundletool -- test AAB locally before uploading to Play // bundletool build-apks --bundle=app.aab --output=app.apks // bundletool install-apks --apks=app.apks // ABI splits for direct APK distribution (achieves same size benefit as AAB) android { splits { abi { isEnable = true reset() include("arm64-v8a", "armeabi-v7a") isUniversalApk = false } } }
The key technical difference between APK and AAB is when splitting happens. With APK, you either ship one universal APK (all densities, all ABIs, all languages bundled) or you manually configure APK splits in Gradle and manage multiple APK variants. With AAB, Google Play handles splitting automatically at distribution time — it analyses the target device's screen density, CPU architecture, and language settings, and serves a device-specific APK containing only the resources that device needs. This is transparent to the developer; you upload one AAB and Play handles the rest.
The size savings from AAB are substantial and automatic. A typical AAB-distributed app is 15–20% smaller than the equivalent universal APK because unnecessary density buckets (hdpi resources on an xxhdpi device), unused ABI libraries (arm64-v8a library on an x86_64 emulator), and unneeded language strings are stripped. For apps with large native libraries — games, ML-heavy apps — the ABI splitting alone can reduce the delivered size by 30–40% since most devices only need one ABI. These size reductions directly improve install conversion rate: Play Store data shows measurable conversion rate improvement for every 10MB reduction in download size.
Local testing with AAB requires the bundletool command-line utility (or Android Studio's built-in AAB deployment). bundletool build-apks --bundle=app.aab --output=app.apks generates an .apks archive containing all possible device-specific APK splits. bundletool install-apks --apks=app.apks installs the correct splits for the connected device — identical to what Play would install. This workflow is essential for verifying that your AAB's split configuration works correctly before uploading to Play, catching issues like missing native library splits or incorrect resource qualifiers that would cause crashes on specific device configurations.
- APK: self-contained installable -- includes all ABIs, all densities, all languages -- users download everything even if unused
- AAB: publishing format, not directly installable -- Play generates per-device split APKs automatically
- 20-40% smaller: Play strips wrong-ABI native libs, wrong-density images, and unused language strings
- Mandatory for Play: AAB required for new apps since August 2021
- bundletool: Google's CLI to simulate AAB→APK generation locally -- verify the output before uploading to Play
"The key insight: AAB is not the file users install — it's the file Google Play uses to generate user-specific APKs. A user on a Pixel 8 (arm64, xxhdpi, English) gets an APK containing only arm64 libraries, xxhdpi images, and English strings. That's why downloads shrink significantly — most users were downloading 3x the resources they needed with APK."
3. What is R8? How does it differ from ProGuard? What does it do to your app?
R8 is the modern replacement for ProGuard — it does code shrinking, obfuscation, and optimisation in a single pass. It's significantly faster than ProGuard and produces smaller output. Since AGP 3.4, R8 is the default.
// Enable R8 (default in release builds) android { buildTypes { release { isMinifyEnabled = true // enable R8 shrinking + obfuscation isShrinkResources = true // also remove unused resources proguardFiles(getDefaultProguardFile ("proguard-android-optimize.txt"), "proguard-rules.pro" ) } } } // What R8 does (in one pass): // 1. SHRINKING (tree-shaking) // Removes unused classes, methods, and fields // A library with 10,000 methods you use 50 of → 50 methods in output // 2. OBFUSCATION // Renames: com.example.UserRepository → a.b // Makes reverse engineering much harder // Produces mapping.txt for crash de-obfuscation // 3. OPTIMISATION // Inlines short methods // Removes dead code branches // Rewrites bytecode for smaller dex // R8 vs ProGuard: // ProGuard R8 // Integrated No Yes (built into AGP) // Speed Slow 2-3x faster // Dex output size Larger ~8% smaller // Full mode No Yes (more aggressive) // mapping.txt — generated alongside release build // Upload to Play Console → crash stacktraces auto-deobfuscated // WITHOUT mapping.txt: "at a.b.c(Unknown Source:4)" // WITH mapping.txt: "at com.example.UserViewModel.loadUser(UserViewModel.kt:42)"
R8 performs three distinct optimisations in a single pass over your bytecode. Shrinking removes classes, methods, and fields that are provably unreachable — code that can never be called from your app's entry points. Obfuscation renames the remaining classes and members to short meaningless names (a, b, c), reducing the size of the DEX file and making reverse engineering harder. Optimisation rewrites bytecode to be more efficient: inlining short methods, removing dead branches, propagating constants, eliminating redundant null checks. ProGuard did only shrinking and obfuscation and was a separate tool; R8 combines all three in a single faster pass directly integrated into the AGP build pipeline.
R8's most impactful advantage over ProGuard is its deeper understanding of Kotlin. R8 knows that Kotlin data classes generate equals(), hashCode(), and copy() — if none of these are called, R8 removes them. It understands Kotlin's when expressions and can eliminate unreachable branches at compile time. It understands companion objects and removes them when they only contain constants that R8 has already inlined. ProGuard treated Kotlin-generated bytecode as opaque Java bytecode and was conservative about removing anything it couldn't prove was dead. The result is that R8 produces smaller DEX output than ProGuard for the same codebase, typically by an additional 5–10%.
R8 breaks apps in three common ways. First, reflection: any class or member accessed via Class.forName(), getDeclaredMethod(), or Gson/Moshi annotations that use reflection must be kept with explicit rules, because R8 cannot trace reflection at compile time. Second, serialisation libraries that instantiate classes by name (Gson, older Moshi, Retrofit converters) need @Keep annotations or keep rules on all model classes. Third, JNI: native methods declared in Kotlin/Java that are called from C++ must not be obfuscated — add -keepclasseswithmembernames class * { native <methods>; }. The standard workflow is to always build and test a release APK against your full test suite before shipping.
- R8 = shrinking + obfuscation + optimisation in one pass — ProGuard did them separately
- Shrinking removes unused code: libraries rarely used fully — R8 strips what you don't call
- Obfuscation: renames classes and methods to single letters — harder to reverse engineer
- mapping.txt: crucial for crash debugging — always save it alongside every release build
- isShrinkResources: separate flag to also remove unused drawables, layouts, strings
"The most important production practice: always upload mapping.txt to Play Console for every release. Without it, crash reports from Firebase Crashlytics and Play Vitals show obfuscated stack traces — 'a.b.c:4' instead of real method names. You can't debug crashes without the mapping file from that exact build."
4. How do you write ProGuard/R8 keep rules? When does R8 break your app?
R8 removes code it thinks is unused — but it can't see code accessed via reflection, serialisation, or native JNI. Keep rules tell R8 "don't touch this" for classes that must survive shrinking.
// proguard-rules.pro — your custom keep rules // Keep a class and all its members -keep class com.example.api.UserDto { *; } // Keep all classes in a package -keep class com.example.api.** { *; } // Keep only class name (not members) — for reflection -keepnames class com.example.MyClass // Keep Serializable classes intact (Gson/Moshi use reflection) -keepclassmembers class * implements java.io.Serializable { private static final java.io.ObjectStreamField[] serialPersistentFields; private void writeObject(java.io.ObjectOutputStream); private void readObject(java.io.ObjectInputStream); java.lang.Object readResolve(); } // Common situations where R8 breaks things: // 1. Gson / reflection-based serialisation // R8 removes fields it thinks unused — Gson reads them via reflection // Fix: add -keep for your model classes, OR switch to Kotlin Serialization -keepclassmembers class com.example.models.** { *; } // 2. Retrofit interface methods // R8 may remove methods it thinks uncalled — Retrofit uses reflection // Fix: library consumer rules (Retrofit ships its own .pro rules — usually auto-applied) // 3. Custom View constructors (needed by XML inflation) -keepclasseswithmembers class * extends android.view.View { public(android.content.Context); public (android.content.Context, android.util.AttributeSet); } // Debug R8 issues // -printusage usage.txt → shows what was removed // -printseeds seeds.txt → shows what was kept // -verbose → detailed output during R8 run
ProGuard/R8 rules follow a simple pattern: -keep preserves a class or member, -keepclassmembers preserves members of a class that R8 itself has decided to keep, and -keepclasseswithmembers keeps a class only if it has the specified member. The most targeted rule keeps a specific class: -keep class com.example.MyModel { *; }. The wildcard { *; } keeps all members — too broad for production; prefer { <fields>; } to keep only fields for serialised models. Use @Keep annotation instead of rules when you control the class — it is refactor-safe (moves with the class) and avoids the brittle string-matching of rules files.
The most reliable way to write correct keep rules is to read the R8 output. Enable -printusage build/outputs/mapping/release/usage.txt to see every class and member that R8 removed — if something in this list shouldn't have been removed, add a keep rule for it. Enable -printmapping build/outputs/mapping/release/mapping.txt to get the obfuscation mapping — this file is essential for deobfuscating crash stack traces in production. Upload the mapping file to Firebase Crashlytics or Play Console after every release so that crash reports show original class and method names. Losing a mapping file for a release version makes crash debugging nearly impossible.
Incorrect keep rules are one of the most common sources of release-only crashes. The typical failure mode: a class is used only via reflection (by a JSON serialisation library), R8 removes its members because there are no direct code references, and the app crashes in production with a MissingFieldException or a null field that should have been populated. Debug builds don't reproduce this because R8 is disabled by default in debug. The safe development practice is to maintain a dedicated proguard-rules.pro file per module, keep it under version control alongside the code it protects, and run a release build in CI for every PR — not just before a release. A test that only runs the keep rules once a sprint will miss regressions for weeks.
- -keep: preserve class + all members — use for reflection-accessed classes
- -keepnames: preserve name only, R8 can still remove unused members
- -keepclassmembers: keep specific members of matched classes
- Library rules: most libraries ship consumer ProGuard rules — auto-applied, check aar/META-INF
- Debug with -printusage: generates a file listing everything R8 removed — find missing classes
"The fastest way to debug an R8 crash: run the release build on a device, get the crash stack trace, check if the class names are obfuscated. If yes, use the mapping.txt. If the class is missing entirely (ClassNotFoundException), R8 stripped it — add a -keep rule. Add -printusage to the build to see exactly what got removed."
5. What are build types in Android? How do debug and release differ?
Build types define how your app is compiled and packaged for different purposes. Debug is for development — fast builds, debugging enabled, test signing. Release is for distribution — optimised, obfuscated, production-signed.
android { buildTypes { // DEBUG — automatic, for development debug { isDebuggable = true // allows debugger attachment isMinifyEnabled = false // R8 off — fast builds applicationIdSuffix = ".debug" // install alongside release versionNameSuffix = "-debug" // Uses auto-generated debug.keystore for signing } // RELEASE — for Play Store release { isDebuggable = false isMinifyEnabled = true // R8 enabled isShrinkResources = true // remove unused resources proguardFiles(getDefaultProguardFile ("proguard-android-optimize.txt"), "proguard-rules.pro" ) signingConfig = signingConfigs.getByName ("release") } // STAGING — custom build type (mirrors release config but hits staging server) create("staging") { initWith(getByName ("release")) // inherits release settings applicationIdSuffix = ".staging" buildConfigField("String", "API_URL", "\"https://staging.api.example.com\"") signingConfig = signingConfigs.getByName ("debug") // easier to install } } // BuildConfig fields — accessible from code defaultConfig { buildConfigField("String", "API_URL", "\"https://api.example.com\"") } } // Access in code: val url = BuildConfig.API_URL if (BuildConfig.DEBUG) { /* dev-only code */ }
Build types in Android are configurations that control how the app is compiled and packaged. The two built-in types are debug and release, but you can add custom types like staging or benchmark. Debug has debuggable = true (allows debugger attachment), minifyEnabled = false (R8 disabled), and a debug signing key applied automatically. Release has debuggable = false, typically enables minifyEnabled = true and shrinkResources = true, and requires an explicit signing configuration. The applicationIdSuffix property in a build type (e.g., .debug) lets debug and release variants install alongside each other on the same device — invaluable for testing without uninstalling the production app.
Custom build types serve specific workflow needs. A staging build type can point at a staging backend URL (set via BuildConfig fields), enable certain debugging tools that production disables, but still run R8 to catch any minification issues before they reach production. A benchmark build type is required for Macrobenchmark measurements — it must be non-debuggable (to get accurate performance numbers) but uses a signing key that allows installation without the full release keystore setup. Setting isDefault = true on your most-used non-debug build type avoids having to specify it on every Gradle command in CI.
Build type source sets allow you to override specific files per build type. A src/debug/ directory can contain a google-services.json pointing at the development Firebase project, while src/release/ contains the production one — no conditional code in Application.onCreate(), just file-level overrides. Similarly, a debug-only DebugActivity can live in src/debug/java/ and be registered in a debug-only AndroidManifest.xml overlay — it is simply absent from the release build without any if (BuildConfig.DEBUG) guards. This approach keeps debug tools completely out of the release APK rather than just conditionally disabled.
- debug: debuggable, no R8, debug signing — fast iteration during development
- release: non-debuggable, R8 enabled, production signing — what goes to users
- applicationIdSuffix: lets debug and release be installed simultaneously on one device
- initWith(): inherit another build type's settings — avoids repeating release configuration
- BuildConfig fields: compile-time constants that differ per build type — API URLs, flags
"The staging build type is underused. Create one that inherits release config (R8 enabled, same optimisations) but uses staging server URLs and debug signing. This catches R8-related issues before they reach production — a bug that only appears in release is often an R8 stripping issue that staging would catch first."
6. What are product flavors in Android? How do they combine with build types?
Product flavors let you create multiple versions of your app from the same codebase — free vs premium, different regions, white-label variants. They combine with build types to create build variants (e.g. freeDebug, premiumRelease).
android { flavorDimensions +=listOf ("tier", "region") // must declare dimensions productFlavors { // TIER dimension create("free") { dimension = "tier" applicationIdSuffix = ".free" versionNameSuffix = "-free" buildConfigField("Boolean", "IS_PREMIUM", "false") resValue("string", "app_name", "\"MyApp Free\"") } create("premium") { dimension = "tier" applicationIdSuffix = ".premium" buildConfigField("Boolean", "IS_PREMIUM", "true") resValue("string", "app_name", "\"MyApp\"") } // REGION dimension create("india") { dimension = "region" buildConfigField("String", "CURRENCY", "\"INR\"") } create("global") { dimension = "region" buildConfigField("String", "CURRENCY", "\"USD\"") } } } // This creates 8 build variants (2 tiers × 2 regions × 2 build types): // freeIndiaDebug, freeIndiaRelease // freeGlobalDebug, freeGlobalRelease // premiumIndiaDebug, premiumIndiaRelease // premiumGlobalDebug, premiumGlobalRelease // Flavor-specific source sets — different code per flavor // src/free/java/ — free-only code // src/premium/java/ — premium-only code // src/main/java/ — shared code // Check flavor at runtime: if (BuildConfig.IS_PREMIUM) { showPremiumFeature() }
Product flavors let you build multiple distinct versions of your app from the same codebase. Common use cases: a free and a paid tier with different feature sets, a white-label app for multiple clients with different branding, or a consumer and enterprise variant with different authentication flows. Each flavor can override the applicationId (allowing both to be installed simultaneously), versionName, resource files, and source code files. Flavor dimensions group related flavors — a tier dimension (free, paid) combined with an environment dimension (dev, prod) produces four variant combinations automatically.
Build variants are the Cartesian product of flavors and build types. With two flavors (free, paid) and two build types (debug, release), you get four variants: freeDebug, freeRelease, paidDebug, paidRelease. Each variant has its own merged source set, its own APK output, and its own BuildConfig class with flavor-specific constants. CI typically builds all release variants: ./gradlew assembleFreeRelease assemblePaidRelease. Local development uses ./gradlew installFreeDebug for whichever variant the developer is working on. Android Studio's "Build Variants" panel lets you select the active variant for IDE features like code completion and resource resolution.
Flavor-specific source sets work like build type source sets but for flavors. src/paid/java/ contains source files that only exist in the paid variant — a PremiumFeatureActivity that the free variant never references. src/paid/res/ overrides resource values: a different app name, logo, or color palette for the paid variant. src/free/java/ can contain a stub implementation of a feature that the paid variant implements fully — this is the standard approach for feature gating without if (BuildConfig.FLAVOR == "paid") conditionals scattered throughout the code. The compiler only sees the source files relevant to the active variant, so unused code paths are simply absent rather than conditionally compiled.
- Flavor dimensions: required group for each flavor — can have multiple orthogonal dimensions
- Build variants: every combination of flavor + build type — flavors × build types = variants
- applicationIdSuffix: each flavor can have a different app ID — multiple variants installed side-by-side
- Source sets: flavor-specific code/resources in src/flavorName/ — different implementations per variant
- resValue: override string resources per flavor — different app names, API endpoints
"The killer use case for flavors: white-label apps. Same codebase, different logo, different colors, different API endpoints — each configured in a different flavor's source set and buildConfigField. One build system, 10 branded apps. Without flavors you'd maintain 10 separate codebases."
7. Scenario: Your APK is 80MB and the team wants it under 30MB. Walk through your reduction strategy.
APK size reduction is a systematic process — profile first with Android Size Analyzer, then attack the biggest contributors: native libraries (ABI splits), images (WebP), unused code (R8), and unused resources (isShrinkResources).
// Step 1: Profile — Android Studio → Build → Analyze APK // Shows breakdown: res/ classes.dex lib/ assets/ // Identify the biggest contributors before optimizing // Step 2: ABI Splits — biggest win for native libraries android { splits { abi { isEnable = true reset() include("arm64-v8a", "armeabi-v7a") // 98%+ of devices isUniversalApk = false // no universal APK } } } // arm64-v8a APK: 15MB vs universal (arm64+arm+x86): 45MB // Step 3: Switch to AAB — Play generates per-device APKs // ./gradlew bundleRelease (instead of assembleRelease) // Automatic ABI + density + language splits — same effect as step 2 // Step 4: Enable R8 + resource shrinking android { buildTypes { release { isMinifyEnabled = true isShrinkResources = true // removes unused drawables, layouts, strings } } } // Step 5: Convert PNG/JPG to WebP // Android Studio: right-click drawable → Convert to WebP // WebP lossy: 25-35% smaller than JPEG, near same quality // WebP lossless: ~26% smaller than PNG // Step 6: Remove unused language resources android { defaultConfig { resourceConfigurations +=setOf ("en", "hi") // only keep these languages } } // OkHttp ships 20+ language string files — this strips them to your 2 // Step 7: Vector drawables instead of PNGs for multiple densities android { defaultConfig { vectorDrawables.useSupportLibrary = true } } // One vector file replaces mdpi/hdpi/xhdpi/xxhdpi/xxxhdpi PNGs
APK size reduction is most effectively approached in order of impact. The highest-impact step is switching from APK to AAB — immediately reduces delivered size by 15–20% through automatic density and ABI splitting, with zero code changes. The second step is enabling R8: minifyEnabled = true and shrinkResources = true in your release build type. R8 shrinking removes unused code; resource shrinking removes unreferenced drawables, layouts, and strings. Together they typically reduce the DEX and resources by 30–50%. Third, convert PNG assets to WebP — lossless WebP is 26% smaller than PNG, and lossy WebP at quality 80 is typically 70% smaller than JPEG with comparable quality. Use Android Studio's built-in PNG-to-WebP converter on all existing assets.
The APK Analyzer (Build → Analyze APK) is the diagnostic tool for size reduction. It shows a breakdown by component: DEX files, resources, native libraries, assets, and manifest. Sort by size to find the biggest contributors. A DEX file over 5MB suggests unused library code that R8 should shrink — check whether consumer ProGuard rules from your dependencies are preventing removal. Native libraries are often the largest single component — if you have both arm64-v8a and armeabi-v7a libraries, consider dropping armeabi-v7a (32-bit ARM) for new apps since virtually all active Android devices support 64-bit. Filter to only 64-bit with abiFilters "arm64-v8a" in your defaultConfig.
Dependency auditing catches bloat that grows invisibly over time. Run ./gradlew app:dependencies to see the full dependency tree and identify libraries that are larger than expected or have large transitive dependencies. A library like com.google.guava:guava adds several MB of code that R8 should shrink, but if ProGuard rules keep large portions of it, the savings are smaller than expected. Use ./gradlew app:checkDebugDuplicateClasses to detect duplicate classes from multiple conflicting library versions. Dynamic Feature Modules are the architectural solution for very large apps — move rarely-used features (account settings, onboarding, help centre) into dynamic modules downloaded on demand, reducing the base install to only what every user needs immediately.
- Analyze APK first: find the real culprits — native libs, images, or unused library code
- AAB or ABI splits: single biggest size win — arm64 APK is 3x smaller than universal
- isShrinkResources: removes unused drawables/layouts — safe and automatic with R8
- WebP: 25-35% smaller than JPEG, lossless WebP beats PNG — supported since API 18
- resourceConfigurations: strip unused library language files — OkHttp alone has 20+ languages
"Priority order for APK reduction: (1) Switch to AAB — free 20-40% from Play optimisations. (2) Enable R8 + isShrinkResources — removes unused code and assets. (3) ABI filter to arm64+arm only. (4) WebP for large images. (5) resourceConfigurations for languages. Steps 1-3 alone typically get you from 80MB to 35MB without touching any assets."
8. What is KAPT vs KSP? Why is KSP faster and when should you migrate?
KAPT (Kotlin Annotation Processing Tool) compiles Kotlin to Java stubs before running annotation processors — a slow extra step. KSP (Kotlin Symbol Processing) processes Kotlin source directly — up to 2x faster and supports incremental processing.
// KAPT — old way (slow) // Kotlin source → Kotlin compiler → Java stubs → KAPT → annotation processor // Extra compilation step: generating Java stubs is slow (adds 30-60s to clean builds) // No incremental processing for many processors // build.gradle.kts with KAPT plugins { alias(libs.plugins.kotlin.kapt) } dependencies { kapt(libs.hilt.compiler) // ❌ KAPT — deprecated for Hilt kapt(libs.room.compiler) // ❌ KAPT — should migrate to KSP } // KSP — new way (fast) // Kotlin source → KSP → annotation processor (reads Kotlin AST directly) // No Java stub generation step // Incremental: only reprocesses changed files // KMP compatible: works in Kotlin Multiplatform plugins {alias(libs.plugins.ksp) } dependencies { ksp(libs.hilt.compiler) // ✅ KSP — 2x faster ksp(libs.room.compiler) // ✅ KSP — recommended for Room 2.6+ } // Libraries supporting KSP (2025): // ✅ Hilt — ksp("com.google.dagger:hilt-compiler") // ✅ Room — ksp("androidx.room:room-compiler") // ✅ Moshi — ksp("com.squareup.moshi:moshi-kotlin-codegen") // ✅ Kotlin Serialization — uses plugin, no KAPT/KSP needed // ⚠️ Dagger2 (standalone) — KSP support available but check version // Can't mix KAPT and KSP for the same library // Pick one processor per library — use KSP when supported // Benchmark (typical project): // KAPT clean build: 4 min // KSP clean build: 2.5 min (37% faster) // KSP incremental: 15 sec (stubs rebuilt only for changed files)
KAPT (Kotlin Annotation Processing Tool) is a compatibility shim that runs Java annotation processors against Kotlin code by first converting Kotlin stubs to Java, then running the Java annotation processor, then generating output. This two-step process adds significant overhead: stub generation runs on every build even when only unrelated code changed, and the processors themselves (Hilt, Room, Glide, Moshi) rerun their entire analysis. On large projects, KAPT can account for 30–60% of total build time. KSP (Kotlin Symbol Processing) is a Kotlin-first API that processes Kotlin symbols directly without the Java stub generation step, resulting in build time reductions of 25–50% for KSP-enabled processors.
KSP also enables better incremental processing. KAPT's incremental processing is coarse-grained — changing any annotated class can trigger reprocessing of all annotations. KSP's incremental processing is fine-grained: each processor declares which symbols it depends on, and KSP only reruns the processor for the specific symbols that changed. For a large codebase with hundreds of Room entities or Hilt modules, this means adding a new method to one DAO reprocesses only that DAO rather than rebuilding all generated Room code. The practical impact on CI build time is significant for large projects with many annotation-processed modules.
Migration from KAPT to KSP requires the library to support KSP — the annotation processor itself must be rewritten to use the KSP API instead of the Java APT API. As of 2025, all major Android libraries support KSP: Room, Hilt, Moshi (via moshi-kotlin-codegen), Glide, and Dagger all have KSP processors. Replace kapt("...") with ksp("...") for each dependency and change the plugin from id("kotlin-kapt") to id("com.google.devtools.ksp"). Libraries that still only support KAPT can coexist with KSP-migrated libraries — you can have both kapt and ksp in the same module during migration. Once all processors are migrated, remove KAPT entirely to eliminate its overhead completely.
- KAPT: generates Java stubs then runs Java annotation processors — extra slow compilation step
- KSP: reads Kotlin source directly — no stubs, 2x faster on clean builds
- Incremental: KSP only reprocesses files that changed — huge win on incremental builds
- KMP support: KSP works in Kotlin Multiplatform, KAPT doesn't
- Migrate now: Room 2.6+, Hilt — both fully support KSP with no functionality loss
"Migration from KAPT to KSP is one of the highest-ROI build improvements you can make — a 30-60 second saving on every clean build, and dramatically faster incremental builds. The migration is usually just changing kapt() to ksp() in dependencies and updating the plugin. Do it for Room and Hilt first — those two account for most annotation processing time."
9. What is the Version Catalog (libs.versions.toml)? Why is it better than declaring dependencies inline?
Version Catalog centralises all dependency versions in a single TOML file — no more hunting across 10 build.gradle files when upgrading a library. It also enables type-safe accessors (libs.retrofit instead of string literals) and IDE autocomplete.
// gradle/libs.versions.toml — single source of truth [versions] kotlin = "2.1.0" compose-bom = "2024.12.01" hilt = "2.51.1" room = "2.6.1" retrofit = "2.11.0" [libraries] hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" } room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } [plugins] android-application = { id = "com.android.application", version = "8.7.3" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } ksp = { id = "com.google.devtools.ksp", version = "2.1.0-1.0.29" } // In build.gradle.kts — type-safe, IDE autocomplete dependencies { implementation(libs.hilt.android) // ✅ type-safe accessor ksp(libs.hilt.compiler) implementation(libs.retrofit) } // Before (string literals — typo-prone, no autocomplete): // implementation("com.google.dagger:hilt-android:2.51.1") // Upgrade a library: change ONE version in [versions], all modules pick it up // hilt = "2.51.1" → "2.52" → all 3 hilt dependencies updated atomically
Version Catalog centralises all dependency declarations in a single libs.versions.toml file in the root of your project. This file has four sections: [versions] for version strings, [libraries] for individual dependencies, [bundles] for groups of related dependencies, and [plugins] for Gradle plugins. In build scripts, you reference these via the generated type-safe accessors: implementation(libs.retrofit.core) instead of implementation("com.squareup.retrofit2:retrofit:2.9.0"). The generated accessors get IDE autocompletion and compile-time validation — a typo in a dependency coordinate is a build error, not a silent runtime failure.
The critical advantage over hardcoded version strings is single-point-of-truth version management across modules. In a multi-module project without a version catalog, updating Retrofit requires finding and changing the version string in every module's build.gradle — often inconsistently, leading to modules using different versions and potential runtime conflicts. With a version catalog, changing retrofit = "2.11.0" in one place updates the version for every module that uses libs.retrofit.core. Dependabot and Renovate Bot both understand the TOML format and can automatically open PRs to update dependency versions, keeping your dependency tree current without manual auditing.
The [bundles] section reduces boilerplate for groups of dependencies that are always used together. A [bundles] compose = ["compose-ui", "compose-material3", "compose-preview", "compose-activity"] bundle lets you write implementation(libs.bundles.compose) instead of four separate lines. This is particularly useful for Compose, Ktor client, or Arrow dependencies that have multiple artifacts all updated together. The bundle is still resolved to individual artifacts at build time — it's purely a convenience layer in the build script, with no impact on how Gradle resolves or downloads dependencies. Bundles also make it obvious which libraries form a coherent group, improving readability for new team members.
- Single source of truth: all versions in one file — no hunting across module build scripts
- Type-safe accessors: libs.hilt.android instead of string literals — IDE autocomplete, typos caught at build time
- Atomic upgrades: change version once → all modules using that library update together
- Bundles: group related dependencies — libs.bundles.room includes room-runtime + room-ktx
- Dependency updates: Renovate/Dependabot can auto-update the TOML file via PRs
"Before Version Catalog, upgrading Retrofit meant finding every 'com.squareup.retrofit2:retrofit:2.x.x' string across 8 build files, updating each, hoping you got them all. With Version Catalog: change one line in libs.versions.toml, every module picks it up. The type-safe accessor also means you can't accidentally reference a non-existent library — it's a compile error."
10. Scenario: Your CI build takes 20 minutes. How do you systematically reduce Gradle build time?
Slow builds kill developer productivity. The fix is a combination of Gradle caching, configuration cache, parallel execution, and KSP — each targeting a different bottleneck in the build pipeline.
// gradle.properties — the most impactful config file org.gradle.jvmargs=-Xmx4g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 org.gradle.caching=true // reuse outputs from previous builds org.gradle.parallel=true // build independent modules in parallel org.gradle.configureondemand=true // only configure modules needed for the task org.gradle.configuration-cache=true // cache build configuration (Gradle 8+) kotlin.incremental=true // only recompile changed Kotlin files ksp.incremental=true // only reprocess changed KSP inputs android.enableR8.fullMode=true // faster, smaller R8 in release // Profile build with --scan // ./gradlew assembleDebug --scan // Generates a Gradle Enterprise report — shows where time is spent // Key optimizations: // 1. KAPT → KSP (saves 30-60s per clean build) // 2. Gradle build cache (saves 40-80% on CI — reuses unchanged module outputs) // 3. Configuration cache (saves 30s+ — skips re-evaluating build scripts) // 4. Parallel builds — multi-module projects build independent modules simultaneously // 5. Modularisation — smaller modules = faster incremental builds // Change in :feature:profile only rebuilds :feature:profile, not :feature:home // 6. Avoid transitive dependency leakage // implementation() — dependency not exposed to consumers (faster compilation) // api() — exposed to consumers (forces recompilation of dependents) // Rule: use implementation() everywhere — only api() when truly needed // 7. Disable unused features in debug android { buildTypes { debug { splits.abi.isEnable = false // no ABI splits in debug } } } // Typical results after all optimizations: // Clean build: 20 min → 8 min // Incremental build: 3 min → 30 sec
Build time profiling starts with ./gradlew assembleDebug --profile, which generates an HTML report in build/reports/profile/ showing task execution times and configuration times. Sort by duration to find the slowest tasks. The configuration phase is often the first bottleneck on large projects — if configuration takes 30+ seconds, the culprit is usually build scripts doing I/O (reading files, making network calls) during configuration rather than during execution, or a large number of modules each registering many tasks eagerly. Enable the configuration cache (org.gradle.configuration-cache=true) to skip the configuration phase entirely on subsequent builds when inputs haven't changed.
Incremental compilation and the build cache eliminate redundant work. The Gradle build cache stores task outputs keyed by their inputs — if you run compileDebugKotlin on machine A and push to CI, machine B can pull the cached output from the remote build cache without recompiling. Set up a shared remote build cache server (Gradle Enterprise, or a simple HTTP cache) and configure all CI machines to use it: buildCache { remote(HttpBuildCache) { url = "https://cache.yourcompany.com/cache/"; push = isCI } }. Cache hit rates above 70% are achievable for large projects and can reduce CI build time by 40–60%. Ensure your custom tasks declare @Input and @OutputFiles annotations correctly — undeclared inputs prevent caching and cause incorrect incremental results.
Parallel execution and module parallelism are the architectural optimisations. Enable org.gradle.parallel=true to allow independent modules to build concurrently on multi-core CI machines. The speedup is proportional to the degree of parallelism in your module dependency graph — a linear chain of modules (each depending on the previous) cannot be parallelised, while a wide flat graph with many leaf modules benefits dramatically. Refactoring a monolithic app module into independent feature modules is therefore both an architectural improvement and a build performance optimisation. Run ./gradlew :app:dependencies to visualise your dependency graph and identify opportunities to break dependency chains that prevent parallel compilation.
- Gradle build cache: reuses task outputs — unchanged modules never recompiled
- Configuration cache: caches the build graph — skips script evaluation on repeated builds
- Parallel execution: independent modules built concurrently — critical for multi-module apps
- KAPT→KSP: annotation processing 2x faster — single largest win for annotation-heavy projects
- implementation() over api(): limits recompilation cascade — change in a module doesn't force all dependents to recompile
"Build time ROI order: (1) Gradle build cache — free, enable it, huge CI win. (2) KAPT→KSP — one-time migration, saves time on every build forever. (3) Configuration cache — needs compatibility fixes but saves 30s+ per build. (4) implementation() over api() — simple discipline, prevents recompilation cascades. Profile first with --scan to know where your time actually goes."
11. What is the difference between implementation, api, compileOnly, and runtimeOnly in Gradle?
These configurations control how dependencies are exposed to other modules and when they're included in the classpath. Choosing wrong causes compilation errors, build slowdowns, or bloated APKs.
// implementation — private dependency (default choice) implementation(libs.retrofit) // ✅ Available at compile AND runtime in THIS module // ❌ NOT visible to modules that depend on THIS module // ✅ Faster builds — consumer modules don't recompile when this changes // api — public dependency (use sparingly) api(libs.retrofit) // ✅ Available at compile AND runtime, also exposed to consumers // ❌ Slower builds — changing this forces all consuming modules to recompile // Use for: types in your public API surface that consumers need // Example: :core:network exposes Retrofit types that :feature:home uses in its API // compileOnly — compile time only, not bundled in APK compileOnly(libs.javax.annotation) // ✅ Used for annotations/stubs needed at compile time (e.g., Lombok, JSR-305) // ❌ Not available at runtime — app crashes if you try to use it // Use for: annotation processors, APIs provided by the runtime environment // runtimeOnly — runtime only, not needed at compile time runtimeOnly(libs.slf4j.simple) // ❌ Not on compile classpath — can't import or reference it directly // ✅ Available at runtime (needed for service discovery, SPI, logging backends) // Use for: logging implementations, JDBC drivers, plugin implementations // Real example — multi-module // :core:network module dependencies { implementation(libs.okhttp) // internal impl — feature modules don't see OkHttp api(libs.retrofit) // exposed — feature modules use Retrofit API types } // :feature:home module can use Retrofit (transitive via api) // :feature:home cannot use OkHttp directly (hidden by implementation)
The dependency configuration determines the visibility of the dependency to consuming modules. implementation adds the dependency to the compile classpath of the current module only — modules that depend on this module do not see the dependency transitively. This is the correct choice for most dependencies: Retrofit, Room, Coroutines. api exposes the dependency to all consuming modules — use it only when the dependency's types appear in the public API of your module (a method that returns a Flow<T> from the Coroutines library requires api("org.jetbrains.kotlinx:kotlinx-coroutines-core") in a library module). Overusing api increases compilation scope and slows incremental builds.
compileOnly adds the dependency to the compile classpath but not the runtime classpath — it is present during compilation but not packaged into the output. This is used for annotation processors whose runtime is not needed (only their generated code is), and for APIs that are guaranteed to be present at runtime from the platform (like the Android SDK itself). runtimeOnly is the inverse — present at runtime but not during compilation. A common use case is logging backends: you compile against an SLF4J API (implementation) and add a specific backend like Logback as runtimeOnly — your code compiles against the API but the actual implementation is resolved at runtime.
Understanding dependency scopes prevents both compile errors and APK bloat. A dependency accidentally declared as api instead of implementation exposes internal types to all consuming modules — other modules start accidentally using types they shouldn't, creating tight coupling. A dependency declared as implementation when it should be api causes compile errors in consuming modules that use the leaked type: error: cannot find symbol class RetrofitClient. Audit your dependency configurations with ./gradlew :module:dependencies and look for any api declarations that could be implementation — changing them to implementation reduces the compilation scope and speeds incremental builds for all downstream modules.
- implementation: the default — keeps dependency private, faster incremental builds
- api: exposes transitive dependency — only when consumers genuinely need the types
- compileOnly: annotations and stubs only — reduces APK size, crashes if used at runtime
- runtimeOnly: service implementations — logging backends, JDBC drivers, plugins
- Build speed: prefer implementation everywhere — api forces consumer recompilation on any change
"The rule: start with implementation for everything. Upgrade to api only when you get a compilation error in a consumer module that says it can't find a type from your module's dependency. api() should be rare — it means 'I'm intentionally making this part of my public API'. Overusing api() is one of the top causes of slow incremental builds."
12. What are Convention Plugins (build-logic)? How do they eliminate build script duplication in multi-module projects?
Convention Plugins are reusable Gradle plugins written in your project that standardise build configuration across modules. Instead of copy-pasting the same android {} block into 20 modules, you apply one plugin that contains all the shared config.
// Problem: 20 feature modules each have the same 50-line android {} block // Change compileSdk → edit 20 files. Add a lint rule → edit 20 files. // Solution: build-logic/convention/src/main/kotlin/ // AndroidFeatureConventionPlugin.kt class AndroidFeatureConventionPlugin : Plugin<Project> { override fun apply(target: Project) { with(target) { pluginManager.apply {apply ("com.android.library")apply ("org.jetbrains.kotlin.android")apply ("com.google.devtools.ksp") }extensions .configure <LibraryExtension> { compileSdk = 35 defaultConfig { minSdk = 24; testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 } }dependencies { add("implementation",libs .findLibrary ("hilt.android").get ()) add("ksp",libs .findLibrary ("hilt.compiler").get ()) } } } } // build-logic/convention/build.gradle.kts gradlePlugin { plugins { register("androidFeature") { id = "myapp.android.feature" implementationClass = "AndroidFeatureConventionPlugin" } } } // Each feature module's build.gradle.kts — just 3 lines plugins {alias(libs.plugins.myapp.android.feature) } dependencies { implementation(project(":core:data")) // only module-specific deps here } // compileSdk, minSdk, Hilt, KSP — all handled by the plugin
Convention Plugins extract repeated build configuration into reusable Gradle plugins that live in a build-logic module. Without them, each module's build.gradle.kts contains 40–80 lines of identical boilerplate: the same Android namespace configuration, the same Kotlin JVM target, the same test options, the same lint rules, the same R8 settings. When you want to change the minimum SDK, you edit every module's build script individually — tedious, error-prone, and a constant source of inconsistency. A Convention Plugin encapsulates this shared configuration: id("com.example.android.library") in a module's build script applies all standard library configuration in one line.
The build-logic module is itself a Gradle project that produces plugins. It lives at the root of the repository alongside app/ and feature modules. Its src/main/kotlin/ contains plugin files that end in .gradle.kts or implement Plugin<Project>. These plugins use the standard Gradle and AGP APIs: extensions.configure<ApplicationExtension> { } to configure Android, dependencies { } to add common test dependencies. The build-logic project's own build.gradle.kts declares gradlePlugin { plugins { } } to register each plugin with its ID. Modules reference them with plugins { id("com.example.android.library") } — exactly like any other Gradle plugin.
Convention Plugins also enforce team-wide standards non-negotiably. If your plugin applies a custom Lint baseline and lint rules, every module using the plugin gets those rules automatically — a developer cannot "forget" to add lint to their module. If your plugin configures a specific detekt or ktlint version, every module uses the same version. Changes to shared configuration — bumping the Kotlin JVM target from 11 to 17, enabling a new compiler flag — require a single change in the plugin rather than dozens of changes across modules. For large teams, this is the difference between a build system that drifts towards inconsistency over time and one that consistently enforces the same standards across all modules.
- Convention Plugin: a Gradle plugin in your project — encapsulates shared build config
- Single change: update compileSdk in the plugin → all 20 modules updated instantly
- Enforces standards: every module gets the same lint rules, test runner, Java version
- Follows Now in Android: Google's reference architecture uses this pattern exactly
- build-logic module: lives in build-logic/ — a composite build included in settings.gradle.kts
"Convention plugins are the answer to 'how do you maintain 20 feature modules without duplication?' Each module's build.gradle.kts is 5-10 lines — just plugins{} and module-specific dependencies. All shared config (Android SDK, Kotlin, Hilt, testing) lives in the convention plugin. This is exactly how Google's Now in Android reference app is structured."
13. What are compileSdk, minSdk, and targetSdk? What happens if you set them wrong?
These three SDK values control what APIs you can use, what Android versions can install your app, and how the OS handles your app's behaviour. Getting them wrong causes either app crashes or Play Store policy violations.
android { compileSdk = 35 // SDK used to COMPILE your code defaultConfig { minSdk = 24 // MINIMUM Android version that can install your app targetSdk = 35 // Android version you've TESTED against (affects OS behaviour) } } // compileSdk — "which APIs can I write code with?" // compileSdk = 35 → can use APIs introduced up to Android 15 // compileSdk = 30 → using a new API from API 33 = compile error // Rule: always set to latest SDK — doesn't affect what devices can run your app // minSdk — "who can install my app?" // minSdk = 24 → Android 7.0+ can install (covers 99%+ of active devices in 2025) // minSdk = 21 → Android 5.0+ (adds 0.1% more devices, significant compat work) // Calling API 26 on a device running API 24 → crash! // Fix: @RequiresApi(26) + check at runtime: if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { /* API 26+ code */ } // targetSdk — "what behaviour changes do I accept?" // Android makes behaviour changes per targetSdk // targetSdk = 33 → app is subject to Android 13 behaviour changes (storage, permissions) // targetSdk = 34 → subject to Android 14 changes (foreground service types required) // targetSdk < 34 → Play Store rejected apps targeting old targetSdk (policy deadline) // Rule: always keep targetSdk = latest to stay compliant with Play policy // Lint warning: using new API without version check // @SuppressLint("NewApi") — suppress when you've checked manually // @RequiresApi(Build.VERSION_CODES.O) — annotate your own methods that need newer APIs
compileSdk determines which Android APIs are available during compilation — setting it to 35 means you can call APIs introduced in Android 15 without compile errors. minSdk is the lowest Android version your app officially supports — calls to APIs newer than minSdk that are not version-guarded cause a Lint error (NewApi) and a runtime crash on older devices. targetSdk tells Android that your app has been tested and is compatible with the declared Android version, opting into new behaviour changes introduced in that version. A lower targetSdk keeps older behaviour but Google Play requires targeting within two API levels of the current release, or the app becomes invisible to new users on current Android versions.
The interaction between these three values matters in practice. You should always set compileSdk to the latest stable API level — this enables the latest APIs and lint checks without affecting which devices your app runs on. Set minSdk based on your user base analytics — if 95% of your users run Android 8+ (API 26), there is no reason to support API 21. Each API level you drop raises the minimum expands your potential install base slightly but adds engineering overhead to test on and work around older OS behaviour. targetSdk should match compileSdk — once you have tested your app against the new Android version's behaviour changes, raise targetSdk to opt into the new behaviour and meet Play Store requirements.
Setting targetSdk too low causes specific behaviour regressions on newer Android versions. Below API 29: the app uses a legacy storage model (pre-Scoped Storage). Below API 31: foreground service restrictions from Android 12 don't apply, but neither do optimisations like the exact alarm permission change. Below API 33: the new per-permission notification model doesn't apply — users are never asked to grant notification permission, and the app may receive a warning banner in the notification shade on Android 13+ devices. Each Android release's migration guide documents which behaviour changes are tied to targetSdk versus unconditional — read it before raising targetSdk to understand what needs testing and potential code changes.
- compileSdk: what APIs you can write with — always latest, doesn't affect runtime
- minSdk: who can install your app — balance coverage vs compatibility effort
- targetSdk: tells the OS which behaviour changes you've adapted to — must keep current
- Play Store policy: Google requires targetSdk within 1 year of latest release — keep it updated
- Version check: always guard new APIs with Build.VERSION.SDK_INT >= check at runtime
"The conceptual model: compileSdk is your toolkit at compile time. minSdk is your audience at runtime. targetSdk is your contract with the OS about which behaviour changes you've adopted. Developers often confuse compileSdk and targetSdk — they can differ. You might compile with SDK 35 but target SDK 34 while testing the SDK 35 behaviour changes."
14. Scenario: Your release app crashes but debug works fine. How do you diagnose R8-related crashes?
Debug-works-release-crashes is almost always an R8 issue — something got stripped or renamed. The diagnosis is systematic: check the stack trace, use the mapping file, add keep rules, and narrow down the culprit.
// Step 1: Reproduce with minified build locally // Create a build type that has R8 but uses debug signing (easier to install) buildTypes { create("minifiedDebug") { initWith(getByName ("debug")) isMinifyEnabled = true // enable R8 proguardFiles(getDefaultProguardFile ("proguard-android-optimize.txt"), "proguard-rules.pro") signingConfig = signingConfigs.getByName ("debug") // debug signing — easy install } } // Step 2: Get and deobfuscate the stack trace // mapping.txt from the release build → retrace tool // ./gradlew assembleRelease // mapping.txt: app/build/outputs/mapping/release/mapping.txt // java -jar retrace.jar mapping.txt stacktrace.txt → readable stacktrace // Step 3: Common R8 crash types and fixes // ClassNotFoundException — class was stripped // Crash: java.lang.ClassNotFoundException: com.example.MyCallback -keep class com.example.MyCallback { *; } // NoSuchMethodException — method was removed // Crash: Method not found: onSuccess -keepclassmembers class com.example.** { public void onSuccess(...); public void onFailure(...); } // NullPointerException from Gson — field removed // Gson reads via reflection — R8 removes "unused" fields -keepclassmembers class com.example.models.** { *; } // Step 4: Use -printusage to see what was removed // proguard-rules.pro: -printusage build/outputs/usage.txt // After release build, check if your class appears in usage.txt (= was removed) // Step 5: Verify library consumer rules are applied // Libraries should ship their own .pro rules in META-INF/proguard/ // If library has outdated rules, file a bug and add manual rules
Release-only crashes almost always fall into one of four categories: R8 removing or renaming classes needed at runtime, missing native libraries or mismatched ABI splits, incorrect signing configuration causing certificate validation failures, or BuildConfig values (API keys, endpoints) that differ between debug and release. The first diagnostic step is to check the release build's crash log carefully — an InvocationTargetException or ClassNotFoundException on a class with an obfuscated name (like a.b.c) points to R8 shrinking; a java.lang.UnsatisfiedLinkError points to a missing native library.
Reproduce the crash by building a release APK with ./gradlew assembleRelease and installing it locally — never wait for Play Store upload to test release behaviour. If R8 is the cause, deobfuscate the stack trace using the mapping file: Android Studio's "Analyze Stack Trace" dialog can do this automatically if you point it at the mapping.txt from the release build. Add -keep rules for any class that appears as a ClassNotFoundException. For Gson/Moshi model classes, ensure all serialised models are annotated with @Keep or have corresponding keep rules — this is the most common cause of release-only crashes in apps that recently enabled R8.
The safest development practice is to run automated tests against the release build in CI, not just the debug build. Add a CI step that assembles the release APK and runs your instrumented test suite against it — most release-only R8 bugs are caught immediately by tests that exercise the obfuscated code paths. Enable -verbose in your ProGuard rules to get detailed R8 output showing every removal decision. For apps with complex native library configurations, test every supported ABI explicitly in CI by filtering abiFilters to each ABI separately and verifying the app starts correctly. A "release smoke test" step that installs the release APK on an emulator and runs a simple startup test costs 3 minutes in CI and prevents an entire class of production incidents.
- minifiedDebug build type: reproduce R8 issues locally without dealing with release signing
- retrace: Google's tool to de-obfuscate crash stack traces using mapping.txt
- -printusage: generates a file listing everything R8 removed — find your missing class
- Gson + R8: the most common culprit — Gson reads fields via reflection, R8 removes "unused" fields
- Consumer rules: check META-INF/proguard/ in the library AAR — library should ship its own rules
"The diagnostic flowchart: (1) Get stack trace → deobfuscate with mapping.txt → (2) See ClassNotFoundException? Add -keep. See NPE? Probably Gson stripping fields → -keepclassmembers. See MethodNotFoundException? Add keep for that method. (3) If you can't reproduce, add a minifiedDebug build type — never debug R8 issues by releasing to production."
15. What is versionCode vs versionName? How do you automate version management?
versionCode is an integer the Play Store uses to determine update ordering — must increase with every release. versionName is the human-readable string shown to users. Automating both prevents manual errors and ties releases to your CI pipeline.
android { defaultConfig { versionCode = 42 // integer, must increase each release versionName = "2.1.0" // string, shown to users } } // versionCode rules: // • Must be a positive integer // • Must be higher than the previous release (Play rejects downgrades) // • Max value: 2,100,000,000 // • Not shown to users (only versionName is visible) // Automation: read versionCode from CI environment // CI systems expose a build number (GitHub Actions: GITHUB_RUN_NUMBER) val ciVersionCode = System.getenv ("GITHUB_RUN_NUMBER")?.toIntOrNull () ?: 1 android { defaultConfig { versionCode = ciVersionCode versionName = "2.1.$ciVersionCode" } } // Each CI run → unique, incrementing versionCode — no manual tracking // Git-based versioning val gitVersionCode = "git rev-list --count HEAD".execute ().text ().trim ().toInt () val gitVersionName = "git describe --tags --always".execute ().text ().trim () android { defaultConfig { versionCode = gitVersionCode // total commit count — always increases versionName = gitVersionName // "v2.1.0-3-gabcdef" from git tag } } // Tie version to git tag → release v2.1.0 → tag "v2.1.0" → versionName = "v2.1.0" // ABI-based versionCode (for APK splits) // arm64: 2001000, arm: 1001000 — ensures correct update ordering per ABI val abiCodes =mapOf ("armeabi-v7a" to 1, "arm64-v8a" to 2)
versionCode is an integer used by Android and Play Store to determine update ordering — a higher versionCode is treated as a newer version. Play Store rejects uploads with a versionCode equal to or lower than any existing version. versionName is the human-readable string displayed to users ("2.1.4") and carries no technical significance — it does not affect update ordering, only user perception. The two values must be managed together: bumping versionName without bumping versionCode appears as the same build to the Play Store even if the content changed.
Manual version management is error-prone at scale — developers forget to bump the version code before release, or bump it inconsistently between build variants. The standard automation approach is to derive versionCode from the CI build number: versionCode = System.getenv("CI_BUILD_NUMBER")?.toInt() ?: 1. Every CI build gets a unique, monotonically increasing version code automatically. For multi-flavor apps with separate tracks (free and paid in different Play Store listings), encode the flavor in the version code: versionCode = buildNumber * 10 + flavorOffset ensures version codes never conflict between flavors. Semantic versioning for versionName (MAJOR.MINOR.PATCH) is conventional; automate it from git tags using the com.gladed.androidgitversion Gradle plugin.
Play Store's track system (internal, alpha, beta, production) uses versionCode to control promotion between tracks. A build promoted from beta to production uses the same APK/AAB — the versionCode doesn't change on promotion. This means version codes must be reserved for release builds only, not debug builds — if your debug APK has versionCode 1000 and your release APK also has versionCode 1000, Play Store will not accept the release upload. The clean approach is to only set a meaningful versionCode for release build types: if (buildType.name == "release") { versionCode = ciNumber } else { versionCode = 1 }.
- versionCode: integer only — Play Store enforces it must increase for each release
- versionName: any string — semantic versioning (2.1.0) is conventional
- CI automation: GITHUB_RUN_NUMBER is always increasing — perfect versionCode source
- Git tag versioning: git describe produces human-readable version from tags + commits
- ABI versionCodes: for APK splits, offset by ABI priority to ensure correct update path
"Manual versionCode management is a time bomb — eventually someone uploads a lower versionCode to Play and it's rejected. Automate it: use CI build number or git commit count. Both are monotonically increasing and never require human attention. Tie versionName to git tags so your crash reports show 'v2.1.0' not '42'."
16. Scenario: Set up a complete signing configuration for release builds without hardcoding credentials in build files.
Signing credentials (keystore password, key password) must never be committed to source control. The correct approach uses environment variables on CI and a local properties file for developers — both kept out of git.
// .gitignore — NEVER commit these keystore/release.jks keystore.properties local.properties // keystore.properties — local developer file (git-ignored) KEYSTORE_PATH=../keystore/release.jks KEYSTORE_PASSWORD=myKeystorePassword KEY_ALIAS=myKeyAlias KEY_PASSWORD=myKeyPassword // app/build.gradle.kts — read from properties OR environment variables import java.util.Properties val keystoreFile =rootProject .file ("keystore.properties") val keystoreProps = if (keystoreFile.exists ()) { Properties().apply {load (keystoreFile.inputStream ()) } } else null android { signingConfigs { create("release") { // Try local properties first, fall back to environment variables (CI) storeFile =file (keystoreProps?.getProperty ("KEYSTORE_PATH") ?: System.getenv ("KEYSTORE_PATH") ?: return@create) storePassword = keystoreProps?.getProperty ("KEYSTORE_PASSWORD") ?: System.getenv ("KEYSTORE_PASSWORD") keyAlias = keystoreProps?.getProperty ("KEY_ALIAS") ?: System.getenv ("KEY_ALIAS") keyPassword = keystoreProps?.getProperty ("KEY_PASSWORD") ?: System.getenv ("KEY_PASSWORD") } } buildTypes { release { signingConfig = signingConfigs.getByName ("release") } } } // CI (GitHub Actions): // Store keystore as base64 secret // Store passwords as GitHub Secrets // Decode keystore to file, set env vars, run ./gradlew bundleRelease // - name: Decode keystore // run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > keystore/release.jks
Android release signing requires a keystore file containing a private key. The signing configuration in build.gradle.kts references the keystore path, alias, and passwords. Hardcoding these values in the build script is a security vulnerability — the keystore password in plain text in version control gives any repository reader the ability to sign APKs as your app. The correct approach is to read signing credentials from environment variables: storePassword = System.getenv("KEYSTORE_PASSWORD") ?: "". Local developers set these environment variables in their shell profile; CI sets them as protected secrets in the CI configuration (GitHub Actions secrets, GitLab CI/CD variables, etc.).
The keystore file itself must never be committed to version control, but it must be accessible to CI. The standard solutions are: store the keystore as a base64-encoded secret in CI and decode it to a temporary file during the build, or store it in a secrets manager (AWS Secrets Manager, HashiCorp Vault, Google Secret Manager) and fetch it during CI setup. The base64 approach: echo "$KEYSTORE_BASE64" | base64 --decode > keystore.jks in a CI step before the build. Clean up the decoded file in a post-build step. Never let the keystore land in build artifacts or caches.
Key rotation policy and backup are as important as the initial setup. Android signing keys are permanent — if your upload key is compromised or lost and you haven't enrolled in Play App Signing, you cannot update your app on the Play Store (you would need a new package name and start fresh). Enrol in Play App Signing (mandatory for new apps since August 2021) — Google stores the app signing key and you only manage the upload key. If your upload key is compromised, Google can rotate it. Back up both the keystore file and the passwords in at least two separate secure locations (encrypted password manager plus offline secure storage). Document the key alias name, creation date, and expiry in your team's internal documentation — keys last for 25 years but team members change.
- Never in source control: keystore file and passwords in .gitignore — non-negotiable
- Dual source: local keystore.properties for developers, env vars for CI — same build script
- Base64 keystore in CI: encode keystore to base64, store as GitHub Secret, decode on CI runner
- return@create: skip signing config if credentials aren't available — prevents debug build failures
- Separate keystores: use different keystores for debug and release — debug keystore auto-generated
"If signing credentials are ever committed to git, rotate them immediately — even in a private repo. The pattern: developers have a local keystore.properties (git-ignored), CI has environment variables from GitHub Secrets. The build script reads from either source. The keystore file itself is stored encrypted in a password manager and shared via secure channels, never git."
17. What is BuildConfig? What can you store in it and how do you access it?
BuildConfig is a generated Java class that Gradle creates at build time. It contains compile-time constants that differ per build type or flavor — like API URLs, feature flags, and debug modes. It's how you inject configuration without hardcoding it in source code.
// Enable BuildConfig generation (required in AGP 8+) android { buildFeatures { buildConfig = true // opt-in since AGP 8.0 } } // Built-in fields (always present): BuildConfig.DEBUG // Boolean — true in debug, false in release BuildConfig.APPLICATION_ID // "com.example.app" BuildConfig.BUILD_TYPE // "debug" / "release" / "staging" BuildConfig.FLAVOR // "free" / "premium" BuildConfig.VERSION_CODE // Int BuildConfig.VERSION_NAME // "1.0.0" // Custom fields — defined in build.gradle.kts android { defaultConfig { buildConfigField("String", "API_URL", "\"https://api.example.com\"") buildConfigField("Boolean", "ANALYTICS", "true") buildConfigField("int", "MAX_RETRIES", "3") } buildTypes { debug { buildConfigField("String", "API_URL", "\"https://staging.api.example.com\"") buildConfigField("Boolean", "ANALYTICS", "false") } } } // Access in Kotlin: val apiUrl = BuildConfig.API_URL if (BuildConfig.DEBUG) { Timber.plant (Timber.DebugTree()) } if (!BuildConfig.ANALYTICS) { analytics.disable () } // ⚠️ Don't store secrets in BuildConfig! // BuildConfig values are embedded in the APK and readable via reverse engineering // ❌ buildConfigField("String", "API_KEY", "\"secret123\"") — visible in decompiled APK // ✅ Store API keys in backend or Android Keystore for sensitive values
BuildConfig is a generated Java class that Gradle creates for each build variant. It always contains: DEBUG (boolean), APPLICATION_ID (String), BUILD_TYPE (String), FLAVOR (String), VERSION_CODE (int), and VERSION_NAME (String). You can add custom fields via buildConfigField("String", "API_URL", ""https://api.example.com"") in your build type or flavor configuration. These fields are evaluated at build time and baked into the class — they are compile-time constants that R8 can inline and use to eliminate dead code branches at the bytecode level.
The most powerful use of BuildConfig is environment switching. Define API_URL differently per build type: debug points at https://staging.api.example.com, release points at https://api.example.com. R8 sees if (BuildConfig.DEBUG) branches as dead code in release builds and removes them entirely — debug-only code paths (logging, shake-to-open debug menu, fake data generators) are physically absent from the release APK, not just conditionally skipped. This means debug tooling has zero performance cost in production: the code simply doesn't exist in the release binary.
As of AGP 8.0, BuildConfig generation is disabled by default for library modules (android.buildFeatures.buildConfig = false is now the default). This is a build optimisation — library modules rarely need BuildConfig, and generating it for every module adds compilation overhead. If a library module genuinely needs variant-specific constants, opt in explicitly with buildFeatures { buildConfig = true }. For sensitive values like API keys, prefer reading them at runtime from a secure source (EncryptedSharedPreferences, Android Keystore) rather than baking them into BuildConfig — values in BuildConfig are visible in the compiled APK via tools like apktool and jadx, even in release builds.
- Generated at build time: BuildConfig class is created by Gradle — not in your source code
- buildConfig=true: required since AGP 8.0 — opt-in to keep build fast if not using it
- Build-type-specific: debug overrides defaultConfig values — different API URLs per environment
- Don't store secrets: BuildConfig values are in the APK in plain text — visible in decompiled apps
- Common uses: API URLs, feature flags, analytics toggles, debug mode checks
"BuildConfig.DEBUG is the most useful field — it's true exactly in debug builds and false in release, automatically. Use it to plant Timber.DebugTree() and enable logging/StrictMode. The security caveat: BuildConfig values ARE visible in a decompiled APK — use it for URLs and config, not for secrets. Real secrets belong in the backend or encrypted local storage."
18. What are Dynamic Feature Modules? When should you use them?
Dynamic Feature Modules let you deliver parts of your app on-demand — downloaded only when the user needs that feature. This reduces install size and startup time, but adds delivery complexity. Best for large features rarely used by all users.
// Dynamic Feature Module — downloaded after install // Example: AR product viewer — needed by 5% of users // ar/build.gradle.kts plugins {alias(libs.plugins.android.dynamic.feature) } android { // No applicationId — it inherits from :app } dependencies { implementation(project(":app")) // depends on base module implementation(libs.arcore) } // app/build.gradle.kts — declare the dynamic feature android { dynamicFeatures += setOf (":ar") } // Download and install the feature at runtime val splitInstallManager = SplitInstallManagerFactory.create (context) val request = SplitInstallRequest.newBuilder () .addModule ("ar") // module name matches build.gradle.kts directory name .build() splitInstallManager. startInstall (request) .addOnSuccessListener { // Module installed — now safe to use AR classes val intent = Intent().setClassName (packageName, "com.example.ar.ArActivity")startActivity (intent) } .addOnFailureListener { e -> showError(e) } // ✅ Good candidates for dynamic delivery: // - AR / VR features (large native libs) // - High-res asset packs (game levels, textures) // - Rarely-used features (accessibility tools, admin panel) // - Region-specific features (payments only in certain countries) // ❌ Bad candidates: // - Core navigation (user immediately needs it) // - Authentication (always needed) // - Small features (overhead not worth it)
Dynamic Feature Modules (DFM) allow parts of your app to be downloaded on demand rather than bundled in the base APK. A user who never uses the AR camera feature doesn't need to download the ARCore integration code and assets. A user who never accesses the help centre doesn't need the 2MB of help content. DFMs work exclusively with AAB: when the user requests a feature (or the app requests it programmatically via SplitInstallManager), Play Store serves only the APK splits for that feature, containing its code, resources, and native libraries. The installed base APK plus on-demand modules equal the full app functionality.
The module split comes with architectural constraints that must be planned up front. The base module cannot depend on feature modules — the dependency graph must be unidirectional, with feature modules depending on the base or on shared library modules. Navigation between the base and a dynamic feature requires the Navigation component's dynamic navigation extension (androidx.navigation:navigation-dynamic-features-fragment) which handles the download-install-navigate flow. Activities and fragments in dynamic feature modules must be accessed via class name strings rather than direct references to avoid compile-time dependency. This forces a clean separation between the base app and features — which is architecturally desirable but requires deliberate design.
DFMs have a clear cost-benefit threshold. The engineering complexity — setting up the module, handling install states, testing install flows, dealing with Play's on-demand delivery constraints — is substantial. DFMs are worth this investment when the feature's size exceeds 5MB and it is used by less than 30% of your users. Below this threshold, the complexity outweighs the install size reduction. Always-required features should be in the base module regardless of size. The practical sweet spots are: video editing features in a social app, AR/camera effects, advanced import/export tools, enterprise-only admin dashboards, and large asset packs for games. Before implementing DFMs, measure your actual install funnel data to confirm the feature meets the usage and size thresholds.
- On-demand delivery: users download the feature only when they tap into it
- Reduces install size: users who never use AR don't download AR libraries
- SplitInstallManager: Google Play's API to request, monitor, and install dynamic features
- Classes unavailable until installed: must check module is installed before using its classes
- Good candidates: AR, large asset packs, rarely-used features, region-specific functionality
"Dynamic Feature Modules solve the 'app is huge because 5% of users use AR' problem. The 95% who never use AR never download the AR module. The trade-off: you must handle the download UX (show a spinner, handle failures, handle slow connections) and the feature's classes are unavailable until installed — you can't reference them directly, only via reflection or Intent."
19. What is Lint in Android? How do you configure custom Lint rules?
Android Lint is a static analysis tool that checks your code and resources for potential bugs, performance issues, security vulnerabilities, and style violations — without running the app. It catches issues at build time that would otherwise reach users.
// Enable strict Lint in build.gradle.kts android { lint { abortOnError = true // fail build on Lint errors warningsAsErrors = true // treat warnings as errors (optional, strict) htmlReport = true // generate HTML report xmlReport = true // XML for CI parsing baseline = file("lint-baseline.xml") // ignore pre-existing issues disable +=setOf ("ObsoleteLintCustomCheck") // suppress specific rules enable +=setOf ("StopShip") // enable specific rules } } // Run Lint: // ./gradlew lint — all variants // ./gradlew lintDebug — debug variant only (faster) // Suppress in code: @SuppressLint("SetTextI18n") fun showCount(count: Int) { textView.text = "Count: $count" } // Custom Lint rule (in a separate :lint module) class NoHardcodedColorDetector : Detector(), XmlScanner { override fun getApplicableAttributes() =listOf ("color", "background") override fun visitAttribute(context: XmlContext, attribute: Attr) { if (attribute.value.startsWith ("#")) { context.report ( issue = ISSUE, location = context.getValueLocation (attribute), message = "Avoid hardcoded colors — use theme color attributes" ) } } companion object { val ISSUE = Issue.create ("HardcodedColor", "Hardcoded color", "Use @color or ?attr/colorPrimary instead of #RRGGBB", Category.CORRECTNESS, 6, Severity.WARNING, implementation = ...) } }
Android Lint is a static analysis tool integrated into the Android build pipeline that checks your code, resources, and manifest for correctness, security, performance, and usability issues. It ships with hundreds of built-in checks: NewApi (API call newer than minSdk without version guard), HardcodedText (string literal instead of resource reference), UnusedResources, MissingTranslation, WrongThread (UI operation on background thread). Run ./gradlew lint to produce an HTML report; configure lintOptions { abortOnError true } in your release build type to fail CI if any error-severity lint issue is found.
Custom Lint rules catch team-specific conventions that built-in rules don't cover. Examples: enforcing that ViewModel constructors are annotated with @HiltViewModel, preventing direct use of System.currentTimeMillis() in domain code (use an injectable Clock instead for testability), detecting calls to a deprecated internal API across a large codebase, or enforcing that coroutines launched in Activities use lifecycleScope not GlobalScope. Custom rules are written as JUnit-testable Kotlin classes extending Detector and registered in a IssueRegistry. Package them in a separate lint-rules module and add it as a lintChecks(project(":lint-rules")) dependency in every module you want to enforce.
Lint baselines allow you to adopt Lint incrementally in an existing codebase with many existing violations. Run ./gradlew lintDebug -Dlint.baselines.continue=true to generate a lint-baseline.xml file recording all current violations. Subsequent Lint runs only fail for violations not in the baseline — the existing debt is acknowledged but not blocking. As the team fixes violations, remove them from the baseline. New code is held to the zero-violations standard. This approach lets you enable abortOnError true immediately on an existing project without requiring a cleanup sprint first. Commit the baseline file to version control and update it only when intentionally accepting new technical debt — an unexpected baseline update in a PR is a signal to investigate.
- abortOnError=true: Lint failures fail the build — catches issues before they ship
- baseline: suppress known pre-existing issues — allows adopting Lint incrementally
- Custom rules: write detectors for project-specific standards — no hardcoded colors, required annotations
- CI integration: run lintDebug on every PR — catches issues without full release build
- @SuppressLint: per-call suppression — use sparingly, document why
"The lint-baseline.xml strategy: enable Lint with abortOnError=true, generate a baseline that suppresses all existing issues (./gradlew lint -Dlint.baselines.continue=true), commit the baseline. From that point, any NEW Lint issue fails the build. You've adopted strict Lint without having to fix all existing issues immediately — fix them incrementally."
20. Scenario: Set up a multi-module Android project with shared build logic, Version Catalog, and a CI pipeline that builds and runs tests.
A well-structured multi-module project uses a composite build for convention plugins, a Version Catalog for all dependencies, and a CI pipeline that runs tests on every PR and deploys on tag push. Getting this right from day one saves weeks of Gradle debt later.
// settings.gradle.kts -- declare modules and include build-logic pluginManagement { includeBuild("build-logic") } include(":app", ":core:network", ":core:database", ":feature:home") // feature/home/build.gradle.kts -- 5 lines using convention plugin plugins { alias(libs.plugins.myapp.android.feature) } dependencies { implementation(project(":core:network")) } // GitHub Actions -- CI on every PR // on: [push, pull_request] // - run: ./gradlew test lintDebug ← fast, every PR // - run: ./gradlew bundleRelease ← only on tag push
A well-structured multi-module project separates code by feature and layer, not by technical type. The common mistake is a "layer-first" structure: one :data module, one :domain module, one :ui module. This creates a dependency graph where every feature change touches every module, eliminating the incremental build benefit of modularisation. The correct structure is "feature-first with shared infrastructure": :feature:home, :feature:search, :feature:checkout depend on :core:data, :core:domain, :core:ui. Feature modules are independent of each other — adding a new feature adds a new module, not more classes to an existing module.
Shared build logic via Convention Plugins (discussed in Q12) is essential for a multi-module project to be maintainable. Without it, 20 modules means 20 build scripts with duplicated configuration, diverging over time. The build-logic module provides Convention Plugins like android-feature (for feature modules), android-library (for shared libraries), and android-app (for the app module). Each plugin encodes the standard configuration for that module type — a feature module automatically gets compose-ui dependencies, test dependencies, and the correct AGP configuration. A new developer creates a feature module by applying one plugin and adding feature-specific dependencies, not by copying 80 lines of Gradle boilerplate.
The dependency rule in Clean Architecture maps cleanly to module structure: outer layers depend on inner layers, never the reverse. Feature modules (UI layer) depend on core modules (domain/data layer). Core modules depend on nothing except Kotlin stdlib and a few framework APIs. The :app module is the only module that depends on all features — it wires together the navigation graph and the DI graph. Enforcing this rule structurally (feature modules cannot reference each other) prevents the tight coupling that makes large codebases brittle. Use Gradle's forbidden-apis plugin or a custom Lint rule to fail the build if a feature module accidentally gains a dependency on another feature module.
- build-logic as composite build: convention plugins available to all modules via pluginManagement { includeBuild }
- libs.versions.toml: all dependency versions in one file -- type-safe accessors, IDE autocomplete, atomic upgrades
- Convention plugins: AndroidFeaturePlugin, AndroidLibraryPlugin -- shared compileSdk, minSdk, Hilt, KSP in one place
- CI split: test+lint on every PR (fast), bundleRelease+upload on git tag push (intentional release trigger)
- gradle/actions/setup-gradle: caches ~/.gradle on CI -- saves 3-5 minutes per run
"The CI split strategy: run ./gradlew test lintDebug on every PR — fast (2-3 min). Run connectedAndroidTest only on merge to main — slow, needs an emulator. Run bundleRelease + upload only on git tag push — production deployments are intentional events, not automatic per-commit. This prevents slow feedback loops on PRs."
21. What is the Gradle configuration cache? What breaks it and how do you fix it?
The configuration cache saves the result of the build configuration phase — it skips re-evaluating all build.gradle scripts on subsequent builds. This saves 20-60 seconds per build. But many common patterns break it and must be fixed first.
// Enable in gradle.properties org.gradle.configuration-cache=true org.gradle.configuration-cache-problems=warn // warn instead of fail (during migration) // What configuration cache does: // First build: evaluate all build scripts → cache the task graph // Subsequent builds: load task graph from cache → skip script evaluation // Savings: 20-60 seconds on typical projects // Common patterns that BREAK configuration cache: // ❌ 1. Accessing Project at execution time tasks.register ("myTask") {doLast { val version = project.version // ❌ Project not serialisable } } // ✅ Fix: capture value at configuration time val version = project.version // captured at config time tasks.register ("myTask") {doLast { println(version) } // ✅ uses captured value } // ❌ 2. Using System.getenv() inside task action tasks.register ("printEnv") {doLast { println(System.getenv ("CI")) } // ❌ env not cached } // ✅ Fix: use providers API val ci = providers.environmentVariable ("CI") tasks.register("printEnv") { doLast { println(ci.orNull ()) } } // ❌ 3. Plugins that don't support configuration cache yet // Check: https://github.com/gradle/gradle/issues → configuration-cache label // Workaround: use configuration-cache-problems=warn until plugin is updated // Diagnose compatibility: // ./gradlew test --configuration-cache // Generates report: build/reports/configuration-cache/.../report.html
The Gradle configuration cache records the result of the configuration phase — the entire task graph, with all task inputs and outputs resolved — and replays it on subsequent builds without re-executing any build script code. The speedup is proportional to your configuration phase time. Projects with 20+ modules and complex build scripts can see configuration cache hits reduce build startup from 30 seconds to under 2 seconds. Enable it with org.gradle.configuration-cache=true in gradle.properties. Gradle reports cache hits and misses with reasons: "configuration cache entry reused" vs. "configuration cache cannot be reused because..." with the specific invalidating change.
Configuration cache compatibility requires that build scripts and plugins do not read mutable state during configuration. The most common incompatibilities: reading environment variables directly in a build script task action (environment variables are runtime state, not configuration state — use providers instead), capturing a live reference to a Project object in a task's closure (use @Internal properties and project.layout providers), and accessing File objects directly rather than through Gradle's file property APIs. Plugin authors are increasingly updating for configuration cache compatibility — check the Gradle compatibility matrix before enabling it if you depend on older plugins.
The configuration cache requires all tasks to use Gradle's property API for inputs and outputs rather than direct field access. A task that reads project.version directly in its action breaks the cache; it should use @Input val projectVersion: Property<String> injected via the project's extension. Migrating legacy custom tasks requires adding @Input, @InputFile, @OutputFile annotations to all task properties that affect the task's output. This migration is also an opportunity to make tasks properly incremental — a task with correctly declared inputs and outputs skips execution when inputs haven't changed, even without the configuration cache. Run ./gradlew --configuration-cache to test compatibility and read the generated HTML report that lists every incompatibility with its location.
- Configuration cache: caches the build task graph — skips Groovy/Kotlin script evaluation
- Project not serialisable: can't access `project` in task actions — capture values at config time
- Providers API: use providers.environmentVariable() instead of System.getenv() in tasks
- Plugin compatibility: some third-party plugins break config cache — use warn mode during migration
- 20-60s savings: significant on large multi-module projects with many build scripts
"Configuration cache is one of the most impactful Gradle improvements in recent years, but the migration can be painful because it requires fixing all build script patterns that aren't cache-compatible. The strategy: enable with problems=warn, run ./gradlew test, read the HTML report, fix issues one by one. Don't try to fix everything at once."
22. What is the difference between a library module and an application module in Android?
An application module produces an APK/AAB — it can be installed on a device. A library module produces an AAR (Android Archive) — it provides code and resources to other modules but can't be installed directly.
// Application module (app/build.gradle.kts) plugins {alias(libs.plugins.android.application) // com.android.application } android { defaultConfig { applicationId = "com.example.app" // unique, required for application } } // Output: APK + AAB (installable, publishable) // Has: applicationId, signing config, split config // Can: be installed, have dynamic features, use instant apps // Library module (core/network/build.gradle.kts) plugins { alias(libs.plugins.android.library) // com.android.library } android { // NO applicationId — libraries don't have one // namespace used for generated R class namespace = "com.example.core.network" } // Output: AAR (Android Archive) file // Has: its own resources, assets, manifest, native libs // Can: be consumed by other modules via implementation(project(":core:network")) // Key differences: // Application Library // Plugin android.app android.library // Output APK + AAB AAR // applicationId Required ❌ Not allowed // Installable ✅ ❌ // Publishable ✅ (Play) ✅ (Maven) // R class app-level module-level (own namespace) // Android Test module (com.android.test) // Third plugin type — for separate Espresso/UI test modules // Applied to a module that ONLY contains tests, no production code
A library module produces an AAR (Android Archive) — a zip file containing compiled classes, resources, manifest, and ProGuard consumer rules. An application module produces an APK or AAB for direct installation. The key architectural difference: a library module cannot have an applicationId, cannot have a main activity, and cannot be run independently. It is consumed by other modules. The library module's build.gradle.kts applies com.android.library plugin instead of com.android.application; everything else (dependencies, source sets, build types) works identically.
Consumer ProGuard rules are one of the most important differences in practice. When a library module is consumed by an application module that has R8 enabled, R8 needs to know which classes in the library must be kept. The library module declares these rules in proguard-consumer-rules.pro — these rules are automatically applied when a consuming app runs R8. If a library uses reflection internally, its consumer rules keep the reflected classes so consuming apps don't need to add rules manually. Forgetting consumer rules is one of the most common causes of release-only crashes in apps that consume internal library modules: R8 removes a class that the library accesses by reflection, causing a ClassNotFoundException only in the release build.
The namespace property (replacing applicationId in library modules as of AGP 7.3) determines the package of the generated R class. Each library module has its own R class containing references to its own resources — this is the nonTransitiveRClass feature enabled by default in AGP 8.0. Before this, all resources from all modules were accessible via the app module's single R class, meaning that renaming a resource in one library required recompiling all other modules. With nonTransitiveRClass, each module's R class only contains that module's own resources — a resource change in one library triggers recompilation only of that library and modules that directly reference it, not the entire project.
- Application: produces APK/AAB — the thing users install from Play Store
- Library: produces AAR — reusable code package consumed by other modules
- applicationId: required and unique for application modules — absent in library modules
- namespace: required in library modules — generates the R class for that module's resources
- Publishable: library AARs can be published to Maven for external consumption
"The architecture implication: in a multi-module app, there's exactly one application module (:app) and many library modules (:core:network, :feature:home). The application module is the entry point — it declares the manifest activities, application class, and pulls together all the feature modules. Feature modules never reference :app — dependencies only flow toward :app, never away."
23. Scenario: How do you measure and track APK/AAB size changes in CI to prevent accidental size regressions?
Size regressions sneak in when someone adds a large library without realising its impact. Tracking AAB size in CI and failing builds that exceed a threshold prevents this — catching size increases in the PR that caused them.
// Method 1: Simple CI size check in GitHub Actions // .github/workflows/size_check.yml // - run: ./gradlew bundleRelease // - name: Check AAB size // run: | // SIZE=$(stat -c%s app/build/outputs/bundle/release/app-release.aab) // echo "AAB size: $SIZE bytes" // MAX=52428800 # 50MB threshold // if [ $SIZE -gt $MAX ]; then echo "❌ AAB too large!"; exit 1; fi // Method 2: Gradle task for APK size reporting tasks.register ("reportApkSize") { dependsOn("assembleRelease")doLast { val apk =fileTree ("${buildDir}/outputs/apk/release") .filter { it.name.endsWith(".apk") } . first () val sizeKb = apk.length () / 1024 println("APK size: ${sizeKb}KB") if (sizeKb > 50_000)error ("APK exceeds 50MB — check recent dependency additions") } } // Method 3: Diffuse — Jakewharton's AAB/APK diff tool // Compares two APKs and shows what changed in each section // diffuse diff old.apk new.apk // Output: // OLD: 32.1 MB NEW: 35.7 MB DIFF: +3.6 MB // classes.dex: +1.2MB (new library added) // res/: +2.4MB (large PNG assets added) // lib/arm64: +0MB // Method 4: android-size-report Gradle plugin // Generates PR comments with size diff // Run on PR → comment "APK grew by 2.3MB — lib/xyz.so increased" // What to track: // Download size (what user sees on Play Store) // Install size (space used on device) // Dex method count (65K method limit) // classes.dex size (reflects code bloat) // res/ and assets/ (reflects image/asset bloat)
APK/AAB size tracking in CI prevents the gradual size creep that turns a 15MB app into a 50MB app without any single change being obviously to blame. Set up a CI step after the release build that extracts the APK/AAB size and compares it against a stored baseline. If the size increases by more than a threshold (e.g., 500KB per PR), fail the build or post a warning comment on the PR. The bundletool get-size total --apks=app.apks command returns the minimum and maximum download size across device configurations — use the maximum as your tracked metric for conservative size budgeting.
The APK Analyzer Gradle task (./gradlew :app:analyzeReleaseBundle with the com.github.ben-manes.versions plugin, or a custom task using the bundletool API) can extract a machine-readable size breakdown. Store the breakdown as a CI artifact so you can diff it against the previous PR: "DEX increased by 200KB, resources decreased by 50KB, native libraries unchanged." This diff is far more actionable than a raw size change — 200KB of DEX growth points to a new dependency or disabled R8 rule, which can be investigated and justified (or reverted) before merge.
The Play Console's "Android vitals → App size" dashboard provides historical size data across all released versions, broken down by device configuration. This is the ground truth for what your users actually download. Compare it against your CI-measured size to validate that your CI measurement is accurate (accounting for Play's additional processing). Set up a size regression alert by tracking the "Universal APK" size in the Play Console API programmatically — a sudden jump between releases that wasn't flagged in CI often indicates that a large asset was added to the release build but not the debug build, or that a Gradle configuration change only affects the Play-processed AAB. Investigating size regressions immediately after release (before the next release) is much easier than investigating a size doubling that accumulated over six releases.
- CI size threshold: fail build if AAB exceeds limit — catches regressions in the PR
- Diffuse: shows exactly which section grew — dex, resources, native libs
- PR comments: automated size diff comment on every PR — visible without running the build
- Track download vs install size: different metrics, both matter for user experience
- Method count: 65K dex method limit — multidex needed beyond it, Lint checks this
"Size regressions are invisible without CI tracking. Someone adds a 5MB image library, APK grows from 30MB to 35MB, it ships. Nobody noticed. With a CI size check and a PR comment showing '+5.2MB in res/', the developer sees it immediately and can fix it before merge. Diffuse is the best tool for understanding exactly why the size changed."
24. What is R8 Full Mode? How does it differ from standard R8?
R8 Full Mode enables more aggressive optimisations than standard R8 -- class merging, interface removal, constructor argument propagation, and enum unboxing. The result is 5-8% smaller DEX output. The trade-off is that some patterns standard R8 preserved automatically now need explicit keep rules.
// Enable in gradle.properties android.enableR8.fullMode=true // Enum toString() -- Full Mode may optimise away the name enum class Status { PENDING, ACTIVE, DONE } // Fix: keep enum members if you use .name or .toString() // -keepclassmembers enum * { public static **[] values(); public static ** valueOf(java.lang.String); } // Default interface methods -- Full Mode may remove unused defaults interface Callback { fun onSuccess() {} // add -keep rule if accessed via reflection } // Build and check for warnings // ./gradlew bundleRelease → R8 warnings in output → address each one
R8 Full Mode (enabled with -allowaccessmodification and -repackageclasses in combination with AGP's full mode setting) enables more aggressive optimisations than standard R8. Standard R8 respects the original class hierarchy and package structure. Full Mode can move classes to a flattened package (-repackageclasses ""), inline classes that have only one subclass, remove interface implementations that are never used as that interface type, and perform more aggressive constant propagation. The result is smaller DEX output and faster startup due to fewer class loads, but at the cost of higher risk of runtime errors if keep rules are incomplete.
Full Mode is most beneficial for apps with large amounts of Kotlin-generated code — companion objects, data class boilerplate, coroutine continuations. R8 Full Mode can eliminate entire classes that are only used as implementation details of the Kotlin compiler's code generation, not as types that application code references directly. The size reduction over standard R8 is typically an additional 5–10% for Kotlin-heavy codebases. Before enabling Full Mode in production, run your complete test suite (unit tests, instrumented tests, smoke tests) against a release build with Full Mode enabled — any test failures reveal missing keep rules that must be added before shipping.
Full Mode requires more thorough keep rules because R8 makes assumptions that standard mode does not. If your app uses any framework that creates instances via reflection without declared keep rules (custom annotation processors, third-party DI frameworks, certain serialisation libraries), Full Mode will break them in ways that are hard to diagnose from stack traces alone. The recommended migration path: enable Full Mode in a debug release variant first, run all tests, add any required keep rules, then enable it for the production release. Maintain the R8 mapping file carefully — Full Mode's more aggressive obfuscation produces shorter class names that are harder to deobfuscate manually if the mapping file is lost.
- Full Mode additional optimisations: class merging, interface removal, constructor argument propagation, enum unboxing
- 5-8% smaller DEX than standard R8 -- meaningful for apps already close to a size budget
- Enum .name/.toString() may break: Full Mode can optimise away the string name -- add keep rules if you use enum names via reflection or serialisation
- Enable for new projects from day one -- much easier than retrofitting keep rules into an established app
- Standard R8 vs Full Mode: standard is compatible with older ProGuard tooling; Full Mode is more aggressive and faster
"R8 Full Mode is the 2025-26 recommendation for new projects — enable it from the start along with Kotlin Serialization (which avoids the reflection-based issues that make Full Mode painful). The 5-8% additional size reduction is meaningful at scale, and the extra keep rules needed are minimal if you're using annotation-based code generation rather than reflection."
25. Scenario: Conduct a build system code review. What 8 things do you check?
A build system review catches configuration mistakes that silently slow down builds, bloat APK size, or create security vulnerabilities — before they accumulate into intractable debt.
// 1. ❌ KAPT when KSP is available kapt(libs.hilt.compiler) // ❌ slow — should be ksp() // ✅ ksp(libs.hilt.compiler) — 2x faster // 2. ❌ api() overused instead of implementation() api(libs.okhttp) // ❌ forces all consumer modules to recompile when OkHttp changes // ✅ implementation(libs.okhttp) — unless consumers genuinely need OkHttp types // 3. ❌ R8 disabled in release buildTypes { release { isMinifyEnabled = false } } // ❌ no shrinking/obfuscation // ✅ isMinifyEnabled = true + isShrinkResources = true // 4. ❌ Hardcoded version strings (no Version Catalog) implementation("com.google.dagger:hilt-android:2.48") // ❌ scattered, drift-prone // ✅ implementation(libs.hilt.android) from libs.versions.toml // 5. ❌ Signing credentials in build files storePassword = "MySecretPassword" // ❌ committed to git! // ✅ Read from local keystore.properties (git-ignored) or env vars // 6. ❌ targetSdk way behind latest targetSdk = 30 // ❌ Play Store will reject — must be within 1 year of latest // ✅ targetSdk = 35 (latest as of 2025) // 7. ❌ Universal APK without ABI filtering or AAB splits.abi.isEnable = false // ❌ shipping arm64 + arm + x86 to everyone // ✅ Use AAB (Play handles it) or ABI splits for direct distribution // 8. ❌ No Gradle caching enabled // gradle.properties missing: org.gradle.caching=true // ✅ org.gradle.caching=true + org.gradle.parallel=true // + kotlin.incremental=true + ksp.incremental=true // Bonus check: mapping.txt not archived in CI // ❌ ./gradlew bundleRelease but mapping.txt not saved as artifact // ✅ Archive mapping.txt as CI artifact, upload to Play Console
A build system code review starts with dependency hygiene. Check that every dependency uses implementation instead of api unless the type is genuinely part of the module's public API. Check for duplicate dependencies at different versions — ./gradlew :app:dependencies | grep "(*)" shows all version conflicts resolved by Gradle's default strategy (highest version wins). Version conflicts are not always benign: Retrofit 2.9.0 and 2.11.0 coexisting in the dependency graph means some code compiles against one version and the runtime uses another, which can cause subtle API incompatibilities. Pin conflicting versions explicitly with configurations.all { resolutionStrategy { force("com.example:library:x.y.z") } }.
The second pass covers security and credential management. Any hardcoded API keys, signing passwords, or service account credentials in build scripts or committed local.properties files are critical findings. Check that local.properties is in .gitignore and that its absence doesn't break the build for new developers (provide a local.properties.template with placeholder values). Verify that signing configuration reads credentials from environment variables, not from hardcoded strings. Check that no debug signing certificate is used in the release build type — the presence of signingConfig = signingConfigs.getByName("debug") in the release build type is a common accident that prevents Play Store upload.
The third pass covers build performance and correctness. Check that R8 is enabled for release builds (minifyEnabled = true) and that consumer ProGuard rules are present in library modules that use reflection. Verify that targetSdk is current (within two levels of the latest Android release per Play Store policy). Check that all modules use the same AGP and Kotlin versions — version mismatches between modules cause mysterious build failures. Look for tasks that call project.files() or read environment variables inside task action closures rather than in task configuration — these break incremental builds and the configuration cache. Finally, verify that the build includes at least one automated size tracking step and that mapping files are uploaded to Crashlytics or Play Console as part of the release pipeline.
- KAPT→KSP: first thing to check — biggest build speed improvement available
- api() overuse: causes recompilation cascades — prefer implementation() always
- R8 disabled: production apps must have minification enabled — security and size
- No Version Catalog: scattered version strings cause drift and upgrade pain
- Signing in build files: immediate security issue — credentials in git are compromised
"In a build system review, I check security first (signing credentials), then correctness (targetSdk, R8 enabled), then performance (KAPT vs KSP, caching, api vs implementation). The most impactful fixes: KAPT→KSP saves minutes per build day. Signing credentials in git is a security incident. Missing mapping.txt means you can't debug production crashes."
26. What is Gradle's task graph? How does task dependency work?
Gradle models every build step as a task. Tasks declare inputs and outputs — Gradle builds a directed acyclic graph (DAG) of task dependencies and executes only what's needed. Understanding this lets you extend the build and diagnose why tasks run or are skipped.
// See the task graph for a build // ./gradlew assembleDebug --dry-run → lists tasks without running them // ./gradlew assembleDebug --scan → visual task graph in browser // Tasks run in dependency order: // preBuild → generateDebugSources → compileDebugKotlin → ... // → mergeDebugResources → packageDebugResources → assembleDebug // Define a custom task with dependencies tasks.register("printVersionInfo") { dependsOn("assembleDebug") // runs after assembleDebug doLast { println("Build complete: ${android.defaultConfig.versionName}") } } // Task inputs and outputs — enable UP-TO-DATE checks tasks.register<Copy>("copyApk") { dependsOn("assembleRelease") from("${buildDir}/outputs/apk/release") into("${rootDir}/artifacts") } // If from and into haven't changed since last run → task is UP-TO-DATE → skipped // This is how Gradle's incremental build works — avoid re-running unchanged tasks // finalizedBy — run a task after another (even on failure) tasks. named("test") { finalizedBy("generateTestReport") // generate report even if tests fail } // mustRunAfter — ordering without hard dependency tasks. named("lintDebug") { mustRunAfter("test") // if both run, lint goes after test } // but lintDebug doesn't force test to run
Gradle's task graph is a directed acyclic graph (DAG) where each node is a task and each edge is a dependency relationship. When you run ./gradlew assembleDebug, Gradle resolves the full graph of tasks that assembleDebug depends on — transitively — before executing any of them. Tasks are executed in topological order: a task only runs after all tasks it depends on have completed successfully. You declare dependencies with taskB.dependsOn(taskA) or taskB.mustRunAfter(taskA) — the latter enforces ordering without creating a hard dependency (taskB can still run if taskA is skipped). Gradle executes tasks in parallel by default across modules when org.gradle.parallel=true is set.
Understanding the task graph is the foundation of build optimisation. The --dry-run flag (./gradlew assembleDebug --dry-run) prints every task that would run and in what order, without actually executing them — useful for understanding why an unexpected task is being invoked. The taskTree plugin renders a visual tree of task dependencies, making it easy to see why a slow task is being pulled into a build that shouldn't need it. A common issue is a task being invoked because it's listed as a dependency of a lifecycle task (like build) that you don't actually need — running a more targeted task like assembleDebug instead of build skips the test and check tasks.
Custom task ordering matters when you add tasks to the build pipeline. Suppose you add a custom task that copies generated files before compilation — you need compileDebugKotlin.dependsOn(copyGeneratedFiles). If you use mustRunAfter instead of dependsOn, your task only runs in the right order when both tasks are explicitly requested; Gradle won't pull it in automatically. The distinction between dependsOn (hard pull-in) and mustRunAfter (ordering-only) is the most common source of ordering bugs in custom Gradle tasks. Always validate your custom task ordering with --dry-run to confirm the execution order is what you intend.
- DAG: Gradle resolves all task dependencies into an ordered execution graph before running anything
- UP-TO-DATE: if task inputs/outputs unchanged, Gradle skips it — core of incremental builds
- dependsOn: hard dependency — the listed task always runs first
- finalizedBy: runs after, even on failure — useful for cleanup and reporting tasks
- --dry-run: preview which tasks would run without executing them
"UP-TO-DATE checks are why incremental builds are fast. Gradle tracks every task's inputs (source files, config) and outputs (class files, APK). If nothing changed, the task is skipped entirely. When you change one file, only tasks that depend on that file re-run. --scan shows the full task graph and which tasks were UP-TO-DATE vs executed."
27. How does dependency resolution work in Gradle? What is dependency conflict resolution?
Gradle resolves dependencies transitively — if Retrofit depends on OkHttp 4.11, your project gets OkHttp 4.11 even if you didn't declare it. When two paths pull in different versions of the same library, Gradle must pick one — by default it picks the highest version.
// See the full dependency tree // ./gradlew app:dependencies --configuration releaseRuntimeClasspath // Output: // +--- com.squareup.retrofit2:retrofit:2.11.0 // | \--- com.squareup.okhttp3:okhttp:4.12.0 // +--- com.squareup.okhttp3:okhttp:4.9.0 (*) // (*) = version conflict, resolved to 4.12.0 (highest wins) // Force a specific version — override conflict resolution configurations.all { resolutionStrategy { force("com.squareup.okhttp3:okhttp:4.12.0") } } // Exclude a transitive dependency implementation(libs.retrofit) { exclude (group = "com.squareup.okhttp3", module = "okhttp") } // Use when: the transitive dep conflicts or you provide your own version // Detect version conflicts explicitly configurations.all { resolutionStrategy { failOnVersionConflict() // fail build instead of silently picking highest } } // BOM (Bill of Materials) — align versions across a family implementation(platform(libs.compose.bom)) // sets versions for all Compose libs implementation(libs.compose.ui) // no version needed — BOM controls it implementation(libs.compose.material3) // guaranteed compatible version // Check for dependency updates // ./gradlew dependencyUpdates (with ben-manes/gradle-versions-plugin) // Lists which of your dependencies have newer versions available
Gradle resolves dependencies by downloading metadata (POM or Gradle Module Metadata files) from configured repositories in the order they are listed. For each dependency, Gradle reads the metadata to find transitive dependencies and recursively resolves the full dependency graph. When multiple modules in a multi-module project declare the same library at different versions — say module A uses Retrofit 2.9.0 and module B uses Retrofit 2.11.0 — Gradle must pick one version. By default it uses the "newest wins" strategy: the highest declared version is selected for all modules. This is called dependency conflict resolution, and it happens silently unless you inspect the dependency tree.
Dependency conflict resolution becomes a problem when the resolved version introduces a breaking API change. ./gradlew app:dependencies --configuration releaseRuntimeClasspath shows the full resolved dependency tree, with arrows indicating where version selections were overridden. Lines marked with (*) are duplicate entries that were omitted. Lines marked with -> X.Y.Z show where version conflict resolution upgraded or downgraded a dependency. Force a specific version with configurations.all { resolutionStrategy { force "com.squareup.retrofit2:retrofit:2.9.0" } } — useful when a transitive upgrade breaks your app and you need a quick fix while investigating the root cause.
Dependency locking protects you from unexpected transitive upgrades. ./gradlew dependencies --write-locks generates a gradle.lockfile that pins the exact resolved version of every dependency — including transitive ones — to a specific version. Subsequent builds fail if the resolved versions deviate from the lockfile, preventing silent transitive upgrades from breaking your build. This is standard practice in release branches. For active development branches, use ./gradlew dependencies --update-locks com.squareup.retrofit2:retrofit to update a specific library's lock entry. Combining version catalogs (for explicit version management) with dependency locking (for transitive pinning) gives you complete control over your dependency graph.
- Transitive dependencies: Gradle pulls in all of your dependencies' dependencies automatically
- Conflict resolution: when two paths need different versions, Gradle picks the highest by default
- resolutionStrategy.force: pin an exact version — overrides Gradle's default conflict resolution
- exclude: drop a transitive dependency entirely — useful when a library ships a conflicting version
- BOM: aligns a whole family of libraries to known-compatible versions — eliminates version guessing
"The Compose BOM is the best example of why BOMs matter. Compose has 15+ libraries (ui, material3, animation, runtime...) that must all be on compatible versions. Without the BOM you'd manually manage 15 version strings and hope they're compatible. With the BOM, declare one version, all 15 are aligned automatically."
28. Scenario: How do you set up Gradle to publish an Android library to Maven Central or GitHub Packages?
Publishing a library requires configuring the maven-publish plugin, generating sources and docs JARs, and signing artifacts. The process differs slightly between Maven Central (strict, requires signing) and GitHub Packages (simpler, requires GitHub auth).
// library/build.gradle.kts plugins {alias(libs.plugins.android.library) id("maven-publish") id("signing") } android { publishing { singleVariant("release") { withSourcesJar() withJavadocJar() } } } afterEvaluate { publishing { publications { create<MavenPublication>("release") { from (components["release"]) groupId = "com.example" artifactId = "mylibrary" version = "1.0.0" pom { name.set ("My Library") description.set("A useful Android library") url. set ("https://github.com/example/mylibrary") licenses { license { name.set ("Apache-2.0") } } developers { developer { name.set ("Alice"); email.set ("alice@example.com") } } scm { connection.set ("scm:git:github.com/example/mylibrary.git") url.set ("https://github.com/example/mylibrary") } } } } // GitHub Packages repository repositories { maven { name = "GitHubPackages" url = uri("https://maven.pkg.github.com/example/mylibrary") credentials { username = System.getenv ("GITHUB_ACTOR") password = System.getenv ("GITHUB_TOKEN") } } } } // Signing — required for Maven Central signing { val key = System.getenv ("GPG_SIGNING_KEY") val pwd = System.getenv ("GPG_SIGNING_PASSWORD")useInMemoryPgpKeys (key, pwd) sign(publishing.publications["release"]) } } // Publish: ./gradlew publishReleasePublicationToGitHubPackagesRepository
Publishing an Android library to Maven Central requires the maven-publish Gradle plugin and a Sonatype OSSRH account. In your library module's build.gradle.kts, configure the publishing block to define a MavenPublication with the component (components["release"] for the release AAR), coordinates (groupId, artifactId, version), and POM metadata (name, description, license, developers, SCM URL — all required by Maven Central). Configure the repositories block to point at the Sonatype staging repository URL, with credentials loaded from gradle.properties or environment variables — never hardcoded in build scripts.
The full Maven Central publication workflow goes through Sonatype's staging repository. You publish to https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/ — this creates a staging repository. You then log into the Sonatype Nexus UI (or use the io.github.gradle-nexus.publish-plugin to automate it), verify the staging repository contents, and "close" it — which triggers validation checks (signatures present, POM complete, license declared). If validation passes, you "release" the staging repository to Maven Central. The whole process takes 10–30 minutes. The gradle-nexus-publish plugin automates the close-and-release steps: ./gradlew publishToSonatype closeAndReleaseSonatypeStagingRepository.
GPG signing is mandatory for Maven Central publication. Generate a GPG key pair, publish the public key to a keyserver (gpg --keyserver keyserver.ubuntu.com --send-keys KEY_ID), and configure the signing plugin in Gradle: signing { sign(publishing.publications["release"]) }. Store the GPG key ID, secret key ring (base64-encoded), and passphrase as CI secrets — never in the repository. For GitHub Actions, set these as repository secrets and pass them to Gradle via environment variables. A common CI pattern: on every tag push matching v*.*.*, run the full publish workflow; for snapshot versions on main branch pushes, publish to a snapshot repository only. This keeps release artifacts on Maven Central stable and only updates snapshots for pre-release testing.
- maven-publish plugin: Gradle's built-in publishing support — generates POM and publishes artifacts
- withSourcesJar + withJavadocJar: required for Maven Central — consumers get IDE source navigation
- POM metadata: name, description, URL, license, developer, SCM — all required for Maven Central
- GitHub Packages: simpler auth via GITHUB_TOKEN — great for private or org-internal libraries
- Signing: GPG signature required for Maven Central — use in-memory key from CI environment variable
"GitHub Packages is the fastest way to share a library within a team — create a private repo, publish to GitHub Packages using the GITHUB_TOKEN, and consumers add your repo as a Maven repository. Maven Central takes more setup (account, GPG key, Sonatype staging) but makes the library available to the whole world without any repo configuration on the consumer side."
29. What is the Gradle version catalog's bundle feature? How do you use it?
Bundles group related libraries that are always added together. Instead of declaring Room runtime, ktx, and compiler separately in every module, you declare a bundle once and reference one name.
// gradle/libs.versions.toml [versions] room = "2.6.1" hilt = "2.51.1" retrofit = "2.11.0" [libraries] room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-compiler = { module = "com.google.dagger:hilt-compiler",version.ref = "hilt" } retrofit-core = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } retrofit-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" } retrofit-scalars = { module = "com.squareup.retrofit2:converter-scalars", version.ref = "retrofit" } // Declare bundles — groups of libraries added together [bundles] room = ["room-runtime", "room-ktx"] // runtime deps together retrofit = ["retrofit-core", "retrofit-gson"] // networking stack // In build.gradle.kts — one line instead of three dependencies { // Without bundle: implementation(libs.room.runtime) implementation(libs.room.ktx) // ✅ With bundle: implementation(libs.bundles.room) // room-runtime + room-ktx ksp(libs.room.compiler) // compiler is ksp, not in bundle implementation(libs.bundles.retrofit) // retrofit + gson converter implementation(libs.hilt.android) ksp(libs.hilt.compiler) } // Convention plugin can also use bundles dependencies { add("implementation", libs.bundles.room) }
The Version Catalog's bundle feature groups multiple related library references under a single name. In libs.versions.toml, define a bundle like [bundles] compose = ["compose-ui", "compose-material3", "compose-ui-tooling-preview", "compose-activity"]. In your build script, implementation(libs.bundles.compose) adds all four artifacts in one line. This is purely a build script convenience — Gradle still resolves and downloads four separate artifacts. The bundle has no effect on the final APK; it only reduces build script verbosity. The primary value is grouping artifacts that are always updated together and always used together, making the build script intention clear to future maintainers.
Bundles shine in multi-module projects where the same set of libraries appears in many modules. Without bundles, each module independently lists the same 5-6 Compose dependencies. With a bundle, each module has one line. When the Compose version is upgraded, you change one version string in [versions] and all modules pick up the change automatically. Bundles can also include test dependencies: a testing-unit bundle containing JUnit 5, Mockk, and Kotest can be added to every module with testImplementation(libs.bundles.testingUnit). This enforces a consistent testing setup across all modules without any convention plugin or shared build script required.
One nuance: bundles do not support api() vs implementation() granularity — all artifacts in a bundle are added with the same dependency configuration. If you need some artifacts from a group to be api (exported to consumers) and others to be implementation (internal), you must list them separately. This is rarely an issue for app modules (which have no consumers) but matters for library modules. In that case, split your bundle into two or list the api dependencies explicitly and use the bundle only for the implementation ones. The Version Catalog itself does not enforce this — it's the developer's responsibility to choose the correct configuration for each bundle usage.
- Bundles: named groups in [bundles] section — reference multiple libs with one accessor
- Always-together pattern: Room runtime+ktx, Retrofit+converter — always added as a pair
- Convention plugins: bundles work perfectly in convention plugins for shared module config
- Compiler excluded: annotation processors (ksp/kapt) are declared separately — not in the bundle
- Refactoring: add a lib to a bundle in one place → all modules that use the bundle get it
"Bundles shine in multi-module projects. Your feature convention plugin adds libs.bundles.room for every feature module. When Room ships a new companion library you want in every module, add it to the bundle once — all feature modules get it automatically. Without bundles you'd edit every module's build.gradle.kts individually."
30. Scenario: How do you configure a multi-flavour CI pipeline that builds, tests, and uploads different variants to different channels?
Multi-flavour CI means each build variant (freeIndiaRelease, premiumGlobalRelease) is built and tested independently. GitHub Actions matrix builds parallelise this -- all variants build simultaneously so total CI time equals one variant's time, not the sum of all variants.
// .github/workflows/release.yml -- matrix over flavors // strategy: // matrix: // flavor: [freeIndia, premiumIndia, freeGlobal, premiumGlobal] // steps: // - run: ./gradlew bundle${{ matrix.flavor }}Release // - run: ./gradlew test${{ matrix.flavor }}ReleaseUnitTest // Variant-specific Gradle commands // ./gradlew assembleFreeIndiaRelease → APK for free + India + release // ./gradlew bundlePremiumGlobalRelease → AAB for premium + global + release // Upload to different Play tracks per flavor (Gradle Play Publisher plugin) play { track.set("internal") // free → internal, premium → alpha serviceAccountCredentials.set(file("play-service-account.json")) }
A multi-flavour CI pipeline needs to build, test, and sign each variant independently. The standard approach is a matrix strategy in your CI YAML. In GitHub Actions: strategy: matrix: flavor: [free, paid] buildType: [debug, release] — this runs four parallel jobs, each executing ./gradlew assemble{Flavor}{BuildType} and ./gradlew test{Flavor}{BuildType}UnitTest. Each job installs only the artifacts relevant to its matrix combination, and failures in one variant don't block others. The matrix approach parallelises work across CI runners, cutting total wall time compared to a sequential build of all four variants.
Signing must be handled per-variant in the pipeline. Store each signing keystore as a CI secret (base64-encoded), along with the key alias, key password, and store password. In the GitHub Actions job, write the keystore file to disk: echo "$KEYSTORE_BASE64" | base64 --decode > keystore.jks. Pass the path and credentials to Gradle via -Pandroid.injected.signing.store.file=... -Pandroid.injected.signing.key.alias=... or via environment variables read in the signing config block. For a free variant that doesn't need a release build, use a conditional in the matrix to skip that combination: exclude: - flavor: free buildType: release. This avoids wasting CI minutes on an APK that is never shipped.
Artifact uploading completes the pipeline. After a successful release build, upload the APK or AAB to Firebase App Distribution (for QA testing) or directly to Play Store's internal track using the gradle-play-publisher plugin. Gate the upload step on the branch: only main or release branches upload to Play; feature branches upload to Firebase App Distribution only. Add a Slack or email notification on build failure so the team knows immediately when a variant breaks. Use Gradle's --continue flag in CI to run all tasks even when some fail — this way you get failure information for all variants in one run rather than fixing one variant at a time.
- Matrix builds: GitHub Actions matrix strategy runs each flavor in parallel -- 4 flavors build simultaneously, not sequentially
- Variant task naming: Gradle capitalises each dimension -- bundleFreeIndiaRelease, testPremiumGlobalReleaseUnitTest
- Conditional upload: use if: contains(matrix.flavor, 'premium') to route variants to different Play tracks
- Firebase App Distribution: fastest non-Play distribution for internal testing -- wzieba/Firebase-Distribution-Github-Action
- Gradle Play Publisher: automates Play Store uploads from CI -- replaces manual console upload
"Matrix builds are the key insight for multi-flavour CI. Instead of one sequential pipeline that builds each variant one by one, matrix runs them in parallel — 4 variants build simultaneously. Total CI time = time for one variant, not four times that. For 10-minute builds across 4 variants: sequential = 40 minutes, matrix = 10 minutes."
31. What is the 64K method limit in Android? How do you enable and configure multidex?
Android's Dalvik Executable (DEX) format uses 16-bit method references — limiting each DEX file to 65,536 methods. Large apps with many libraries exceed this. Multidex splits code across multiple DEX files. R8 makes this largely irrelevant in release builds.
// Error without multidex (large apps): // "Cannot fit requested classes in a single dex file" // "method count: 65536 > 65536" // Enable multidex android { defaultConfig { multiDexEnabled = true } } dependencies { implementation(libs.androidx.multidex) } // Application class (for API < 21) class MyApp : MultiDexApplication() // OR class MyApp : Application() { override fun attachBaseContext(base: Context) { super.attachBaseContext (base) MultiDex.install (this) // install multidex manually } } // API 21+ (Android 5.0+): native multidex — no library needed android { defaultConfig { minSdk = 21 // ART natively supports multiple dex files multiDexEnabled = true } } // With minSdk 21+, just set multiDexEnabled = true — no library dependency // Why R8 makes this mostly irrelevant for release builds: // R8 shrinks your app aggressively — removes unused code // A release build with 100K methods → R8 removes unused → 30K methods // Below the 64K limit → single dex → no multidex needed // Multidex is mainly needed for DEBUG builds (R8 disabled) // Check method count // ./gradlew countDebugDexMethods (with dexcount gradle plugin) // Or: Android Studio → Build → Analyze APK → classes.dex → method count
The 64K method limit (commonly called the "dex limit") arises because the DEX bytecode format uses a 16-bit index to reference methods, limiting a single DEX file to 65,536 method references — not method definitions, but references, which includes all methods from all libraries you use. Large apps with many libraries easily exceed this. The symptom without multidex is a build error: Cannot fit requested classes in a single dex file (# methods: 65536+). With minSdk = 21+, multidex is automatic — Android runtime natively supports multiple DEX files. For minSdk below 21, you need the legacy androidx.multidex:multidex library and must call MultiDex.install(this) in your Application class.
Enable multidex in the defaultConfig block: multiDexEnabled = true. For apps with minSdk 21 or higher, this is all you need — the Android runtime handles loading multiple DEX files. For lower minSdk, add the legacy multidex dependency: implementation("androidx.multidex:multidex:2.0.1") and extend MultiDexApplication or override attachBaseContext to call MultiDex.install(context). The legacy multidex library has a cold start performance penalty on pre-Lollipop devices because secondary DEX files must be extracted and loaded at startup. This is a strong incentive to keep minSdk at 21+ in new projects.
The longer-term solution to the method limit is reducing method references, not just enabling multidex. R8 with minifyEnabled = true aggressively removes unused methods and reduces the method count — often dramatically. An app that exceeds 64K methods without R8 may drop well below the limit with R8 enabled. Run ./gradlew assembleRelease and check the method count with the APK Analyzer or dexcount-gradle-plugin (./gradlew countDebugDexMethods) to track your method count over time. Adding this metric to CI with a threshold alert prevents slow creep past the limit — catching it early, before you're forced to enable multidex and deal with its startup cost on older devices.
- 65K limit: DEX format's 16-bit method reference ceiling — ~65,536 methods per file
- multiDexEnabled=true: tells D8/R8 to generate multiple DEX files when needed
- minSdk 21+: ART natively supports multidex — no multidex library needed
- R8 in release: shrinks methods well below 64K for most apps — multidex mainly a debug concern
- Analyze APK: shows method count per DEX file — use to verify you're below the limit
"In 2025 the 64K limit is mostly a solved problem: set minSdk to 21 (covers 99%+ of devices), enable multiDexEnabled=true, and enable R8. Release builds with R8 rarely hit the limit because R8 strips unused code. Debug builds sometimes hit it — that's when multidex actually kicks in, and why you might see slower debug app launch times."
32. How do you use resource qualifiers and density-specific assets? How does this affect APK size?
Android selects the best matching resource at runtime based on device qualifiers — screen density, locale, API level, orientation. Providing too many density variants bloats the APK. AAB and ABI splits eliminate this by delivering only the right assets per device.
// Resource qualifier folders: // res/drawable/ → default (no qualifier) // res/drawable-mdpi/ → 160dpi (1x) // res/drawable-hdpi/ → 240dpi (1.5x) // res/drawable-xhdpi/ → 320dpi (2x) // res/drawable-xxhdpi/ → 480dpi (3x) ← most modern phones // res/drawable-xxxhdpi/ → 640dpi (4x) ← high-end phones // res/drawable-nodpi/ → never scaled (used for exact-pixel assets) // Problem: shipping 5 PNG versions for every icon = 5x the asset size // Universal APK includes ALL densities — user downloads all even if using only xxhdpi // Solution 1: Vector drawables — one file for all densities // res/drawable/ic_arrow.xml → scales to any density at runtime // ✅ Zero density variants needed android { defaultConfig { vectorDrawables.useSupportLibrary = true } } // Solution 2: AAB density splits — Play delivers only the right density // Upload AAB → user on xxhdpi gets only xxhdpi resources // No config needed — Play does it automatically from AAB // Solution 3: Restrict density for APK direct distribution android { splits { density { isEnable = true reset() include("xxhdpi", "xxxhdpi") // 95%+ of modern devices compatibleScreens("normal", "large", "xlarge") } } } // Other useful qualifiers: // res/values-v26/ → API 26+ only strings/styles // res/layout-land/ → landscape orientation // res/values-night/ → dark mode colors // res/values-en/ → English strings (override defaults)
Android resource qualifiers let you provide different assets for different device configurations without conditional code. Folder suffixes like drawable-hdpi/, drawable-xhdpi/, drawable-xxhdpi/, and drawable-xxxhdpi/ contain the same image at different pixel densities — the system automatically picks the best match for the device. For screen size: layout-sw600dp/ for tablets (small width 600dp+). For dark mode: values-night/ for dark-themed colors. For locale: values-fr/ for French strings. The res/ folder without a qualifier is the default fallback when no qualified version matches. The build system merges all qualified resources at compile time — there's no runtime switching cost.
Resource qualifiers directly affect APK size because all density variants are bundled unless you use AAB (which strips unused densities at distribution time) or explicitly configure APK splits. aaptOptions { cruncherEnabled = false } disables PNG crunching, which can speed up debug builds at the cost of slightly larger debug APKs. For production, using vector drawables (VectorDrawable / AnimatedVectorDrawable) instead of rasterised PNGs eliminates density variants entirely — a single SVG-based XML file scales perfectly at all densities. This is the recommended approach for icons and simple illustrations; complex photographs or textures that degrade when vectorised should use density-qualified WebP assets instead.
Build-time resource merging follows a priority order: variant-specific source sets override flavor-specific source sets, which override build-type source sets, which override the main source set. This means a file at src/paidRelease/res/drawable/logo.png overrides src/paid/res/drawable/logo.png, which overrides src/main/res/drawable/logo.png. Understanding this merge order lets you maintain white-label apps cleanly: a client-specific flavor source set overrides just the branding assets (logo, color palette, app name) while the main source set contains everything else. The mergeDebugResources task produces a merged resource folder you can inspect to debug unexpected resource overrides — look at build/intermediates/merged_res/.
- Qualifiers: Android picks the closest matching resource folder at runtime — density, locale, API, orientation
- Vector drawables: single XML scales to any density — eliminates 5 PNG variants per icon
- AAB density splits: Play automatically delivers only the matching density to each device
- density splits for APK: manually restrict to xxhdpi+xxxhdpi — covers 95%+ of modern devices
- nodpi qualifier: exact-pixel assets like notification icons — never scaled by the system
"The modern recommendation: use vector drawables for all icons and UI assets (no density folders needed), use WebP for photographs and complex images (one file per image), publish as AAB (Play handles density delivery). Following these three rules eliminates the density bloat problem entirely — you never need to think about mdpi/hdpi/xhdpi/xxhdpi folders again."
33. Scenario: How do you implement baseline profiles to speed up app startup and reduce jank?
Baseline Profiles tell the Android Runtime (ART) which classes and methods to pre-compile ahead of time. Without them, ART JIT-compiles code on first run — causing startup jank. With them, critical code paths are compiled during app install.
// implementation("androidx.profileinstaller:profileinstaller:1.3.1") // androidTestImplementation("androidx.benchmark:benchmark-macro-junit4:1.2.3") // Step 1: Create a Macrobenchmark test to generate the profile @RunWith(AndroidJUnit4::class) class BaselineProfileGenerator { @get:Rule val rule = BaselineProfileRule() @Test fun generateBaselineProfile() { rule.collect (packageName = "com.example.app") { // Describe the critical user journey pressHome() startActivityAndWait() // app launch device.waitForIdle () device.findObject (By.text("Products")). click() // navigate to key screen device. waitForIdle () } // Generates: src/main/baseline-prof.txt } } // Step 2: Baseline profile generated (baseline-prof.txt) // Lcom/example/app/MainActivity; // Lcom/example/app/ProductViewModel; // Lcom/example/repository/ProductRepository; // ... (hundreds of class/method patterns) // Step 3: Include in build (automatic if file exists in src/main/) // AGP bundles baseline-prof.txt into the APK/AAB automatically // Step 4: Verify with Macrobenchmark @Test fun startupBenchmark() { benchmarkRule.measureRepeated(packageName = "com.example.app", metrics = listOf (StartupTimingMetric()), startupMode = StartupMode.COLD, iterations = 5 ) { pressHome(); startActivityAndWait() } // Reports: timeToFullDisplayMs median: 850ms (was 1400ms before profile) } // What baseline profiles improve: // ✅ Cold startup: 30-40% faster (critical code pre-compiled) // ✅ Frame jank on first scroll: reduced (rendering code pre-compiled) // ✅ Works from first launch — no warm-up period needed
Baseline Profiles are a set of class and method patterns pre-compiled by the Android Runtime (ART) before the app's first run. Without a baseline profile, ART interprets bytecode the first time each method runs (slow) and then JIT-compiles frequently-used methods over time. This produces the typical "jank on first launch" experience. A baseline profile tells ART to ahead-of-time compile the specified methods during app installation, so they execute as native code from the very first launch. Google reports 20–40% startup time improvement and reduced jank during first user interactions for apps that adopt baseline profiles.
Generating a baseline profile requires the Macrobenchmark library and a connected device or emulator. Create a BaselineProfileGenerator test class in a dedicated :baselineprofile module: annotate it with @BaselineProfileRule and write rule.collect { startActivityAndWait() } with the user journeys you want to cover. Run ./gradlew :app:generateBaselineProfile — this launches the app on a device with profiling enabled, traces the methods called during your user journeys, and writes a baseline-prof.txt file to src/main/. The AGP includes this file in the release APK/AAB. The ProfileInstaller library installs the profile at app startup on devices that support it (API 28+, though full AOT compilation happens on API 33+).
Maintaining baseline profiles over time is as important as generating them initially. Profiles must be regenerated whenever major new user flows are added or existing flows significantly change — a profile that doesn't cover the current critical paths provides no benefit for those paths. Integrate profile generation into your CI release workflow: regenerate on every release branch and commit the updated baseline-prof.txt alongside the code changes. The :baselineprofile module's tests can also double as startup regression tests — if the measured startup time exceeds a threshold, fail the CI build. This creates a feedback loop where performance regressions are caught before they reach users.
- ART JIT problem: without profiles, code is compiled on first use — causes startup jank
- Baseline profile: list of class/method patterns to pre-compile during app install
- BaselineProfileRule: Macrobenchmark API to record which code runs during critical user journeys
- AGP integration: place baseline-prof.txt in src/main/ — AGP bundles it automatically
- 30-40% startup improvement: measured on real devices with Macrobenchmark
"Baseline profiles are one of the highest-impact, lowest-effort build improvements available in 2025. Generate a profile for your app's startup and main navigation flow — it takes 30 minutes to set up and delivers a 30-40% startup improvement for every user, on every install. Google requires them for featured Play Store apps."
34. What is the Android Gradle Plugin (AGP)? How do you upgrade it safely?
AGP is the Gradle plugin that knows how to build Android projects — it defines all the android {} DSL blocks, build types, flavors, and tasks. AGP versions are tightly coupled to Gradle versions, Kotlin versions, and Android Studio versions. Upgrading requires care.
// AGP version in libs.versions.toml [versions] agp = "8.7.3" kotlin = "2.1.0" gradle = "8.11.1" // in gradle-wrapper.properties [plugins] android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } // Compatibility matrix — MUST check before upgrading // AGP 8.7 → Gradle 8.9+, Kotlin 1.9+, Studio Meerkat // AGP 8.6 → Gradle 8.7+, Kotlin 1.9+, Studio Ladybug // AGP 8.5 → Gradle 8.7+, Kotlin 1.9+, Studio Koala // Always check: https://developer.android.com/build/releases/gradle-plugin // Safe upgrade process: // 1. Check compatibility matrix (AGP ↔ Gradle ↔ Kotlin ↔ Studio) // 2. Upgrade on a branch // 3. Fix deprecation warnings before they become errors // ./gradlew --warning-mode=all assembleDebug → shows all deprecation warnings // 4. Run full test suite and lint // 5. Check migration guide for breaking changes // Common AGP 8.x breaking changes: // BuildConfig generation: must now opt-in → buildFeatures { buildConfig = true } // namespace required: must set android.namespace in every module // Removed: compile, provided configurations (use implementation, compileOnly) // Android Studio's AGP upgrade assistant // Tools → AGP Upgrade Assistant → select target version → preview changes // Auto-fixes many breaking changes — use it first
The Android Gradle Plugin (AGP) is the Gradle plugin maintained by Google that adds all Android-specific build capabilities: compiling resources with aapt2, running D8/R8 for DEX compilation, merging manifests, packaging APKs and AABs, running Lint, and defining all the Android-specific DSL blocks (android { }, buildTypes { }, productFlavors { }). AGP is versioned separately from Gradle itself — a given AGP version has a required minimum Gradle version and a maximum tested Kotlin version. The compatibility matrix is documented in the Android Studio release notes. Keeping AGP current is important because newer versions add performance improvements, new features, and deprecate old behaviours.
Safe AGP upgrades follow a specific sequence. First, check the AGP compatibility matrix for the required Gradle version — you'll likely need to update the Gradle wrapper first. Update gradle-wrapper.properties, then update the AGP version in the Version Catalog or root build script. Run ./gradlew --version to confirm the Gradle wrapper picked up the change. Run ./gradlew assembleDebug and read every deprecation warning — AGP upgrade warnings are often silent in the current version but become errors in the next. Fix all deprecations before moving forward. Run the full test suite and a release build to catch any behaviour changes in R8 or resource processing.
AGP upgrades commonly break builds through three mechanisms. First, API changes in the AGP variant API — custom build logic that accesses variant.javaCompileOptions or similar APIs may need updating when AGP reorganises its public API surface. Second, changed default behaviours — AGP 8.x changed defaults for non-transitive R classes, namespace declarations, and resource shrinker behaviour; projects relying on old defaults need explicit opt-outs or code fixes. Third, R8 version bundled with AGP — each AGP version bundles a specific R8 version, and a new R8 may apply more aggressive optimisations that expose latent keep rule gaps. Always test a release build (with R8 enabled) as part of any AGP upgrade, not just a debug build.
- AGP defines the build: all android {} DSL, tasks, and build pipeline are provided by AGP
- Compatibility matrix: AGP version must match supported Gradle and Kotlin versions exactly
- AGP Upgrade Assistant: Android Studio tool that auto-applies migration changes
- --warning-mode=all: surface all deprecation warnings before they become build-breaking errors
- Namespace required: AGP 8.x requires namespace in every module's build.gradle.kts
"Always use Android Studio's AGP Upgrade Assistant (Tools menu) before manually editing versions. It understands the full migration path — adds namespace, migrates deprecated APIs, updates wrapper. Then run --warning-mode=all to find any remaining deprecations. Upgrading AGP without addressing deprecation warnings is how you get a build that breaks 6 months later when the deprecated API is finally removed."
35. Scenario: How do you set up a remote Gradle build cache to share build outputs across the whole team?
A remote build cache shares task outputs across all developer machines and CI. When Alice builds a module and pushes the output, Bob's machine downloads it instead of rebuilding — saving minutes per build across the whole team.
// Local cache (default) — only on your machine // org.gradle.caching=true in gradle.properties // Caches in: ~/.gradle/caches/build-cache/ // Remote cache — shared across machines and CI // Options: // 1. Gradle Enterprise (paid, most powerful) // 2. Develocity (free tier available) // 3. Build Cache Node (self-hosted HTTP cache) // 4. GitHub Actions Cache + custom setup // settings.gradle.kts — configure remote cache buildCache { local { isEnabled = true isPush = true // also write to local cache } remote<HttpBuildCache> { url = uri("https://cache.example.com/cache/") isPush = System.getenv ("CI") != null // only CI pushes to remote credentials { username = System.getenv ("CACHE_USERNAME") ?: "" password = System.getenv("CACHE_PASSWORD") ?: "" } isEnabled = true } } // Critical: tasks must be cacheable // Only tasks annotated with @CacheableTask or built-in cached tasks benefit // All standard Gradle and Android tasks are cacheable // Custom tasks: annotate with @CacheableTask + declare @InputFiles / @OutputFiles // Verify cache hits on CI: // ./gradlew assembleDebug --build-cache // Look for "FROM-CACHE" in output — means output was fetched, not built // Typical improvement: // CI clean build without cache: 8 minutes // CI clean build with remote cache + warm cache: 90 seconds // (all unchanged module outputs fetched from cache)
A remote Gradle build cache stores task output artifacts on a shared server so that any developer or CI machine that runs the same task with the same inputs can retrieve the cached output instead of re-executing the task. The cache key is a hash of all task inputs: source files, dependency JARs, compiler flags, and Gradle version. If the key matches a cached entry, Gradle downloads the output and marks the task as FROM-CACHE, skipping execution entirely. This is transformative for CI: when a PR only changes one module, all other modules' compilation and test tasks hit the cache — a clean CI build effectively becomes an incremental build across the entire team.
Setting up a remote cache requires a cache server (Gradle Enterprise, Develocity, or a self-hosted instance like gradle/gradle-build-action's GitHub Actions cache, or a simple HTTP cache server). Configure it in settings.gradle.kts: buildCache { remote(HttpBuildCache::class) { url = uri("https://cache.example.com/cache/") credentials { username = "..."; password = "..." } push = System.getenv("CI") == "true" } }. The critical detail: only CI should push to the cache (push = true gated on the CI environment variable). Developer machines should only pull — allowing developer machines to push risks cache poisoning from local environment differences that aren't captured in the task inputs.
Cache hit rate is the metric to monitor. A well-configured project with a stable remote cache should achieve 80–95% cache hit rate on CI for PRs that don't touch shared modules. Low hit rates are caused by non-cacheable tasks (tasks with overlapping output directories, tasks that write timestamps into outputs, or tasks that read environment variables not declared as task inputs), volatile inputs (absolute file paths in inputs), or missing @Input/@OutputFile annotations on custom tasks. Use --info or Gradle's build scan to see why specific tasks missed the cache. Fixing cache misses for the longest-running tasks — usually Kotlin compilation and test execution — has the highest ROI on build time reduction.
- Remote cache: shares task outputs across all developer machines and CI runs
- isPush=CI only: developers read from cache, CI writes to it — prevents cache pollution from local WIP
- FROM-CACHE: Gradle prints this for cache hits — verify with --build-cache flag
- @CacheableTask: annotation for custom tasks — declares inputs/outputs so Gradle can cache
- 5-10x speed: warm remote cache can reduce CI builds from 8 minutes to under 90 seconds
"The isPush=CI pattern is important: only CI pushes to the remote cache, developers only pull. This prevents a developer's partial or broken build from poisoning the cache that other developers read. CI builds from a clean state — their outputs are trustworthy. Developer machines may have local modifications that would produce different outputs."
36. What is the difference between debuggable and non-debuggable builds? What are the security implications?
isDebuggable=true enables debugger attachment, allows log reading, and disables security protections. Shipping a debuggable APK to production is a serious security vulnerability — attackers can attach a debugger and inspect the app's memory and network traffic.
// What isDebuggable=true enables: // ✅ Attach debugger via Android Studio // ✅ Read Logcat output (adb logcat) // ✅ Run VM tool commands // ✅ Bypass some security checks (useful for testing) // ❌ In production: attacker can read memory, intercept network, bypass checks android { buildTypes { debug { isDebuggable = true // ✅ fine for development } release { isDebuggable = false // ✅ must be false — this is the default isMinifyEnabled = true } } } // Security checks that debuggable disables: // 1. SafetyNet / Play Integrity — detects debuggable APKs // Attestation fails if app is debuggable → can't access backend APIs that require attestation // 2. SSL pinning bypass via debugger: // Debuggable app → attach Frida → hook OkHttp → bypass SSL pinning // Non-debuggable + root detection: much harder to hook // 3. Runtime permission checks: // Debuggable apps can have permissions granted via adb without user consent // Verify your release is not debuggable: // aapt2 dump badging app-release.apk | grep -i debug // Should NOT show "application-debuggable" // Detect debuggable at runtime (for extra security): fun isDebuggable(context: Context): Boolean { return context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0 } if (isDebuggable(context) && !BuildConfig.DEBUG) { // Should never happen in release — someone may have tampered with the APKcrashOrAlert () }
A debuggable build (debuggable = true) instructs the Android runtime to allow debugger attachment, expose the app's data directory via run-as, enable JDWP (Java Debug Wire Protocol), and allow memory profiling tools to attach. It also disables certain security checks — most critically, debuggable apps skip the TrustManager validation in older debugging setups, allow arbitrary code injection via the debugger, and can be profiled with tools that require debuggable access. For a production app, shipping a debuggable APK is a severe security vulnerability: any user with physical access to the device can attach Android Studio, read all in-memory data, and execute arbitrary code in the app's process.
The security implications go beyond debuggability. android:extractNativeLibs="true" in a non-debuggable build allows native libraries to be extracted to the filesystem, where they are more accessible to reverse engineers. Non-debuggable builds with R8 obfuscation make static analysis significantly harder but not impossible — tools like Jadx can decompile the DEX back to readable Java. For truly sensitive logic (payment processing, DRM), use native code with additional obfuscation (LLVM-Obfuscator), or move the logic server-side entirely. The combination of non-debuggable + R8 full mode + root detection + certificate pinning is the practical defence-in-depth stack for a production Android app.
A common CI mistake is accidentally shipping a debuggable release build. Always add a Lint check or build verification step that fails if the release APK has android:debuggable="true" in the merged manifest: aapt2 dump badging release.apk | grep -i debuggable should return nothing for a proper release build. The AGP sets debuggable = false for the release build type by default, but a misconfigured custom build type or a manifest overlay with android:debuggable="true" can override this. This check should be a mandatory CI gate on every release build, not a manual pre-release checklist item.
- isDebuggable=false: the default for release — never ship debuggable APKs to production
- Debugger attachment: debuggable apps can have memory inspected and code patched at runtime
- Play Integrity: Google's attestation API rejects debuggable apps — backend APIs that require integrity fail
- Frida/Xposed: popular Android hooking tools work far more easily on debuggable apps
- Runtime check: verify FLAG_DEBUGGABLE at runtime as an extra tamper-detection measure
"The most common mistake: a developer accidentally sets isDebuggable=true in the release build type to diagnose a production issue, then forgets to revert. Verify every release build: 'aapt2 dump badging app-release.apk | grep debuggable' — if it shows anything, the build is compromised. Add this check to your CI release pipeline."
37. Scenario: How do you configure obfuscation to protect your app against reverse engineering while keeping it functional?
R8 obfuscation renames classes, methods, and fields to single-letter names — making reverse-engineered code extremely hard to understand. The challenge is keeping obfuscation aggressive enough to protect IP while preserving functionality via careful keep rules.
// proguard-rules.pro — obfuscation configuration // Make class names unpredictable (enable by default with R8) -obfuscationdictionary obfuscation-dict.txt // custom rename dictionary -classobfuscationdictionary obfuscation-dict.txt -packageobfuscationdictionary obfuscation-dict.txt // obfuscation-dict.txt — confusing rename targets // O (letter O, looks like 0) // l (letter l, looks like 1 or I) // I (capital I, looks like l or 1) // Makes decompiled code: class O { void l(I lI) { I Il = new O(); } } // What to obfuscate (default — everything not kept): // com.example.core.business.logic.** → obfuscated (your IP) // com.example.feature.payment.** → obfuscated (sensitive logic) // What NOT to obfuscate (must keep readable): // Data models used with Gson/Retrofit (reflection-based parsing) -keepclassmembers class com.example.api.models.** { *; } // Exception handling — keep exception class names for crash reporting -keepnames class * extends java.lang.Exception // Native methods — JNI bridges must keep exact names -keepclasseswithmembernames class * { native <methods>; } // Serialization — Parcelable, Serializable -keepclassmembers class * implements android.os.Parcelable { public static final android.os.Parcelable$Creator *; } // Verify obfuscation strength: // jadx-gui app-release.apk → view decompiled code // Check: are class names single letters? Are your business logic classes unreadable? // If you can read the decompiled logic easily → obfuscation too weak
Effective obfuscation in Android starts with enabling R8 full mode — the most aggressive optimisation level available. In your gradle.properties, add android.enableR8.fullMode=true. Full mode enables optimisations that the default mode skips out of backwards compatibility: more aggressive class merging (combining multiple small classes into one), interface removal (inlining single-implementation interfaces), and enum class reduction (converting enums with no methods to integer constants). Combined with obfuscation, full mode produces code that is significantly harder to reverse engineer because the class hierarchy is flattened and the mapping between the obfuscated bytecode and the original structure is less obvious.
The keep rules file requires careful curation for obfuscation to be effective. Over-keeping with blanket rules like -keep class com.example.** { *; } defeats obfuscation entirely — every class in your package is kept with its original name. Instead, keep only the minimum required: the Application class (referenced by name in the manifest), Activities and Services registered in the manifest (AGP generates keep rules for these automatically), and classes accessed via reflection or JNI. Use -keepnames for classes that must retain their names but whose members can be renamed. Use @Keep annotation on individual members rather than broad class-level rules where possible.
Shipping the mapping file securely is as important as the obfuscation itself. The mapping.txt file is the master key to deobfuscating your app — anyone who has it can completely reverse the obfuscation. Store it in a private, access-controlled location (not in the public Git repository). Upload it to Firebase Crashlytics or Google Play Console so crashes are automatically deobfuscated in dashboards — this upload uses the Gradle plugin and happens as part of the CI release build. Tag each mapping file with the versionCode and versionName it corresponds to and retain it for the full supported lifetime of that version, since users on old versions may still send crashes months after a newer version ships.
- Custom dictionary: rename to l/I/O characters — makes decompiled code visually unreadable
- Business logic obfuscated: payment algorithms, content protection, proprietary formulas
- Exception names kept: crash reporting needs readable exception class names for triage
- JNI methods kept: native bridge methods must match exact JNI naming convention
- jadx verification: decompile release APK to verify your IP is actually obfuscated
"Obfuscation slows down attackers but doesn't stop determined ones. Layer it with other protections: obfuscation + root/debugger detection + SSL pinning + Play Integrity attestation. The goal is raising the cost of attack high enough that the effort isn't worth it for most attackers. Always verify by decompiling your own release APK with jadx — see what an attacker sees."
38. How do you use source sets in Android? How do flavors and build types contribute their own source sets?
Source sets define where Gradle looks for code, resources, and manifests for each build variant. Every build type and flavor gets its own source set directory — you can override or extend code per variant without if/else BuildConfig checks.
// Source set lookup order (highest priority first): // 1. src/freeIndiaDebug/ ← full variant (flavor1 + flavor2 + buildType) // 2. src/freeIndia/ ← flavor1 + flavor2 // 3. src/freeDebug/ ← flavor1 + buildType // 4. src/indiaDebug/ ← flavor2 + buildType // 5. src/free/ ← flavor1 dimension // 6. src/india/ ← flavor2 dimension // 7. src/debug/ ← build type // 8. src/main/ ← always included (base) // Example: different analytics implementation per flavor // src/main/java/com/example/Analytics.kt — interface // src/free/java/com/example/Analytics.kt — free implementation (basic) // src/premium/java/com/example/Analytics.kt — premium implementation (full) // ❌ Without source sets: class Analytics { fun track(event: String) { if (BuildConfig.IS_PREMIUM) { fullTrack(event) } else { basicTrack(event) } } } // ✅ With source sets — no if/else needed, cleaner separation: // src/free/java/Analytics.kt: class Analytics { fun track(event: String) { basicTrack(event) } } // src/premium/java/Analytics.kt: class Analytics { fun track(event: String) { fullTrack(event) } } // Custom source set configuration android { sourceSets {getByName ("main") { java.srcDirs ("src/main/kotlin", "src/generated/kotlin") res.srcDirs ("src/main/res", "src/main/res-extra") } } } // src/debug/res/values/strings.xml — adds debug-only strings // src/release/res/values/strings.xml — overrides prod strings // Resources merge across source sets — later set wins on conflict
Source sets in Android define which source files, resources, and assets belong to each variant. Every module has at least the main source set (src/main/java/, src/main/res/, src/main/AndroidManifest.xml). Build types and flavors each get their own source set that overlays the main one. The merge rules: Java/Kotlin source files are additive (all files from all matching source sets are compiled together — a class in src/debug/java/ is added to the compilation, not replacing anything in main). Resource files follow override semantics — a file at the same resource path in a flavor source set replaces the one in main. Manifests are merged by the manifest merger tool according to merger rules.
Custom source set paths can be declared explicitly: sourceSets { main { java.srcDirs("src/main/java", "src/generated/java") } }. This is used for code generation workflows — generated source code lives in a separate directory and is added to the source set so the IDE recognises it and the compiler picks it up. Build type source sets contribute before flavor source sets in the priority order, which is counterintuitive — the priority is: variant-specific > flavor > build type > main. So src/paid/ overrides src/release/, which overrides src/main/. Understanding this order prevents debugging confusion when a resource override isn't applying as expected.
Test source sets follow the same pattern with test (unit tests) and androidTest (instrumentation tests) as the top-level type. src/test/java/ contains unit tests, src/androidTest/java/ contains instrumentation tests, and these can also have flavor and build type variants: src/testPaid/java/ for paid-variant-specific unit tests. This is useful when a flavor introduces an entirely different implementation of a feature that requires different test coverage. The AGP generates separate test tasks for each variant combination: testFreeDebugUnitTest, testPaidDebugUnitTest — this lets you run only the tests relevant to the variant you're changing rather than the full matrix on every local test run.
- Priority order: full variant > flavor combos > individual flavors > build type > main
- Source set substitution: place the same class in flavor source sets — Gradle picks the right one
- No BuildConfig if/else: source sets provide a clean separation — the right code is compiled in
- Resources merge: all resource files from all active source sets are merged — later sets override conflicts
- Custom srcDirs: add generated code directories or split resources across folders
"Source sets eliminate the 'if BuildConfig.IS_PREMIUM' anti-pattern. Each flavor gets its own implementation of a class — the correct one is compiled in, dead code never reaches the APK. Free users don't have premium code in their APK at all. With BuildConfig if/else, both code paths are compiled in — the premium code just never runs."
39. Scenario: Your build script has grown to 200 lines of complex logic. How do you refactor it to be maintainable?
Large build scripts are a maintenance problem — hard to read, test, and reuse. The refactoring path is: extract reusable config into convention plugins, extract custom task logic into buildSrc or build-logic, and extract utility functions into Gradle extensions.
// Before: 200-line monolithic build.gradle.kts — hard to maintain // After: 3 clean separations // 1. Convention plugins (build-logic/) — shared config across modules // Already covered in Q12 — AndroidFeaturePlugin, AndroidLibraryPlugin // 2. Gradle extensions — reusable utility functions // build-logic/convention/src/main/kotlin/Extensions.kt fun Project.configureAndroid (extension: CommonExtension<*, *, *, *, *, *>) { extension.apply { compileSdk = 35 defaultConfig { minSdk = 24 } compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } }tasks .withType <KotlinCompile>().configureEach { compilerOptions { jvmTarget.set (JvmTarget.JVM_17) } } } // Used in AndroidFeaturePlugin: configureAndroid(extension) // apply all shared Android config // 3. Custom Gradle task classes — complex task logic in its own file abstract class GenerateChangelogTask : DefaultTask() { @get:InputFile abstract val rawChangelog: RegularFileProperty @get:OutputFile abstract val formattedChangelog: RegularFileProperty @TaskAction fun generate() { // complex logic here, not in build.gradle.kts val raw = rawChangelog.get ().asFile ().readText() formattedChangelog. get ().asFile ().writeText (format (raw)) } } // Registered in build.gradle.kts — just 3 lines: val changelog =tasks .register <GenerateChangelogTask>("generateChangelog") { rawChangelog.set (layout .projectDirectory .file ("CHANGELOG.md")) formattedChangelog.set (layout .buildDirectory .file ("changelog.txt")) }
The right tool for refactoring complex build script logic is Convention Plugins — self-contained Gradle plugins written as Kotlin files in buildSrc/src/main/kotlin/ or in a dedicated :build-logic included build. A convention plugin encapsulates a reusable build configuration pattern. For example, a android-library-convention plugin sets up all the standard Android library configuration: compileSdk, minSdk, Kotlin JVM target, test dependencies, Lint rules, and Detekt configuration. Each library module's build.gradle.kts shrinks from 80 lines to 3 lines: apply the convention plugin, declare module-specific dependencies, done.
The buildSrc vs :build-logic included build distinction matters at scale. buildSrc is a special directory Gradle automatically compiles before the main build, but any change to any file in buildSrc invalidates the configuration cache for the entire build — even changing a comment in an unused plugin file triggers a full reconfiguration. A :build-logic included build (declared in settings.gradle.kts with includeBuild("build-logic")) participates in the normal build lifecycle and can be incrementally compiled. For large projects, migrating from buildSrc to :build-logic often reduces configuration time significantly because most build logic changes only affect specific plugins, not the entire build.
When extracting logic into convention plugins, resist the temptation to create a single monolithic plugin that handles everything. Compose multiple small, focused plugins instead: android-application-convention (for app modules), android-library-convention (for library modules), android-compose-convention (adds Compose compiler options and dependencies), android-hilt-convention (adds Hilt dependencies and KAPT/KSP). Each module's build script then reads like a declarative list of capabilities: plugins { id("android-library-convention"); id("android-compose-convention") }. This approach is used in Now in Android (Google's architecture sample) and has become the community standard for large multi-module projects.
- Convention plugins: extract shared Android config — compileSdk, Kotlin, Hilt — out of every module
- Extension functions on Project: reusable config blocks called from multiple convention plugins
- Custom task classes: move complex task logic out of build scripts — testable, type-safe, cacheable
- @InputFile/@OutputFile: proper task declaration enables UP-TO-DATE checks and caching
- Abstract task properties: lazy evaluation — file paths resolved at execution time, not configuration
"The refactoring signal: if your build.gradle.kts has if/else, loops, or functions beyond simple declarations, extract them. Convention plugins for shared config, extension functions for utilities, task classes for complex logic. A clean build.gradle.kts is plugins{} + dependencies{} + module-specific overrides only — maybe 20 lines."
40. What are the most useful Gradle command-line options for Android development?
Knowing the right Gradle flags saves significant time during development and debugging. These commands go beyond the basics and expose what's really happening in your build.
// ESSENTIAL FLAGS // Build a specific task ./gradlew :app:assembleDebug // build only :app module debug ./gradlew :feature:home:testDebug // test only :feature:home module // Skip tests (faster iteration) ./gradlew assembleDebug -x test -x lint // exclude test and lint tasks // Dry run — see what would run without running it ./gradlew assembleRelease --dry-run // Performance flags ./gradlew assembleDebug --parallel // parallel module execution ./gradlew assembleDebug --build-cache // enable build cache for this run ./gradlew assembleDebug --daemon // use Gradle daemon (default) ./gradlew assembleDebug --no-daemon // no daemon (useful for CI debugging) // DEBUGGING FLAGS ./gradlew assembleDebug --info // verbose build output ./gradlew assembleDebug --debug // very verbose (usually too much) ./gradlew assembleDebug --warning-mode=all // surface all deprecation warnings ./gradlew assembleDebug --stacktrace // full stack trace on build failure ./gradlew assembleDebug --scan // upload to Gradle Build Scan (browser report) // DEPENDENCY INSPECTION ./gradlew app:dependencies // full dependency tree ./gradlew app:dependencyInsight --dependency okhttp // why is okhttp included? ./gradlew app:dependencies --configuration releaseRuntimeClasspath // TASK INSPECTION ./gradlew tasks // list all available tasks ./gradlew tasks --all // list ALL tasks including internal ./gradlew :app:assembleDebug --rerun-tasks // force re-run even if UP-TO-DATE // CLEAN BUILDS ./gradlew clean assembleDebug // clean then build ./gradlew clean // delete build/ directories only
The most impactful Gradle flags for daily development are --parallel (run tasks in parallel across modules — also set permanently via org.gradle.parallel=true in gradle.properties), --build-cache (enable the build cache for the current invocation — also set permanently via org.gradle.caching=true), and --configuration-cache (enable the configuration cache — reuses the task graph from a previous build when build scripts haven't changed). These three flags together can reduce a clean build of a large project from minutes to seconds when the cache is warm. The configuration cache is still being adopted and some plugins don't yet support it — check compatibility before enabling permanently.
Diagnostic flags are equally important. --info prints detailed task execution information including why tasks were not up-to-date. --debug is more verbose and usually too noisy — --info is the right level for investigating build issues. --scan generates a Gradle Build Scan (requires accepting the Gradle terms of service) — a browser-based interactive build analysis tool showing task timeline, cache performance, test results, and dependency resolution. For CI, build scans are invaluable for diagnosing intermittent failures and slow tasks. --dry-run prints all tasks that would execute without running them, useful for understanding which tasks a given Gradle command invokes. --rerun-tasks forces all tasks to execute even if up-to-date — useful when diagnosing caching issues.
Module targeting flags reduce work for common development scenarios. ./gradlew :feature:profile:assembleDebug builds only the :feature:profile module and its dependencies rather than the full project — dramatically faster than assembleDebug at the root. ./gradlew :app:testDebugUnitTest --tests "com.example.MyClassTest" runs a single test class. -x lint skips Lint for a faster build when you only need the APK. -x test skips tests for a build-only run. Combining these: ./gradlew :feature:cart:assembleDebug -x lint --build-cache --parallel is often 10x faster than the equivalent full project build. Document your team's most common targeted build commands in a README or Makefile.
- Module-specific tasks: :module:task — only builds what you need, much faster
- -x test: skip test task — useful for fast debug builds when tests are slow
- --scan: generates a detailed web report — shows task timeline, cache hits, and bottlenecks
- dependencyInsight: shows exactly why a dependency is in your graph — traces the path
- --rerun-tasks: forces all tasks to run regardless of UP-TO-DATE — useful when debugging caching issues
"Three commands I use daily: (1) ./gradlew :feature:home:assembleDebug — build just the module I'm working on, not the whole app. (2) ./gradlew app:dependencyInsight --dependency okhttp — trace why a dependency version was chosen. (3) ./gradlew assembleDebug --scan — when a build is slow and I need to know exactly which task is the bottleneck."
41. What is Gradle's Kotlin DSL vs Groovy DSL? Why is Kotlin DSL now preferred?
Gradle supports both Groovy (.gradle files) and Kotlin (.gradle.kts files) for build scripts. Kotlin DSL is now the official recommendation — it offers IDE autocomplete, type safety, refactoring support, and compile-time error detection that Groovy lacks.
// GROOVY DSL — build.gradle (old style) plugins { id 'com.android.application' id 'org.jetbrains.kotlin.android' } android { compileSdk 35 defaultConfig { applicationId "com.example.app" minSdk 24 } } dependencies { implementation 'androidx.core:core-ktx:1.12.0' // string literal — typo-prone } // KOTLIN DSL — build.gradle.kts (recommended) plugins { alias(libs.plugins.android.application) // type-safe accessor alias(libs.plugins.kotlin.android) } android { compileSdk = 35 // = required in Kotlin DSL defaultConfig { applicationId = "com.example.app" minSdk = 24 } } dependencies { implementation(libs.androidx.core.ktx) // type-safe, autocomplete works } // Key Kotlin DSL advantages: // ✅ IDE autocomplete — Ctrl+Space shows all valid options // ✅ Compile-time errors — typos caught when you sync, not at runtime // ✅ Refactoring support — rename a variable and all references update // ✅ Navigation — Cmd/Ctrl+Click to jump to type definitions // ✅ Static type checking — wrong types are build errors // Gotchas in Kotlin DSL vs Groovy: // Kotlin requires = for property assignment: compileSdk = 35 (not compileSdk 35) // String concatenation: "${buildDir}/outputs" (not $buildDir/outputs) // Function calls require (): implementation(libs.retrofit) (not implementation libs.retrofit)
Groovy DSL (using build.gradle files) was the original Gradle scripting language and remained dominant for years because Groovy's dynamic typing made it easy to write concise build scripts. Kotlin DSL (using build.gradle.kts files) was introduced as a statically-typed alternative that enables IDE autocompletion, refactoring support, and compile-time type checking for build scripts. The fundamental technical difference: in Groovy DSL, implementation "com.example:library:1.0" works because Groovy allows method calls with unquoted strings and optional parentheses. In Kotlin DSL, implementation("com.example:library:1.0") is a normal Kotlin function call with type-checked parameters.
The practical developer experience difference is significant in large projects. In Groovy DSL, clicking on a task name or a dependency string in Android Studio navigates nowhere useful — the IDE has no type information to follow. In Kotlin DSL, pressing Cmd+Click on libs.retrofit.core navigates to the Version Catalog entry. Extension functions and properties defined in convention plugins get autocompletion. Build script errors are caught at IDE editing time, not only at build time. For a team new to Gradle, Kotlin DSL is dramatically more approachable: the build scripts look and behave like normal Kotlin code with the familiar IDE experience, rather than a dynamically-typed domain-specific language with its own conventions.
Migrating from Groovy to Kotlin DSL is mechanical but requires attention to syntax differences. String interpolation: Groovy's "${variable}" becomes Kotlin's "$variable" inside the same quotes, but property access often becomes explicit: android.defaultConfig.applicationId. Boolean properties: Groovy's debuggable true becomes Kotlin's isDebuggable = true (properties use is prefix for booleans). Method calls require parentheses in Kotlin. The Android Studio "Convert to Kotlin DSL" action (right-click the build.gradle file) handles most conversions automatically. The result won't compile immediately — expect to spend 30–60 minutes per complex build script fixing the remaining type errors — but the long-term IDE experience improvement is worth the one-time migration effort.
- Kotlin DSL: .gradle.kts files — type-safe, IDE autocomplete, compile-time errors
- Groovy DSL: .gradle files — dynamic, no type safety, errors surface at build time
- Google recommendation: Kotlin DSL is the official recommendation since AGP 8.x
- = required: Kotlin DSL uses property setter syntax — compileSdk = 35 not compileSdk 35
- Migration: rename .gradle to .gradle.kts and fix syntax — Android Studio helps automate this
"The practical difference: in Groovy DSL, if you misspell 'compileSdk' as 'compilSdk', the build silently ignores it. In Kotlin DSL, it's a compile error before the build even starts. That single benefit — catching typos at sync time instead of build time — makes Kotlin DSL worth the migration for any active project."
42. Scenario: How do you use the Macrobenchmark library to measure and improve app performance?
Macrobenchmark measures real user-visible performance — startup time, frame rendering, scroll smoothness — on a real device with release-like code. It's the only way to get accurate performance data because it uses the compiled, optimised app.
// Separate :macrobenchmark module // build.gradle.kts plugins {alias(libs.plugins.android.test) } android { targetProjectPath = ":app" experimentalProperties["android.experimental.self-instrumenting"] = true } dependencies { implementation(libs.benchmark.macro.junit4) } // app/build.gradle.kts — enable profiling in benchmark builds android { buildTypes { create("benchmark") { initWith( getByName ("release")) signingConfig = signingConfigs.getByName ("debug") // Enable profiling without debuggable proguardFiles("benchmark-rules.pro") } } } // Startup benchmark @RunWith(AndroidJUnit4::class) class StartupBenchmark { @get:Rule val benchmarkRule = MacrobenchmarkRule() @Test fun coldStartup() { benchmarkRule.measureRepeated ( packageName = "com.example.app", metrics =listOf (StartupTimingMetric()), compilationMode = CompilationMode.Full(), // simulate baseline profile startupMode = StartupMode.COLD, // kill process before each run iterations = 10 ) { pressHome() startActivityAndWait() // waits for Activity.onStart() } // Results in Android Studio: timeToInitialDisplay, timeToFullDisplay } @Test fun scrollBenchmark() { benchmarkRule. measureRepeated ( packageName = "com.example.app", metrics =listOf (FrameTimingMetric()), // frame rendering metrics startupMode = StartupMode.WARM, iterations = 5 ) { startActivityAndWait() device.findObject (By.res ("product_list")).fling (Direction.DOWN) } // Results: P50/P90/P99 frame times — spot jank } } // Run: ./gradlew :macrobenchmark:connectedBenchmarkAndroidTest
Macrobenchmark is the Android Jetpack library for measuring app-level performance metrics: startup time, scroll jank, animation smoothness, and rendering performance. Unlike Microbenchmark (which measures isolated Kotlin/Java code in a tight loop), Macrobenchmark launches the actual app process in a way that mirrors real user conditions — including cold start (process not in memory), warm start (process in memory but activity not), and hot start (activity in back stack). It drives the app via UiAutomator gestures and measures metrics like timeToInitialDisplay, timeToFullDisplay, frame timing (jank percentage), and memory allocation rate.
Setting up Macrobenchmark requires a dedicated :macrobenchmark module (separate from the app module) and a non-debuggable but testable build of the app — the benchmark build type. In the benchmark module, write test classes annotated with @LargeTest and use the MacrobenchmarkRule: rule.measureRepeated(packageName = "com.example.app", metrics = listOf(StartupTimingMetric()), startupMode = StartupMode.COLD, iterations = 5) { startActivityAndWait() }. Run with ./gradlew :macrobenchmark:connectedBenchmarkAndroidTest on a physical device (emulators give unreliable timing). Results are reported as median and percentile distributions across the iterations.
Integrating Macrobenchmark into CI requires a physical device lab (Firebase Test Lab, AWS Device Farm, or a self-hosted device running in a locked-down thermal state) because performance measurements on emulators are too noisy for regression detection. Run benchmarks on main branch merges only (not every PR — too expensive), record the median startup time as a metric in your observability platform, and alert when the metric degrades beyond a threshold (e.g., startup time increases by more than 50ms). Use Macrobenchmark in conjunction with Baseline Profile generation: the same user journeys used for benchmarking should be used for generating the profile, ensuring the profile covers exactly the critical paths you're measuring.
- Real measurements: runs on device with release-compiled code — microbenchmarks can't measure startup
- Separate module: benchmark module targets :app — doesn't pollute production build
- benchmark build type: release-like (R8 enabled) but allows profiling — accurate and measurable
- StartupTimingMetric: measures time to initial and full display — the numbers users feel
- FrameTimingMetric: measures frame rendering time — P90/P99 reveals jank that P50 hides
"Always use CompilationMode.None() to benchmark without baseline profiles, then CompilationMode.Full() to benchmark with them. The difference between these two numbers is the exact improvement your baseline profile delivers. This gives you a concrete metric: 'Our baseline profile reduced cold startup from 1400ms to 850ms — 39% improvement.'"
43. How do you manage secrets and API keys in Android builds? What are the safe and unsafe approaches?
API keys embedded in APKs are extractable by anyone with a decompiler. The only truly safe option is to never put secrets in the APK. For keys that must be on-device, use Android Keystore and server-side validation to limit exposure.
// ❌ UNSAFE: hardcoded in source val apiKey = "sk_live_abc123secret" // visible in git, decompiled APK // ❌ UNSAFE: in BuildConfig (extractable from APK) buildConfigField("String", "API_KEY", "\"sk_live_abc123\"") // BuildConfig.API_KEY visible in decompiled APK — always // ❌ UNSAFE: in local.properties (fine for dev, but not a security solution) // local.properties is git-ignored but the key still ends up in BuildConfig // ✅ SAFE Option 1: Backend proxy — server holds the secret // App → your backend → third-party API // Your backend authenticates calls with the secret key // App only has a key to your backend (which you control) // ✅ SAFE Option 2: Remote config — fetch at runtime, don't embed // Firebase Remote Config: keys fetched at launch, stored in memory // Revokable: if key is compromised, update Remote Config without app update // ✅ SAFE Option 3: Android Keystore — for device-bound secrets // Generate a key ON device, never leaves hardware // Use for: user data encryption keys, local auth credentials // NOT for: third-party API keys (those belong on your server) // ✅ SAFER BuildConfig pattern: obfuscate the key retrieval // Build a key from parts + apply XOR — raises bar vs plain string // Still extractable by determined attacker — not truly safe fun getKey(): String { val part1 = BuildConfig.KEY_PART1 // "sk_live_" val part2 = BuildConfig.KEY_PART2 // "abc123" return part1 + part2 // still recoverable — not truly safe } // Real answer: the ONLY safe place for a secret is your server
The safest approach to API key management in Android builds is to never put secrets in the codebase at all — not in BuildConfig fields, not in local.properties, not in any file tracked by version control. Instead, store secrets in a secrets management system: environment variables in CI, a secrets manager (AWS Secrets Manager, HashiCorp Vault, GitHub Actions secrets) for automated pipelines, and a developer-specific mechanism like ~/.gradle/gradle.properties (outside the project directory) for local development. Inject them into the build via System.getenv("API_KEY") in build scripts, producing a BuildConfig.API_KEY string at build time without the value ever touching the repository.
Even secrets in BuildConfig are not truly secure — they are embedded as plaintext strings in the DEX bytecode, visible to anyone who runs strings on the APK or decompiles it with Jadx. R8 obfuscation makes them slightly harder to find but does not encrypt them. For keys that must be embedded in the APK (push notification keys, analytics SDK keys), accept that they will eventually be extracted and protect them server-side: rate-limit API calls per device ID, use short-lived tokens refreshed server-side, and monitor for anomalous usage patterns. For highly sensitive keys (payment processing, cryptographic signing keys), never embed them in the APK — perform the sensitive operation on a backend server and give the app a token that authorises that operation.
The local.properties file pattern is a common but imperfect approach for development convenience. Add local.properties to .gitignore and read values in the build script: val apiKey = properties.getProperty("API_KEY") ?: System.getenv("API_KEY") ?: "". This two-source fallback supports both local development (from local.properties) and CI (from environment variables) without code changes. Document the required keys in a local.properties.template file tracked in the repo — developers copy it and fill in their values. The risk is developers accidentally committing local.properties if it gets un-ignored; use a Git pre-commit hook that checks for common secret patterns (apiKey =, password =) in staged files as an additional safeguard.
- BuildConfig secrets: always extractable from decompiled APK — R8 obfuscation doesn't help
- Backend proxy: the only truly safe approach — secret lives on your server, never in the APK
- Remote Config: fetch keys at runtime — revokable without app update if compromised
- Android Keystore: for device-generated keys only — not suitable for third-party API keys
- local.properties: git-ignored so safe for source control, but key still ends up in BuildConfig
"The interview answer: 'No secret in an APK is safe — BuildConfig, strings.xml, native .so — all extractable. The correct architecture: app authenticates with your backend using user credentials, your backend calls third-party APIs with the secret key. The user's JWT is the only credential in the APK, and it's per-user and revokable.'"
44. Scenario: Your app uses a native (.so) library. How do you integrate, package, and debug native code in the Android build?
Native libraries (.so files) require ABI-specific compilation, JNI bridging, and special build configuration. CMake and the Android NDK integrate with Gradle to compile C/C++ as part of your build.
// Option 1: Pre-compiled .so (most common — using a third-party library) // Place in: src/main/jniLibs/ // ├── arm64-v8a/libmylibrary.so // ├── armeabi-v7a/libmylibrary.so // └── x86_64/libmylibrary.so // AGP automatically packages these per ABI in splits/AAB // Filter ABIs to reduce size android { defaultConfig { ndk { abiFilters +=setOf ("arm64-v8a", "armeabi-v7a") // skip x86/x86_64 } } } // Option 2: Compile from C/C++ with CMake android { defaultConfig { externalNativeBuild { cmake { cppFlags += "-std=c++17" arguments += "-DANDROID_STL=c++_shared" } } } externalNativeBuild { cmake { path =file ("src/main/cpp/CMakeLists.txt") version = "3.22.1" } } } // JNI bridge — Kotlin calls native class NativeBridge { external fun processImage(pixels: IntArray, width: Int, height: Int): IntArray companion object { init { System.loadLibrary ("mylib") } // loads libmylib.so } } // Debug native crashes with ndk-stack // adb logcat | ndk-stack -sym app/build/intermediates/cmake/debug/obj/arm64-v8a // Converts native crash addresses to file:line references // R8 keep rules for JNI // -keepclasseswithmembernames class * { native <methods>; }
Integrating a native (.so) library into an Android project follows one of two paths: a prebuilt library (you have the .so files already compiled) or building from C/C++ source using CMake or ndk-build. For prebuilt libraries, place the .so files in src/main/jniLibs/arm64-v8a/libfoo.so (and other ABI directories as needed) — AGP automatically picks them up and packages them in the APK under the correct ABI directories. For C/C++ source, create a CMakeLists.txt in the module root and reference it in the build script: android { externalNativeBuild { cmake { path = file("CMakeLists.txt") } } }.
ABI filtering is critical for APK size. Without filtering, all supported ABIs are included: arm64-v8a, armeabi-v7a, x86, x86_64 — quadrupling the native library size in a universal APK. For most production apps in 2025, abiFilters += setOf("arm64-v8a") in defaultConfig { ndk { } } is the right choice: over 95% of active devices are 64-bit ARM. Keep x86_64 in the debug build type to support emulator development. With AAB, ABI splitting is automatic and free — each device gets only its ABI. With APK, use explicit splits { abi { enable = true; reset(); include("arm64-v8a", "armeabi-v7a") } } to produce per-ABI APKs.
Debugging native crashes requires symbolication. Native crashes appear in logcat as raw memory addresses: pc 0x00000000000b1234 /data/app/com.example/.../libnative.so. To convert to a human-readable stack trace, use ndk-stack: adb logcat | ndk-stack -sym app/build/intermediates/stripped_native_libs/debug/out/lib/arm64-v8a/. The symbol-stripped .so files in the APK are stripped of debug information to reduce size; the unstripped versions with debug symbols live in build/intermediates/merged_native_libs/ and should be archived alongside each release build — they are needed to symbolicate production crashes from Crashlytics or Play Console. Firebase Crashlytics accepts NDK symbol files via the Crashlytics Gradle plugin upload task.
- jniLibs/: pre-compiled .so location — AGP packages the right ABI per device via AAB/splits
- abiFilters: restrict to arm64+arm — eliminates x86/x86_64 .so files from the APK
- CMake integration: externalNativeBuild block — Gradle invokes CMake as part of the build
- external fun: Kotlin/Java JNI bridge — System.loadLibrary loads the .so at runtime
- ndk-stack: convert native crash hexadecimal addresses to readable file:line references
"Native libraries are the #1 cause of large APKs — a single .so may be 5-10MB per ABI. With 4 ABIs (arm64, arm, x86, x86_64) that's 20-40MB just for one library. ABI filter to arm64+arm covers 99% of real devices and cuts native lib size in half. Then publish as AAB and Play delivers only the matching ABI — down to one copy per user."
45. Scenario: You are the build engineer onboarding a new Android project. What does your ideal Gradle setup look like?
The ideal build setup is fast, secure, maintainable, and CI-ready from day one. It takes 2-3 days to configure properly and saves hundreds of hours over the project's lifetime.
// ── 1. PROJECT STRUCTURE ───────────────────────────────── // build-logic/ ← convention plugins // gradle/ // libs.versions.toml ← Version Catalog // wrapper/ ← pinned Gradle version // app/ core/ feature/ ← modules // ── 2. gradle.properties ──────────────────────────────── org.gradle.jvmargs=-Xmx4g -XX:+HeapDumpOnOutOfMemoryError org.gradle.caching=true org.gradle.parallel=true org.gradle.configuration-cache=true kotlin.incremental=true ksp.incremental=true android.enableR8.fullMode=true android.nonTransitiveRClass=true // faster compile — each module only sees own R // ── 3. CONVENTION PLUGINS ─────────────────────────────── // AndroidApplicationPlugin: compileSdk=35, minSdk=24, signing config, R8 // AndroidLibraryPlugin: same SDK config, no signing, namespace required // AndroidFeaturePlugin: library + Hilt + Compose + common test deps // ── 4. RELEASE BUILD TYPE ─────────────────────────────── release { isMinifyEnabled = true isShrinkResources = true isDebuggable = false signingConfig = // from env vars, not hardcoded proguardFiles(getDefaultProguardFile ("proguard-android-optimize.txt"), "proguard-rules.pro") } // ── 5. CI PIPELINE ────────────────────────────────────── // PR: ./gradlew test lintDebug (fast — 2-3 min) // Main merge: ./gradlew test lintDebug bundleRelease (with size check) // Tag push: ./gradlew bundleRelease + upload to Play internal track // ── 6. EXTRAS ─────────────────────────────────────────── // Baseline profile (src/main/baseline-prof.txt) // APK size CI check (fail if AAB > threshold) // Lint baseline (lint-baseline.xml with abortOnError=true) // Renovate/Dependabot for automated dependency updates // mapping.txt archived in CI as artifact
Onboarding a new Android project starts with a build health audit before touching any product code. Run ./gradlew assembleDebug --profile and examine the profile report for total build time, configuration time, and slowest tasks. Run ./gradlew lint and count the warning count as a baseline. Run ./gradlew app:dependencies and scan for outdated dependencies, duplicate library versions, and unused dependencies. Check gradle.properties for memory settings (org.gradle.jvmargs should be at least -Xmx4g -XX:+UseParallelGC) and parallelism (org.gradle.parallel=true and org.gradle.caching=true should be present).
The second phase is module structure assessment. A flat single-module app is a red flag for a project beyond a certain size — all code in one module means every code change recompiles everything and the module cannot benefit from Gradle's incremental compilation at module boundaries. Assess the module boundaries: is there a clear separation between feature modules, a shared data layer, and the app module? Are there circular dependencies between modules (Gradle will refuse to build these but they indicate architectural issues)? Check whether the project uses a Version Catalog (if not, you'll spend time normalising dependency versions), whether build logic is in convention plugins (if not, the build scripts are likely duplicated across modules), and whether there's a CI pipeline with build caching configured.
Document your findings in a build health report with quantified metrics and a prioritised action list. A typical finding list for a neglected project: enable configuration cache (save 30s per build), migrate from KAPT to KSP for Hilt and Room (save 40% annotation processing time), enable R8 full mode (reduce APK size by 8%), add build scan to CI (enable systematic build time tracking), fix 12 critical Lint warnings (security and crash risks), and update 8 outdated dependencies with known CVEs. Frame improvements in terms of developer productivity impact — "saves each developer 5 minutes per build cycle, 20 cycles/day = 100 minutes/developer/day" is a compelling business case for investing in build infrastructure.
- Convention plugins first: shared config before adding any feature modules — retrofitting is painful
- gradle.properties: all performance flags from day one — caching, parallel, config cache
- android.nonTransitiveRClass: each module only sees its own resources — faster compilation
- Lint baseline: enable strict Lint immediately on an empty project — no existing issues to suppress
- Renovate: automated dependency update PRs — keeps libraries current with minimal effort
"The most expensive build system debt: starting with a monolithic build script, no convention plugins, and Groovy DSL. When the project grows to 10 modules, everything needs refactoring at the worst time. Set up convention plugins, Version Catalog, and Kotlin DSL on day one — it takes 3 hours and saves weeks later. android.nonTransitiveRClass=true is the hidden gem: it makes R class compilation much faster in multi-module projects and catches resource name collisions early."
46. What is the Android namespace in build.gradle.kts? How does it replace the package attribute in AndroidManifest?
The namespace in build.gradle.kts defines the package for generated code (BuildConfig, R class). It replaces the package attribute previously set in AndroidManifest.xml. Separating namespace from applicationId allows different package naming for app ID and code generation.
// Before AGP 7.3 — package in AndroidManifest.xml // <manifest package="com.example.app"> → used for both R class and applicationId // ❌ Coupling: can't have different app ID and code package // AGP 7.3+ — namespace in build.gradle.kts (required in AGP 8+) android { namespace = "com.example.app" // package for generated R class and BuildConfig defaultConfig { applicationId = "com.example.app" // unique app ID on Play Store and device } } // ✅ Can now differ: android { namespace = "com.example.core.ui" // library module R class package // No applicationId for library modules — they don't install } // AndroidManifest.xml — no longer needs package attribute // <manifest> ← package removed // <application android:label="@string/app_name"> // Practical importance in multi-module projects: // :core:ui module: namespace = "com.example.core.ui" // :feature:home module: namespace = "com.example.feature.home" // Each gets its own R class: com.example.core.ui.R, com.example.feature.home.R // With nonTransitiveRClass: modules can ONLY reference their own resources // Migration: AGP Upgrade Assistant adds namespace automatically // Or add manually if missing (required for AGP 8.x): // Error without it: "Namespace not specified. Please specify a namespace..." // applicationId vs namespace in flavors: productFlavors { create("free") { applicationIdSuffix = ".free" // changes applicationId → com.example.app.free // namespace stays same → R class unchanged } }
The namespace property in build.gradle.kts replaces the package attribute in AndroidManifest.xml starting with AGP 7.3+. Before this change, the package attribute in the manifest served two purposes: it was the application ID (the unique identifier on the device and Play Store) and it was the Java package used for the generated R class. These two concerns — deployment identity and code generation — were entangled in one attribute, causing confusion when developers tried to change one without the other. The namespace property explicitly sets only the code generation package, while applicationId in the defaultConfig block sets only the deployment identity.
The practical implication for library modules is that the namespace value determines the package of the generated R class. If a library module has namespace = "com.example.feature.cart", the resources in that module are accessed as com.example.feature.cart.R.layout.fragment_cart. With nonTransitiveRClass enabled (recommended for multi-module projects), each module's R class only contains the resources defined in that module — not resources from transitive dependencies. This dramatically improves compilation performance because changing a resource in one module only recompiles the R class for that module and modules that directly depend on it, not the entire downstream dependency chain.
Migrating existing projects to use namespace involves removing package from all manifests and adding namespace to all module build scripts. AGP generates a migration warning when it detects a package attribute in the manifest: Namespace 'com.example.myapp' is currently set via a 'package' attribute in the source AndroidManifest.xml. Run the AGP upgrade assistant in Android Studio (Tools → AGP Upgrade Assistant) — it handles this migration automatically across all modules. After migration, validate that the app ID is still correctly set via applicationId in the app module's defaultConfig — accidentally removing the application ID (letting it default to the namespace) is a common post-migration error that changes the app ID and can cause Play Store rejection.
- namespace: defines the package for generated R class and BuildConfig — not the installed app ID
- applicationId: the unique identifier on Play Store and device — can differ from namespace
- Required in AGP 8+: builds fail without namespace in every module
- Per-module namespaces: each library module gets its own R class — prevents resource conflicts
- nonTransitiveRClass: with unique namespaces, each module can only reference its own resources
"The namespace/applicationId split matters for white-label apps: namespace='com.example.app' (code generation, stays constant), applicationId='com.whitelabel.client1' (what the client sees on Play, differs per flavor). The R class always uses the namespace — your code never changes. The applicationId is purely for distribution identity."
47. Scenario: How do you detect and fix memory leaks at the build level using LeakCanary in development builds only?
LeakCanary automatically detects memory leaks during development and crashes with a clear heap dump analysis. The key is confining it strictly to debug builds — it has significant overhead and should never ship in production.
// Add ONLY to debug dependency — not in release dependencies { debugImplementation(libs.leakcanary.android) // ✅ debug only — not in release APK // implementation(libs.leakcanary.android) ❌ would be in release! } // LeakCanary requires NO code changes — fully automatic from the library // It hooks into the app lifecycle automatically via ContentProvider // When a leak is detected: notification + detailed trace in the UI // Typical leak it catches: class HomeFragment : Fragment() { private var binding: FragmentHomeBinding? = null override fun onCreateView(...) = FragmentHomeBinding.inflate (inflater).also { binding = it // ❌ binding holds a reference to the View }.root // Missing: override fun onDestroyView() { binding = null } ← leak! // LeakCanary reports: HomeFragment → binding → View tree → LEAK } // Fix: override fun onDestroyView() { super.onDestroyView () binding = null // ✅ release binding reference when view is destroyed } // Configure LeakCanary (optional) class MyApp : Application() { override fun onCreate() { super.onCreate () if (BuildConfig.DEBUG) { LeakCanary.config = LeakCanary.config.copy ( retainedVisibleThreshold = 3 // trigger after 3 retained objects ) } } } // In CI — run leak detection as part of automated tests // LeakCanary throws AssertionError in tests when leak detected // ./gradlew connectedDebugAndroidTest → fails if any screen leaks detected
LeakCanary is the standard tool for memory leak detection during development, but integrating it effectively at the build level requires some deliberate configuration. By default, LeakCanary is added as a debugImplementation dependency — it is only present in debug builds and is completely absent from release. In its default mode, LeakCanary shows a notification when it detects a leak, dumps the heap automatically, and presents a human-readable leak trace in the app UI. For automated detection in CI, configure LeakCanary to write leak results to a file and fail the test run if leaks are found: use LeakCanary.config = LeakCanary.config.copy(onHeapAnalyzedListener = FailTestOnLeak()) in your test setup.
The Plumber library (part of the LeakCanary project) automatically fixes known Android OS memory leaks — issues in the framework that cause your objects to be retained by OS components. Including debugImplementation("com.squareup.leakcanary:plumber-android:X.Y.Z") applies these fixes in debug builds, reducing false positives in your leak detection. For Instrumentation tests, add LeakCanary to the androidTest configuration and configure it to fail tests immediately when a leak is detected rather than just logging. This turns memory leak detection from a manual, periodic activity into an automated quality gate — a PR that introduces a Fragment or ViewModel memory leak fails CI before it merges.
Beyond LeakCanary, the Android Gradle Plugin provides the android-lint check for potential memory leak patterns at static analysis time. Lint rules like StaticFieldLeak (a static reference to a Context), FragmentFieldLeak (storing a View reference beyond onDestroyView), and HandlerLeak (anonymous Handler subclass holding implicit outer class reference) catch common leak patterns before the code runs. Add lintOptions { abortOnError = true; error("StaticFieldLeak", "FragmentFieldLeak", "HandlerLeak") } to your build script to promote these from warnings to build-blocking errors. The combination of static analysis (Lint), automated runtime detection (LeakCanary in tests), and manual investigation (LeakCanary in debug) creates a comprehensive defence against memory leaks at the build level.
- debugImplementation: the critical build configuration — LeakCanary only in debug builds
- Zero code setup: ContentProvider initialisation is automatic — no Application code needed
- Fragment binding leaks: most common — must nullify binding in onDestroyView()
- CI integration: LeakCanary throws in instrumented tests — automated leak detection in CI
- Heap dump: LeakCanary captures a heap dump and traces the shortest leak path — precise diagnosis
"debugImplementation is the correct configuration for any development-only tool — LeakCanary, Stetho, Flipper. These libraries add significant overhead and their code is completely excluded from the release APK. A common mistake is putting them in implementation — they end up in production builds, slowing down the app and increasing APK size."
48. What is nonTransitiveRClass and how does it speed up multi-module builds?
By default, a module's R class contains all resources from that module and all its transitive dependencies. With nonTransitiveRClass=true, each module's R class contains only its own resources. This means changing a color in :core:ui no longer forces all 10 feature modules to recompile -- only :core:ui recompiles.
// gradle.properties android.nonTransitiveRClass=true // Before: :feature:home R class contained resources from :core:ui // R.drawable.ic_logo (defined in :core:ui) -- accessible from :feature:home // After: must reference cross-module resources explicitly val logo = com.example.core.ui.R.drawable.ic_logo // explicit module reference // Android Studio auto-migration: Refactor → Migrate to Non-Transitive R Classes // Fixes all reference errors automatically
The R class in Android is a generated file containing integer IDs for all resources in your app — layouts, drawables, strings, colors, and so on. In a traditional (transitive) setup, the R class in each module contains not only that module's resource IDs but also the IDs of all resources from all its transitive dependencies. This means that when any resource in any transitive dependency changes, Gradle must regenerate and recompile the R class for every module in the dependency chain — even modules that don't reference that resource at all. In a large multi-module project, this causes cascading recompilation that makes incremental builds much slower than they should be.
Non-transitive R classes (android.nonTransitiveRClass=true in gradle.properties) change this so each module's R class contains only that module's own resources. If a module needs a resource from a dependency, it must reference it using the dependency's fully-qualified R class: com.example.core.R.layout.fragment_base instead of just R.layout.fragment_base. This sounds like more work, but Android Studio handles the migration automatically (Run → Run 'Migrate to Non-transitive R Classes') — it updates all resource references in your source code. After migration, changing a resource in a core module only recompiles the R class for that core module and modules that directly reference that resource, not the entire downstream graph.
The build time improvement from non-transitive R classes scales with project size. In a project with 50 modules sharing a common :core:ui module, a single string change in :core:ui previously triggered R class regeneration and recompilation in all 50 dependent modules. With non-transitive R classes, only modules that actually reference that string resource need recompilation. Google's internal data and community benchmarks show 20–50% improvement in incremental build times for large multi-module projects after enabling this flag. Combined with configuration cache and build cache, non-transitive R classes is one of the highest-ROI build performance improvements for established multi-module Android projects.
- Without nonTransitiveRClass: change a color in :core:ui → all 10 feature modules recompile (their R class contains that color)
- With nonTransitiveRClass: change a color in :core:ui → only :core:ui recompiles
- Code change required: cross-module resource references must use the fully-qualified R class name
- Android Studio migration: Refactor → Migrate to Non-Transitive R Classes -- auto-fixes all references
- Combined with Gradle build cache, this can cut incremental build time by 60-80% on large multi-module projects
"nonTransitiveRClass is the least-known build performance option with one of the highest impacts. In a project with 10 feature modules, changing a single color in :core:ui without this flag triggers recompilation of all 10 feature modules (they all have that color in their R class). With this flag, only :core:ui recompiles. Use Android Studio's built-in migration refactoring — it fixes all the code references automatically."
49. Scenario: A third-party library you depend on has a known vulnerability. How do you handle it before the library author fixes it?
Vulnerable dependencies need immediate action — you can't wait for an upstream fix that may take weeks. The tools are: force a safe version of the transitive dependency, patch the vulnerable class with a custom overlay, or replace the library entirely.
// Scenario: okhttp 4.9.0 has a CVE. Your library depends on it. // The library hasn't released a fix yet. // OPTION 1: Force a safe version via resolution strategy configurations.all { resolutionStrategy { // Force the patched version everywhere force ("com.squareup.okhttp3:okhttp:4.12.0") } } // ✅ Quick fix — one line // ✅ Applies to all transitive dependencies // ⚠️ The library must be compatible with the newer version // OPTION 2: Exclude the vulnerable transitive dep and add safe version directly implementation(libs.some.library) {exclude (group = "com.squareup.okhttp3", module = "okhttp") } implementation("com.squareup.okhttp3:okhttp:4.12.0") // add safe version directly // OPTION 3: Dependency substitution — swap entire library configurations.all { resolutionStrategy { dependencySubstitution { substitute( module ("com.vulnerable:library:1.0")) .using (module ("com.safe:replacement:2.0")) } } } // OPTION 4: OWASP Dependency Check — detect vulnerabilities in CI // id("org.owasp.dependencycheck") version "9.0.6" dependencyCheck { failBuildOnCVSS = 7.0 // fail CI if any dep has CVSS score >= 7 suppressionFile = "dependency-check-suppressions.xml" // known acceptable CVEs } // ./gradlew dependencyCheckAnalyze → HTML report of all CVEs // Track vulnerabilities automatically: // Renovate/Dependabot: creates PRs when security updates are available // GitHub Dependabot alerts: notifies of known vulnerable dependencies
When a dependency has a known vulnerability, the immediate step is to check whether the vulnerability is actually exploitable in your usage. Review the CVE description carefully — many vulnerabilities are in code paths you don't invoke (a parsing vulnerability in a library you use only for its network client, for example). Check if the vulnerable version is a direct dependency or a transitive one: ./gradlew app:dependencies | grep vulnerable-library. If it's transitive, you can force a safe version without changing your direct dependency: configurations.all { resolutionStrategy { force "com.vulnerable:library:SAFE_VERSION" } }. This buys time while the library's primary dependents catch up with updates.
Automated vulnerability scanning should be part of your CI pipeline, not a reactive process. The OWASP Dependency Check Gradle plugin (id("org.owasp.dependencycheck")) scans your dependency tree against the National Vulnerability Database and fails the build if CVEs above a configured severity threshold are found: dependencyCheck { failBuildOnCVSS = 7 }. Run this weekly on your release branch (daily would be too expensive — the NVD API has rate limits). GitHub's Dependabot, once enabled on the repository, automatically opens PRs to update dependencies with known CVEs, removing the need for manual monitoring. Configure Dependabot to auto-merge patch version updates with passing CI, requiring manual review only for minor and major version bumps.
For truly critical vulnerabilities (CVSS 9+, active exploitation in the wild), the response escalates beyond a normal dependency update. Treat it as an incident: assess whether any data or functionality accessible via the vulnerability is sensitive, pull the affected release from the Play Store if the vulnerability is exploitable by an attacker without physical device access, expedite a hotfix release with the updated dependency, and communicate with users if any data exposure occurred. Document the timeline, the vulnerability details, your assessment, and the remediation steps in a post-incident review — even for vulnerabilities you determine are not exploitable, the documentation ensures the next person investigating a similar issue can find your analysis.
- resolutionStrategy.force: override any transitive dependency version — fastest temporary fix
- exclude + direct dep: remove vulnerable transitive dep and add safe version directly
- OWASP Dependency Check: CI plugin that fails builds when CVE score exceeds threshold
- Renovate/Dependabot: automated PRs for security updates — catches vulnerabilities before they're exploited
- suppression file: acknowledge acceptable CVEs with justification — prevents false-positive failures
"resolutionStrategy.force is the emergency fix — apply it immediately when a CVE is reported. Then add OWASP Dependency Check to CI so future vulnerabilities are caught automatically before they reach production. The suppression file is for false positives or accepted risks — every entry should have a comment explaining why it's acceptable and when to reassess."
50. Scenario: You are joining a team with a slow, messy build system. Write your action plan to fix it over 3 months.
A messy build system needs a prioritised plan, not a big-bang rewrite. Start with zero-risk wins in gradle.properties, then migrate to Kotlin DSL and Version Catalog, then extract convention plugins. Each phase delivers measurable build time improvement while keeping CI green.
// Month 1: gradle.properties -- zero risk, immediate impact org.gradle.caching=true org.gradle.parallel=true kotlin.incremental=true ksp.incremental=true // Month 1: KAPT → KSP for Hilt and Room (biggest single build win) // Change: kapt(libs.hilt.compiler) → ksp(libs.hilt.compiler) // Month 2: Version Catalog + Kotlin DSL // libs.versions.toml + rename .gradle → .gradle.kts // Month 3: Convention plugins + configuration cache pluginManagement { includeBuild("build-logic") } org.gradle.configuration-cache=true
The first week of a build improvement engagement focuses on measurement and low-hanging fruit. Start by getting objective baseline numbers: total clean build time, incremental build time for a single-file change in the app module, and incremental build time for a single-file change in a core module. Run ./gradlew assembleDebug --profile --rerun-tasks three times and average the results. Identify the top 5 slowest tasks — these are your primary targets. Enable org.gradle.parallel=true, org.gradle.caching=true, and increase JVM heap in gradle.properties: org.gradle.jvmargs=-Xmx8g -XX:MaxMetaspaceSize=1g -XX:+UseParallelGC. These three changes alone often reduce build times by 20–30% with no code changes.
The second phase targets the specific bottlenecks identified in the profile. If annotation processing (KAPT) is a top-5 task, migrate Hilt and Room to KSP immediately — this is safe and well-tested. If resource merging is slow, investigate whether modules are unnecessarily re-declaring resources that could live in a shared module, and enable non-transitive R classes. If Kotlin compilation is the bottleneck, check for incremental compilation disablement: kotlin.incremental=true should be set (it's the default but can be overridden), and verify that all modules have stable API boundaries that enable proper incremental compilation. Set up a remote build cache server (or use GitHub Actions cache via the Gradle wrapper plugin) to share cached outputs across CI machines and with developers.
The third phase is sustainability: preventing the build from degrading again over time. Add ./gradlew assembleDebug --profile to CI and publish the total build time as a metric to your observability platform. Set an alert threshold at 120% of the current baseline — if the build slows by 20%, someone investigates before it becomes a 2x regression. Add a module dependency graph check that fails if circular dependencies are introduced. Configure Renovate Bot for automated dependency updates. Create a build health runbook documenting the profiling approach, the known bottlenecks that were fixed, and the steps to diagnose a new slowdown — so the next engineer who inherits the build system isn't starting from scratch. Build infrastructure is a product with users (your team) and deserves the same care as any other product in the codebase.
- Month 1 wins (zero risk): enable caching+parallel in gradle.properties, migrate KAPT→KSP -- saves 30-60s per build immediately
- Profile first with --scan: identify the actual bottleneck before optimising -- don't guess
- Month 2 (reorganise without changing behaviour): Version Catalog + Kotlin DSL -- IDE autocomplete, type safety, no functional change
- Month 3 (structural): convention plugins eliminate build script duplication, configuration cache saves 20-40s per build
- Measure: record clean and incremental build times before and after each phase -- quantify the improvement for stakeholders
"The sequencing matters: Month 1 focuses on zero-risk wins that prove value and build trust. Month 2 reorganises without changing functionality. Month 3 does the structural changes that require the team to adapt. Starting with convention plugins in week 1 would disrupt everyone. Starting with gradle.properties delivers results without disruption, creating support for the harder changes later."