This document serves as a comprehensive guide for developers working on the DHIS2 Android Capture App, outlining best practices, architecture patterns, and implementation examples. It is designed to be useful for both human developers and AI assistants like Copilot.
This project is a DHIS2 Android application that is migrating from a traditional Android app to a * Kotlin Multiplatform (KMP)* project. The migration is ongoing with the goal of eventually becoming a full Compose Multiplatform application supporting Android, iOS, and Desktop platforms.
- Kotlin: Primary programming language
- Kotlin Multiplatform (KMP): Target platform for cross-platform development
- Compose Multiplatform: UI framework for all platforms
- Gradle: Build system with Kotlin DSL
- Primary UI: Use
@dhis2/dhis2-mobile-uidesign system (based on Compose Multiplatform)- Use latest stable version (check
gradle/libs.versions.tomlfor current version) - Import:
org.hisp.dhis.mobile.ui.designsystem.* - Always prefer DHIS2 design system components over Material components when available
- Use latest stable version (check
- DHIS2 Android SDK: Use
@dhis2/dhis2-android-sdkfor all data operations- Use latest stable version (check
gradle/libs.versions.tomlfor current version) - Import:
org.hisp.dhis.android.core.* - Handles persistence, offline/online synchronization, and DHIS2 API communication
- Never create direct network calls or database operations - use the SDK
- Use latest stable version (check
This section describes the core architecture patterns used in the project, along with implementation examples and best practices.
The Model-View-ViewModel (MVVM) pattern is used to separate concerns between the UI (View), business logic (ViewModel), and data (Model).
-
ViewModels: Manage UI state and business logic
- Use
androidx.lifecycle.ViewModelor platform-specific equivalents - Expose state via
StateFlowandFlow - Handle UI events and coordinate with repositories/use cases
- Use
-
Views/Composables: UI layer that observes ViewModel state
- Use
@Composablefunctions for UI components - Collect state using
collectAsState() - Keep composables pure and stateless when possible
- Use Compose multiplatform previews (
@Preview) to validate UI components
- Use
class ExampleViewModel(
private val getDataUseCase: GetDataUseCase
) : ViewModel() {
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
init {
loadData()
}
private fun loadData() {
// Use the `launchUseCase` ViewModel extension which wraps coroutine tracking and uses
// a background dispatcher by default (it increments/decrements CoroutineTracker).
launchUseCase {
// `getDataUseCase()` is an extension for `UseCase<Unit, T>` that calls the use case with Unit
val result = getDataUseCase()
result.fold(
onSuccess = { flow ->
flow
.catch { _uiState.value = UiState.Error(it.message ?: "Unknown error") }
.collect { _uiState.value = UiState.Success(it) }
},
onFailure = { throwable ->
_uiState.value = UiState.Error(throwable.message ?: "Unknown error")
}
)
}
}
}@Composable
fun ExampleScreen(
viewModel: ExampleViewModel = koinViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
DHIS2Theme {
when (uiState) {
is UiState.Loading -> LoadingIndicator()
is UiState.Success -> ExampleContent(uiState.data)
is UiState.Error -> ErrorMessage(uiState.message)
}
}
}Repositories provide an abstraction layer over data sources, handling data access and mapping between SDK models and domain models.
- Repositories: Abstract data access layer
- Define interfaces in
commonMain - Implement platform-specific versions in
androidMain,desktopMain, etc. - Use DHIS2 SDK for data operations
- Handle data mapping between SDK models and domain models
- Map SDK exceptions to domain errors: repository implementations should translate platform/SDK
exceptions into domain-level errors using the project's
DomainErrorMapper(or equivalent). This keeps the domain layer SDK-agnostic and makes error handling consistent across the app.- Imports you will commonly need in Android implementations:
import org.dhis2.mobile.commons.error.DomainErrorMapper import org.hisp.dhis.android.core.maintenance.D2Error - Recommendations:
- Keep repository method signatures
suspendand let them throw domain-level exceptions rather than returning raw SDK exceptions. - Catch/translate only SDK-specific exceptions; allow unexpected exceptions to bubble up or wrap them in a generic domain error if appropriate.
- Write unit tests that mock
DomainErrorMapperto assert error mapping behavior.
- Keep repository method signatures
- Imports you will commonly need in Android implementations:
- Define interfaces in
class ExampleRepositoryImpl(
private val d2: D2,
private val domainErrorMapper: DomainErrorMapper
) : ExampleRepository {
override suspend fun getData(): Flow<List<ExampleData>> {
return try {
d2.exampleModule().examples()
.get()
.asFlow()
.map { it.map { example -> example.toDomainModel() } }
} catch (d2Error: D2Error) {
throw domainErrorMapper.mapToDomainError(d2Error)
}
}
}Use cases encapsulate complex business logic, providing a clear interface for ViewModels to interact with the domain layer.
- Use Cases: Encapsulate complex business logic
- Single responsibility principle
- Implement the shared
UseCaseinterface:UseCase<in R, out T>(seecommonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/domain/UseCase.kt). Use cases must implement/extend this interface so they return aResult<T>from theirinvokemethod. - For parameterless use cases use
UseCase<Unit, T>and the provided extensionsuspend operator fun <T> UseCase<Unit, T>.invoke() = this(Unit)to call them without passingUnitexplicitly. - Return
Result<T>(often wrapping aFlow<T>when the use case emits a stream). Handle exceptions inside the use case and wrap success/failure usingResult.success/Result.failure. - Coordinate between multiple repositories if needed
- Return
Floworsuspendfunctions for async operations - Place in domain layer
class GetDataUseCase(
private val repository: ExampleRepository
) : UseCase<Unit, Flow<List<ExampleData>>> {
override suspend operator fun invoke(input: Unit): Result<Flow<List<ExampleData>>> {
return try {
val flow = repository.getData()
.map { data -> data.filter { it.isValid } }
Result.success(flow)
} catch (e: Exception) {
Result.failure(e)
}
}
}Use sealed classes to represent different UI states in a type-safe manner.
- Sealed Classes: Use for representing different states
sealed class UiState { object Loading : UiState() data class Success(val data: T) : UiState() data class Error(val message: String) : UiState() }
Leverage Kotlin's Flow and Coroutines for reactive and asynchronous programming.
-
Flow: For reactive data streams
- Use
StateFlowfor state that can be observed - Use
Flowfor data streams - Combine flows using operators like
combine,flatMapLatest
- Use
-
Coroutines: For asynchronous programming
- Use
viewModelScopein ViewModels - Handle errors with try-catch blocks
- Use
Dispatchers.IOfor I/O operations
- Use
Koin is used for dependency injection, supporting multiplatform development.
- Koin: Dependency injection framework
- Use latest stable version (check
gradle/libs.versions.tomlfor current version) - Define modules in
commonMainwhen possible - Use
expect/actualpattern for platform-specific dependencies - Module structure:
val commonModule: Module = module { ... }
- Use latest stable version (check
val exampleModule = module {
single<ExampleRepository> { ExampleRepositoryImpl(get()) }
single<GetDataUseCase> { GetDataUseCase(get()) }
viewModel { ExampleViewModel(get()) }
}modulekmm/
├── src/
│ ├── commonMain/kotlin/ # Shared code
│ ├── commonTest/kotlin/ # Shared tests
│ ├── androidMain/kotlin/ # Android-specific code
│ ├── androidUnitTest/kotlin/# Android unit tests
│ ├── desktopMain/kotlin/ # Desktop-specific code
│ └── iosMain/kotlin/ # iOS-specific code (when applicable)
- Domain Layer: Models, use cases, repository interfaces
- Data Layer: Repository implementations, data sources
- UI Layer: Composables, ViewModels, navigation
- DI Layer: Dependency injection modules
- Use
expect/actualpattern for platform differences - Keep platform-specific code minimal
- Prefer shared implementations in
commonMain
- Components: Always check DHIS2 design system first
- Use components from
org.hisp.dhis.mobile.ui.designsystem.component.* - Use theme from
org.hisp.dhis.mobile.ui.designsystem.theme.DHIS2Theme
- Use components from
- Navigation: Use Compose Navigation for multiplatform
- Resources: Place in
commonMain/composeResources/ - Theming: Use DHIS2Theme wrapper
- Never bypass the SDK: Always use DHIS2 Android SDK for all data operations
- Use components from
org.hisp.dhis.android.core.*
- Use components from
- Offline-first: Design with offline capabilities in mind
- Sync handling: Let the SDK handle synchronization
- Error handling: Handle SDK exceptions appropriately
- Unit Tests: Place in appropriate test directories
- Shared Tests: Use
commonTestfor platform-agnostic tests - Mocking: Use Mockito (
org.mockito.kotlin) for mocking —mock(),whenever(),doReturn,verify() - Repository Tests: Mock DHIS2 SDK components
- Integration Tests: Test full flows from ViewModel through use cases to a mocked repository (see
TwoFAScreenConfigurationIntegrationTestfor a reference) - UI Tests: Follow the Robot pattern for instrumented tests (see detailed section below)
- Location: Place UI tests in
androidInstrumentedTest - Pattern: Use Robot pattern for test actions and assertions
- Async handling: Use
CoroutineTrackerwithlaunchUseCase- never use hard-coded delays- Espresso's
IdlingResourceautomatically waits for tracked operations to complete - This enables faster, more reliable tests without manual wait mechanisms
- Espresso's
- Test tags: Add
Modifier.testTag()to interactive UI components- Format:
{SCREEN}_{COMPONENT}_TAG(e.g.,LOGIN_BUTTON_TAG) - Export constants from screen files for test imports
- Format:
- DHIS2 design system components: These are composite components
- Click the wrapper with your test tag to focus it
- Use
"INPUT_TEXT_FIELD"tag to find inner fields - Use
performTextInput()(notperformTextReplacement())
- Mock server: Use
MockWebServerRobotfor API mocking - Best practices:
- Use
waitUntilExactlyOneExists()for element visibility - Use descriptive robot method names (e.g.,
clickLoginButton()) - Keep robots focused on actions, not assertions
- Test user flows, not isolated components
- Mock all external dependencies (network, SDK responses)
- Clean up after tests (databases, preferences)
- Use
fun exampleRobot(
composeTestRule: ComposeTestRule,
robotBody: ExampleRobot.() -> Unit
) {
ExampleRobot(composeTestRule).apply { robotBody() }
}
class ExampleRobot(val composeTestRule: ComposeTestRule) : BaseRobot() {
fun typeUsername(username: String) {
composeTestRule.waitUntilExactlyOneExists(hasTestTag(USERNAME_TAG), TIMEOUT)
composeTestRule.onNodeWithTag(USERNAME_TAG).performClick()
composeTestRule.onAllNodesWithTag("INPUT_TEXT_FIELD")[0].performTextInput(username)
}
fun clickSubmitButton() {
composeTestRule.waitUntilExactlyOneExists(hasTestTag(SUBMIT_TAG), TIMEOUT)
composeTestRule.onNodeWithTag(SUBMIT_TAG).performClick()
}
}class ExampleTest : BaseTest() {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun shouldPerformSuccessfulAction() {
mockWebServerRobot.addResponse(GET, "/api/endpoint", MOCK_RESPONSE, 200)
exampleRobot(composeTestRule) {
typeUsername("user")
clickSubmitButton()
// IdlingResource handles async automatically
checkSuccessMessageDisplayed()
}
cleanDatabase()
}
}Integration tests wire together the ViewModel, use cases, and a mocked repository to verify complete feature flows end-to-end without touching the network or a real database.
- Location: Place in
commonTest(shared across platforms) when the ViewModel and use cases live incommonMain - Pattern: Instantiate real use cases with a mocked repository; inject a
StandardTestDispatcher; observe the ViewModel'sStateFlowvia Turbine (flow.test { … }) - Mocking: Use Mockito (
org.mockito.kotlin) —mock(),whenever(),doReturn,doReturnConsecutively - Async handling: Use
StandardTestDispatcher+Dispatchers.setMain(testDispatcher)/Dispatchers.resetMain()andrunTest { … } - Best practices:
- Use
@BeforeTest/@AfterTest(fromkotlin.test) to set up and tear down the dispatcher - Follow the Given / When / Then comment structure
- Assert every intermediate state (loading, success, error) not just the final result
- Use
cancelAndIgnoreRemainingEvents()to cleanly finish a Turbine block
- Use
@OptIn(ExperimentalCoroutinesApi::class)
class ExampleIntegrationTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var repository: ExampleRepository
private lateinit var viewModel: ExampleViewModel
private val dispatchers = Dispatcher(testDispatcher, testDispatcher, testDispatcher)
@BeforeTest
fun setup() {
Dispatchers.setMain(testDispatcher)
repository = mock()
}
@AfterTest
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `Given repository returns data, When ViewModel loads, Then success state is shown`() =
runTest {
// Given
whenever(repository.getData()) doReturn Result.success(listOf("item"))
// When
viewModel = ExampleViewModel(GetDataUseCase(repository), dispatchers)
// Then
viewModel.uiState.test {
assertEquals(UiState.Loading, awaitItem())
assertTrue(awaitItem() is UiState.Success)
cancelAndIgnoreRemainingEvents()
}
}
}- Kotlin conventions: Follow official Kotlin coding conventions
- ktlint: Project uses ktlint for formatting
- Imports: Organize imports, prefer explicit imports
- Documentation: Document public APIs with KDoc
- Always use DHIS2 design system components before falling back to Material components
- Never create direct database or network operations - use DHIS2 SDK exclusively
- Keep business logic in ViewModels or Use Cases, not in Composables
- Use sealed classes for state representation
- Prefer composition over inheritance
- Write tests for business logic and repositories
- Handle loading and error states appropriately
- Follow offline-first design principles
- Keep platform-specific code minimal - use
expect/actualpattern - Use meaningful commit messages and follow Git flow
- Coroutine cancellation: Remember to handle coroutine cancellation properly
- SDK exception handling: DHIS2 SDK operations might throw exceptions - always handle them
- Component availability: Some DHIS2 design system components might not be available yet - check documentation
- RxJava migration: When migrating from RxJava to Coroutines/Flow, ensure proper error handling
- Platform-specific resources: Handle resources differently in multiplatform (not all platforms support identical APIs)
- Code organization: Move shared logic to
commonMain; extract platform-specific code toandroidMain,desktopMain, etc. - UI conversion: Convert View-based UI to Compose Multiplatform
- Dependency injection: Update to use Koin multiplatform
- Libraries: Prefer multiplatform libraries over platform-specific ones
- Compatibility: Check compatibility with Compose Multiplatform before selecting dependencies
- Documentation: Document public APIs with KDoc