This is a Kotlin Multiplatform (KMP) Android project migrating to Compose Multiplatform, targeting Android, Desktop, and iOS. The app uses MVVM + Repository + Use Case architecture.
# Full lint check (ktlint 1.7.1, ktlint_official style)
./gradlew ktlintCheck
# Auto-format all sources
./gradlew ktlintFormat
# Run all unit tests (debug + KMP host tests)
./gradlew testDebugUnitTest testDhis2DebugUnitTest testAndroidHostTest
# Shortcut: lint + all unit tests (mirrors CI)
./run_tests.sh
# Run a single test class (KMP commonTest)
./gradlew :login:testAndroidHostTest --tests "org.dhis2.mobile.login.main.ui.viewmodel.LoginViewModelTest"
# Run a single test method (KMP commonTest)
./gradlew :login:testAndroidHostTest --tests "org.dhis2.mobile.login.main.ui.viewmodel.LoginViewModelTest.initial screen is set correctly when starting"
# Build debug APK
./gradlew assembleDhis2Debug
# Build all modules
./gradlew assembleGradle task naming by module type:
- Legacy Android modules (
form,commons,tracker, etc.):testDebugUnitTest - KMP modules (
login,commonskmm,sync,aggregates),commonTestsource set:testAndroidHostTest - KMP modules,
androidUnitTestsource set:testAndroidDebugUnitTest - Desktop targets in KMP modules:
desktopTest
root/
├── app/ # Main Android application
├── commonskmm/ # KMP shared utilities, base classes, DI helpers
├── login/ # KMP login feature (Android + Desktop)
├── sync/ # KMP sync feature
├── aggregates/ # KMP aggregate data feature
├── tracker/ # Android tracker feature
├── form/ # Android form module
├── commons/ # Android shared utilities (legacy)
├── compose-table/ # Compose table component
├── dhis2-mobile-program-rules/ # KMP program rules engine
└── gradle/libs.versions.toml # Central dependency catalog
KMP module source sets:
modulekmm/src/
├── commonMain/kotlin/ # Shared business logic, interfaces, use cases
├── commonTest/kotlin/ # Shared unit tests (kotlin-test + mockito-kotlin + turbine)
├── androidMain/kotlin/ # Android implementations, SDK access
├── androidUnitTest/kotlin/ # Android-specific unit tests
├── desktopMain/kotlin/ # Desktop implementations
└── composeResources/ # Shared Compose resources (strings, images)
Config in .editorconfig:
- Style:
ktlint_official - No wildcard imports (
ktlint_standard_no-wildcard-imports = enabled) - No unused imports (
ktlint_standard_no-unused-imports = enabled) - Trailing commas required on both call and declaration sites
- Ordered imports (
ktlint_standard_import-ordering = enabled) - Function naming: standard rule disabled — composables may use PascalCase per Compose conventions
General Kotlin conventions:
- JVM target: Java 17 (
sourceCompatibility = JavaVersion.VERSION_17) - Prefer
data classover plain class for models - Use
sealed class/sealed interfacefor UI state - Use
objectfor singletons, companion objects for constants - Prefer expression bodies for single-expression functions
- Document public APIs with KDoc
domain/
model/ # Pure data classes / sealed states
usecase/ # Business logic, implements UseCase<R, T>
repository/ # Repository interfaces
data/
repository/ # Repository implementations (androidMain)
ui/
state/ # UiState sealed classes
viewmodel/ # ViewModels (expose StateFlow<UiState>)
screen/ # @Composable screens
component/ # Reusable composables
di/ # Koin module definitions
All use cases must implement UseCase<in R, out T> from
commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/domain/UseCase.kt:
// Interface definition
fun interface UseCase<in R, out T> {
suspend operator fun invoke(input: R): Result<T>
}
// Parameterless convenience extension
suspend operator fun <T> UseCase<Unit, T>.invoke() = this(Unit)Implementation pattern:
class SavePinUseCase(private val repo: SessionRepository) : UseCase<String, Unit> {
override suspend fun invoke(input: String): Result<Unit> =
try {
repo.savePin(input)
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}- Use
launchUseCase { }(notviewModelScope.launch) — it wrapsCoroutineTrackerwhich integrates with Espresso'sIdlingResourcefor reliable UI tests - Expose state via
StateFlow; collect in composables withcollectAsState()
- Translate
D2Error→ domain errors viaDomainErrorMapper - Required imports for Android impls:
import org.dhis2.mobile.commons.error.DomainErrorMapper import org.hisp.dhis.android.core.maintenance.D2Error
val featureModule = module {
single<MyRepository> { MyRepositoryImpl(get(), get()) }
factory { MyUseCase(get()) }
viewModel { MyViewModel(get()) }
}- Define modules in
commonMainwhere possible; useexpect/actualfor platform DI - Inject ViewModels in composables with
koinViewModel()
- Always prefer DHIS2 design system components (
org.hisp.dhis.mobile.ui.designsystem.*) over Material components - Wrap screens in
DHIS2Theme { }fromorg.hisp.dhis.mobile.ui.designsystem.theme - Place shared Compose resources in
commonMain/composeResources/ - Use multiplatform Compose Navigation (
org.jetbrains.androidx.navigation:navigation-compose) - Add
@Previewannotations to validate composables in isolation
- Unit tests:
mockito-kotlin+kotlin.testincommonTest;mockito-kotlin+ JUnit inandroidUnitTestand legacy modules - Flow assertions: Turbine (
app.cash.turbine) +kotlinx-coroutines-test - UI tests: Compose Testing + Espresso, Robot pattern, located in
androidInstrumentedTest/ - ViewModel coroutines: always use
launchUseCase { }— it wrapsCoroutineTrackerwhich integrates with Espresso'sIdlingResource; never useThread.sleep()
For patterns, examples, and common mistakes load the android-testing skill.
- Never create direct network or database calls — use the DHIS2 Android SDK (
org.hisp.dhis.android.core.*) - Offline-first: design features to work without connectivity; let the SDK handle sync
- KMP first: put business logic in
commonMain; keepandroidMainto SDK/platform specifics - No RxJava in new code: migrate to Coroutines/Flow; wrap existing RxJava at boundaries
- ktlint must pass before committing — run
./gradlew ktlintFormatthenktlintCheck