These guidelines aim to standardize the architecture, structure, and development practices for Android apps in our organization. The sample app, CleanTasky, demonstrates all practices mentioned here.
- Use Clean Architecture with three main layers:
presentation,domain, anddata. - Enforce SOLID principles across all layers.
- Use OOP principles such as abstraction, encapsulation, and polymorphism.
- Apply Design Patterns like Repository, UseCase, Factory, and Observer where appropriate.
- Use Jetpack Compose for all UI development.
- Follow State Hoisting and Unidirectional Data Flow (UDF).
- Implement Theming with Material3: support light/dark modes and dynamic colors.
- Use Room for local database storage.
- Use DataStore (Preferences and Proto) for key-value and structured settings.
- Abstract data sources using the Repository pattern.
- Use Kotlin Coroutines with proper
Dispatchers. - Use Flow for data streams and reactive patterns.
- Handle side effects and thread context switching cleanly.
- Keep the app single-module for simplicity and better maintainability.
- Use Version Catalogs for dependency management.
- Configure Product Flavors:
mock,staging,prod. - Set up Build Environments:
dev,uat,prod. - Support Gradle Build Config Fields and environment constants per flavor.
- Use
BuildConfigconstants to manage API URLs, keys, and other env-specific settings. - Apply custom ProGuard rules per flavor if needed.
- Structure your
build.gradle.ktsto keep each flavor maintainable.
flavorDimensions += "env"
productFlavors {
create("qa") {
dimension = "env"
applicationIdSuffix = ".qa"
versionNameSuffix = "-qa"
buildConfigField("String", "BASE_URL", '"https://staging-api.cleantasky.com"')
firebaseAppDistribution {
serviceCredentialsFile = "<path-to-service-account-qa.json>"
artifactType = "APK"
releaseNotesFile = "<path-to-release-notes-qa.txt>"
testersFile = "<path-to-testers.txt>"
}
}
create("prod") {
dimension = "env"
buildConfigField("String", "BASE_URL", '"https://api.cleantasky.com"')
firebaseAppDistribution {
serviceCredentialsFile = "<path-to-service-account-prod.json>"
artifactType = "APK"
releaseNotesFile = "<path-to-release-notes-prod.txt>"
testersFile = "<path-to-testers.txt>"
}
}
}✅ Place secrets like tokens in
local.properties, and access them securely usinggradle.propertiesor environment variables.
✅ This structure gives clear separation for dev, test, and release cycles while using a single module.
- Keep the app single-module for simplicity and better maintainability.
- Use Version Catalogs for dependency management.
- Configure Product Flavors:
mock,staging,prod. - Set up Build Environments:
dev,uat,prod.
Firebase App Distribution provides an efficient and secure way to distribute pre-release versions of your app to QA testers and internal stakeholders. It allows:
- Early access to new builds via over-the-air installation
- Automatic notifications for testers
- Integration with CI/CD pipelines (e.g., Fastlane, GitHub Actions)
- Release management with version tracking and notes
- Authentication and access control for testers
In your project:
1. Apply Plugin in Root build.gradle.kts:
plugins {
id("com.google.firebase.appdistribution") version "5.1.1" apply false
}2. Apply Plugin in App build.gradle.kts:
plugins {
id("com.google.firebase.appdistribution")
}3. Configure firebaseAppDistribution in flavor (example - prod)
firebaseAppDistribution {
serviceCredentialsFile = "<path-to-service-account-prod.json>"
artifactType = "APK"
releaseNotesFile = "<path-to-release-notes-prod.txt>"
testersFile = "<path-to-testers.txt>"
}4. Firebase SDK and API usage in code (e.g. MainActivity)
FirebaseAppDistribution.getInstance()to access distribution servicesignInTester()to authenticate the testercheckForNewRelease()to determine if a new version is availableupdateApp()to trigger the update process
5. Show update prompt and download progress in Compose
if (showUpdateDialog.value) {
UpdateAvailableDialog(...)
}
if (isUpdating.value) {
UpdateLoadingDialog(progress = updateProgress.value)
}✅ Recommended for teams aiming to streamline internal testing workflows and reduce manual release management effort.
-
✅ Use Kotlin DSL (
build.gradle.kts) everywhere -
✅ Set up
libs.versions.tomlfor dependency versions -
✅ Add and sync Compose, Material3, Lifecycle, Hilt, Coroutines, Room, DataStore, Navigation, Serialization
-
✅ Use the latest non-conflicting versions across AndroidX, Kotlin, and third-party libraries
-
✅ Enable namespace and Jetpack Compose support in the
build.gradle.kts -
✅ Apply and configure Hilt plugin without using alias for kapt
-
✅ Ensure correct plugin setup in
libs.versions.toml(exclude kapt alias) -
✅ Include
kaptmanually viaid("org.jetbrains.kotlin.kapt")in the app-level build file -
✅ Add Product Flavors like
mock,staging,prod(for different app variants) -
✅ Add Environment Configs using
buildConfigFieldorresValueper flavor -
✅ Add Versioning Strategy with auto increment/versioning logic (optional via CI/CD)
-
✅ Setup Custom Build Types if needed:
debug,release,benchmark, etc. -
✅ Use Kotlin DSL (
build.gradle.kts) everywhere -
✅ Set up
libs.versions.tomlfor dependency versions -
✅ Add and sync Compose, Material3, Lifecycle, Hilt, Coroutines, Room, DataStore dependencies
-
✅ Use the latest non-conflicting versions across AndroidX and Kotlin
-
✅ Enable namespace and Jetpack Compose support in the
build.gradle.kts -
✅ Apply and configure Hilt plugin without using alias for kapt
-
✅ Ensure correct plugin setup in
libs.versions.toml(exclude kapt alias) -
✅ Include
kaptmanually viaid("org.jetbrains.kotlin.kapt")in the app-level build file -
✅ Store version components (
MAJOR_VERSION,MINOR_VERSION,PATCH_VERSION,BUILD_NUMBER) ingradle.properties. -
✅ Calculate
versionCodeusing:(major * 10000) + (minor * 100) + (patch * 10) + build) -
✅ Generate
versionNameas a string:"major.minor.patch"or"major.minor.patch-build"(if including build number). -
✅ Ensure each release increases
versionCodeto maintain Play Store compatibility. -
✅ Keep versioning logic centralized and overrideable (e.g., via CI/CD using
-PBUILD_NUMBER=99).
The following dependencies are used to support Clean Architecture, Jetpack Compose, Hilt, Room, DataStore, and other essential libraries:
- Jetpack Compose (UI, tooling, material3)
- Lifecycle & ViewModel
- Kotlin Coroutines & Flow
- Room (Database)
- DataStore (Proto/Preferences)
- Dagger Hilt (DI)
- Retrofit & OkHttp (Networking, optional for sample app)
- Timber (Logging)
- Testing: JUnit, Compose UI testing, Coroutine test support
Make sure the following are defined in libs.versions.toml and referenced in build.gradle.kts:
[versions]
hilt = "2.51.1"
room = "2.6.1"
datastore = "1.1.1"
coroutines = "1.8.0"
retrofit = "2.11.0"
okhttp = "4.12.0"
timber = "5.0.1"
[libraries]
# Hilt
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
# Room
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
# DataStore
datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
datastore-core = { group = "androidx.datastore", name = "datastore-core", version.ref = "datastore" }
# Coroutines
kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
# Network (optional)
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-converter-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
okhttp-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
# Timber
timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" }Ensure you apply the
kotlin-kaptanddagger.hilt.android.pluginplugins for Hilt to work correctly.
[plugins]
kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" }
dagger-hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }Then reference them in your build.gradle.kts:
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.kapt)
alias(libs.plugins.dagger.hilt)
}✅ This setup supports Clean Architecture, dependency injection, offline storage, state management, and modular expansion in the future.
- ✅ Use Kotlin DSL (
build.gradle.kts) everywhere - ✅ Set up
libs.versions.tomlfor dependency versions - ✅ Add and sync Compose, Material3, Lifecycle, Hilt, Coroutines, and Room dependencies
- ✅ Use the latest non-conflicting versions across AndroidX and Kotlin
- ✅ Enable namespace and Jetpack Compose support in the
build.gradle.kts
Tip: Use
platform(libs.androidx.compose.bom)to manage Compose versions consistently.
Sample Root Project Structure:
CleanTasky/
│
├── build.gradle.kts (Root build config)
├── settings.gradle.kts
├── gradle.properties
├── gradle/wrapper/gradle-wrapper.properties
├── gradle/libs.versions.toml
├── .github/ (CI workflows)
├── .gitignore
│
├── app/ (Main App module containing all features, data, domain, and UI layers)
│ ├── build.gradle.kts
│ ├── proguard-rules.pro
- Follow Git Flow methodology to manage branching and releases.
- Use Conventional Commits for commit messages.
- Enforce PR templates and review checklists.
- Protect
main,develop, and allrelease/*branches.
To streamline QA and production processes, we adopt a customized Git Flow:
main: production-ready code only (deployed to production).develop: ongoing development, all new features and bugfixes go here.feature/feature-name: used for new features (merged intodevelop).fix/bug-description: used for bug fixes (merged intodevelop).qa-release/vX.X.X: frozen branch prepared for internal QA and testing.prod-release/vX.X.X: used to finalize the QA-passed build and deploy to production.hotfix/vX.X.X: urgent fixes for production (branched frommain, merged intomainanddevelop).
hotfix/*: Used only for urgent production fixes. Branched frommain, and after merging, it must be merged into bothmainanddevelop.fix/*: Used for non-urgent bug fixes during development. Branched fromdevelopand merged back intodevelop.
✅ Use
hotfix/*when something must be immediately fixed in production. ✅ Usefix/*when fixing normal bugs during development.
- Developers work in
feature/*branches and merge todevelopvia PRs. - QA engineers test code from
qa-release/*, which is merged fromdevelopwhen ready for testing. - Once QA passes, the
qa-release/*branch is used to createprod-release/*for final production deployment.
This separation allows parallel work: QA can test a stable release while developers continue building new features.
# Initial setup
> git init
> git checkout -b develop
# Create a new feature
> git checkout -b feature/your-feature-name
# Create a new release branch
> git checkout -b release/v1.0.0
# Create a hotfix
> git checkout -b hotfix/v1.0.1feat: add login screen with basic form validation
fix: correct task sorting issue on main screen
chore: setup base project structure and .gitignore
Suggested .gitignore
# Built application files
*.apk
*.aar
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
# App Release Files
app/release/*
release/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml
.idea/
.idea/libraries
.idea/caches
.idea/modules.xml
.idea/navEditor.xml
.idea/codeStyles/
# Keystore files
*.jks
*.keystore
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
.cxx/
# Google Services (e.g. APIs or Firebase)
google-services.json
# Freeline
freeline.py
freeline/
freeline_project_description.json
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/readme.md
# Version control
vcs.xml
# lint
lint/intermediates/
lint/generated/
lint/outputs/
lint/tmp/
# MacOS
.DS_Store
# App Specific cases
app/release/output.json
- Use GitHub Actions + Fastlane for automation.
- Configure workflows for pull requests, build, test, release.
- Automate versionCode and versionName.
- Write Unit Tests for business logic in the domain layer using JUnit and MockK.
- Test Kotlin Flows and suspend functions using Turbine.
- Write UI tests with Jetpack Compose testing APIs.
- Add Instrumentation Tests to validate full app integrations and interactions.
- Unit tests ensure your logic works independently and correctly.
- UI tests verify the actual user interface behaves as expected on real devices/emulators.
- Instrumentation tests simulate full app experiences, including navigation and database access.
- Flow testing ensures your reactive streams emit correctly over time.
- Frameworks: JUnit, MockK
- Example (in
ExampleUnitTest.kt):
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}- Framework: Turbine
- Example:
@Test
fun flowEmitsCorrectly() = runTest {
val flow = flowOf("Hello", "World")
flow.test {
assert(awaitItem() == "Hello")
assert(awaitItem() == "World")
awaitComplete()
}
}- Framework: Compose UI Test
- Example (in
GreetingUiTest.kt):
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun greetingIsDisplayed() {
composeTestRule.setContent {
Greeting("Android")
}
composeTestRule.onNodeWithText("Hello Android!").assertIsDisplayed()
}- Used for testing integration with Android components.
- Example: Accessing
context, navigation, Room DB, etc.
// Unit Testing
testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.turbine)
testImplementation(libs.kotlinx.coroutines.test)
// Instrumented / UI Testing
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
debugImplementation(libs.androidx.ui.test.manifest)
debugImplementation(libs.androidx.ui.tooling)- Use Ktlint for static analysis.
- Format code using built-in or custom rules.
- Maintain consistent naming, styling, and documentation.
- Define Ktlint configs in the
config/folder.
Below is an example of how Ktlint is integrated into the project:
Root build.gradle.kts:
plugins {
// ... other plugins
alias(libs.plugins.ktlint) apply false // Ktlint plugin not applied at root
}app/build.gradle.kts:
plugins {
alias(libs.plugins.ktlint) // Ktlint plugin is actually applied here
}
ktlint {
android.set(true)
outputToConsole.set(true)
coloredOutput.set(true)
verbose.set(true)
ignoreFailures.set(false)
}What This Does:
- Installs Ktlint tasks (e.g.,
ktlintCheck,ktlintFormat). - Enforces style checks on Kotlin files.
- Fails the build if any style violations exist (
ignoreFailures=false). - Provides colorized console output for easier reading.
To run checks:
./gradlew ktlintCheck
./gradlew ktlintFormatThis ensures your code adheres to a consistent style and best practices.
- Use Timber for logging. Initialize
Timber.DebugTree()in debug builds only.
The sample app, CleanTasky, will implement all the concepts outlined in this document:
- Clean Architecture in a single-module setup
- Jetpack Compose UI with Material3
- ViewModel-based state management
- Offline-first data layer using Room and DataStore
- Git Flow-based branching and CI/CD via GitHub Actions
- Fully documented and tested codebase
The app structure and implementation should evolve alongside this guideline to serve as a living reference and onboarding resource.