# Android Architecture Showcase A single, runnable **Android-only (Jetpack Compose)** reference app that demonstrates a complete, idiomatic multi-module architecture - each convention shown in its own minimal-but-complete module. It is a teaching repo: the goal is not features but *how the pieces fit together*. Data comes from the no-key [Rick & Morty API](https://rickandmortyapi.com/). The app lists characters, opens a detail screen, renders that same list **twice** (Compose and classic Views), has a small MVVM *About* screen for contrast, and a dedicated **error-handling demo**. > **Status:** built milestone-by-milestone. Foundation, Core Infrastructure, the flagship MVI > feature, Breadth & Contrast, and Quality & Docs are complete; the project assembles green and ships > unit + UI tests. The only optional item left is the Room offline-cache stretch (see > [Optional: Room stretch](#optional-room-stretch)). --- ## Table of contents - [Stack](#stack) - [Module structure & dependency rules](#module-structure--dependency-rules) - [The data → UI flow](#the-data--ui-flow) - [Presentation patterns: MVI vs MVVM](#presentation-patterns-mvi-vs-mvvm) - [One ViewModel, two renderers (Compose vs Views)](#one-viewmodel-two-renderers-compose-vs-views) - [Errors: `Result`, `DataError`, `UiText`](#errors-result-dataerror-uitext) - [Navigation](#navigation) - [Dependency injection (Koin)](#dependency-injection-koin) - [Testing](#testing) - [Build & run (`android` CLI)](#build--run-android-cli) - [Optional: Room stretch](#optional-room-stretch) --- ## Stack | Concern | Choice | |---|---| | Build | Multi-module Gradle + `:build-logic` **convention plugins**; a single **version catalog** (`gradle/libs.versions.toml`) is the only place versions live | | Toolchain | AGP 9.0.1, Kotlin 2.3.20, Gradle 9.1, `compileSdk`/`targetSdk` 36, `minSdk` 24, Java 17 | | UI | Jetpack Compose (Material 3) + one classic **Views/XML** renderer | | DI | Koin 4.1 (constructor DSL) | | Networking | Ktor (OkHttp engine) + KotlinX Serialization | | Images | Coil 3 | | Navigation | type-safe Compose Navigation (`@Serializable` routes) | | Logging | Timber | | Async | Coroutines + Flow | | Testing | JUnit 5, MockK, Turbine, AssertK, `kotlinx-coroutines-test`, Ktor `MockEngine`, Compose UI test | > **AGP 9 gotcha:** AGP 9.0 has **built-in Kotlin**. Applying `com.android.application`/`library` > auto-applies the Kotlin Android plugin, so the convention plugins must **not** apply > `org.jetbrains.kotlin.android` themselves. Source lives in `src/main/kotlin`. --- ## Module structure & dependency rules Modularized **by feature first, then by layer** (Clean Architecture: `presentation → domain ← data`). Features never depend on each other; anything shared moves to a `core` module; `:app` wires the graph. ``` :app → wires everything; single Activity, Compose host, Koin start :build-logic → Gradle convention plugins (the only place build config lives) :core:domain → Result / Error / DataError, shared contracts (pure Kotlin) :core:data → Ktor HttpClient factory + safe-call helpers (BuildConfig.BASE_URL) :core:presentation → UiText, ObserveAsEvents, DataError → UiText :core:design-system → AppTheme + reusable composables (AppScaffold, ErrorState, …) :feature:characters:domain → models, CharacterRepository, GetCharactersPageUseCase (pure Kotlin) :feature:characters:data → DTOs, mappers, KtorCharacterDataSource, NetworkCharacterRepository :feature:characters:presentation → MVI ViewModels/State/Action/Event (UI-agnostic: no Compose, no Views) :feature:characters:presentation-compose → Compose renderer (list, detail, error demo, nav graph) :feature:characters:presentation-views → Views/XML renderer of the list (same ViewModel) :feature:about:presentation → MVVM contrast screen ``` **Dependency rules** (enforced by what each convention plugin exposes): | Layer | May depend on | |---|---| | `presentation` | own `domain`, `core:domain`, `core:presentation`, `core:design-system` | | `data` | own `domain`, `core:domain`, `core:data` | | `domain` | `core:domain` only - never `data` or `presentation` | | `:app` | everything | A key consequence: `:core:presentation`'s `UiText` is **Compose-free**, and the `compose` convention uses `implementation` (not `api`), so the UI-agnostic `:feature:characters:presentation` never gets Compose on its classpath - which is what lets two different renderers share one ViewModel. --- ## The data → UI flow One request flows through every layer, each with one job: ``` Rick & Morty API │ JSON ▼ CharacterDto / CharactersResponseDto (:data/dto) - serialization shape │ CharacterMapper.toDomain() (:data/mappers) - DTO → domain, never the reverse leaks up ▼ Character / CharactersPage (:domain/model) - pure Kotlin domain model │ CharacterRepository.getCharacters() (:domain contract, :data impl) │ GetCharactersPageUseCase(page) (:domain/usecase) - domain operation (see note) ▼ CharacterListViewModel (:presentation) - holds State, processes Action, emits Event │ Character.toCharacterUi() (:presentation/model)- domain → UI model (display shaping) ▼ CharacterUi in CharacterListState (:presentation) - immutable UI state ▼ CharacterListScreen / CharacterListFragment (:presentation-compose / -views) - dumb renderers ``` - **DTOs** (`*Dto`) live in `data`; **domain models** are separate and never become DTOs/entities. Mappers are pure extension functions in a `mappers/` package (`toDomain()`). - **UI models** (`*Ui`) live in `presentation` and carry display-ready data (e.g. blank detail fields pre-formatted to an em dash). ### Note - when to add a UseCase `GetCharactersPageUseCase` is intentionally a **thin pass-through** included to show the convention. The rule it illustrates: > Add a UseCase when a screen needs **business logic that doesn't belong in the ViewModel** - real > rules, or **composition of several repositories/sources** into one operation. When the ViewModel > would merely forward a single repository call, injecting the repository directly is fine. Here the list VM uses the UseCase; the detail VM calls `CharacterRepository` directly - both are correct, and the contrast is the point. --- ## Presentation patterns: MVI vs MVVM Both patterns live side by side so the trade-off is concrete. | | **MVI** (`:feature:characters:*`) | **MVVM** (`:feature:about:presentation`) | |---|---|---| | State | one immutable `State` data class | one immutable `State` data class | | User input | a single `onAction(Action)` funnel + sealed `Action` | plain public methods on the `ViewModel` | | Side effects | one-time `Event`s via a `Channel` (nav, snackbar) | none - the screen calls a method / uses `LocalUriHandler` | | Best when | state is complex and interacting; effects matter | the screen is small and mostly static | The flagship list is **MVI** because its state is genuinely complex - pagination, loading vs. next-page loading, error surfacing, `SavedStateHandle` restore after process death - and it emits navigation/snackbar effects. *About* is deliberately **MVVM**: a `StateFlow` plus a couple of public methods, with **no `Action` and no `Event` types at all**, because that ceremony would be pure overhead for static content. ### Note - Events vs State State is what the screen **is** (re-rendered on every change, survives recomposition/rotation). Events are things that happen **once** - navigate, show a snackbar. Modeling a one-time effect as state causes it to re-fire on rotation; modeling durable data as an event drops it. MVI keeps them separate (`StateFlow` vs `Channel`); the Compose side consumes events with `ObserveAsEvents`, the Views side with `repeatOnLifecycle`. ### Note - when MVVM is acceptable Reach for MVI when state is complex **and** side effects matter. Reach for plain MVVM when the screen is small, mostly static, and has no real side effects - the *About* screen is the canonical case. --- ## One ViewModel, two renderers (Compose vs Views) `:feature:characters:presentation` is **UI-toolkit-agnostic** - no Compose *and* no Views dependency. State stays Compose-stable via `kotlinx-collections-immutable` (`ImmutableList`) rather than the `@Stable` annotation (which would pull in compose-runtime). The exact same `CharacterListViewModel` (State/Action/Event/UI-model) is rendered twice: - `:feature:characters:presentation-compose` - Jetpack Compose (`LazyColumn`). - `:feature:characters:presentation-views` - `Fragment` + ViewBinding + `RecyclerView`/`DiffUtil`, resolving the **same** Koin `CharacterListViewModel` via `by viewModel()`. `:app` hosts the Views renderer inside the Compose `NavHost` via `AndroidFragment` (Compose↔View interop) and injects all navigation as callbacks, so the renderers stay decoupled from each other and from navigation. > **Material3-XML-theme gotcha:** the host Activity (`MainActivity`) extends **`FragmentActivity`** > (so `AndroidFragment` has a `FragmentManager`) and uses a **Material Components XML theme**, which > the classic Views (e.g. `MaterialToolbar`, `?attr/colorOnSurfaceVariant`) require. A plain > `ComponentActivity` or a non-Material theme breaks the Fragment renderer. --- ## Errors: `Result`, `DataError`, `UiText` Expected failures are **values, not exceptions**. The whole app speaks one typed result: ```kotlin sealed interface Result { Success(data) ; Error(error) } // :core:domain sealed interface DataError : Error { enum Network { NO_INTERNET, NOT_FOUND, SERVER_ERROR, SERIALIZATION, … } ; enum Local { … } } ``` - The **data layer** catches transport/parse exceptions at the boundary (`safeCall` in `:core:data`) and converts them to `Result.Error(DataError.Network.*)` - HTTP status → typed error, and a malformed body → `SERIALIZATION` (the cause chain is unwrapped because Ktor wraps the kotlinx `SerializationException`). Upper layers never see raw exceptions. - The **presentation layer** maps a `DataError` to user-facing **`UiText`** via `DataError.toUiText()` (`:core:presentation`). `UiText` is itself Compose-free (a `StringResource`/`DynamicString`), so a UI-agnostic ViewModel can hold `UiText?` in state; the renderer resolves it (`asString()` in Compose, `asString(context)` in Views). ### The error-handling demo (overflow menu → "Error handling demo") A runnable walk-through of the whole pipeline. Pick a failure to force; the ViewModel produces the real `DataError.Network`, routes it through the **same** steps a genuine call uses, and the shared design-system `ErrorState` renders it: ``` [Force: No internet] → Result.Error(DataError.Network.NO_INTERNET) → onFailure { … } → DataError.toUiText() = UiText.StringResource(R.string.error_no_internet) → ErrorState(message = uiText.asString(), onRetry = { onAction(OnRetry) }) ``` Three distinct cases (`NO_INTERNET`, `NOT_FOUND`, `SERVER_ERROR`) each render their mapped message; **Retry** re-issues the last request as an Action; a successful load **clears** the error. The same `ErrorState` + retry Action is what the real list and detail screens use. --- ## Navigation Type-safe Compose Navigation with `@Serializable` route objects, one nav graph per feature, assembled in `:app`. ```kotlin @Serializable data object CharacterListRoute @Serializable data class CharacterDetailRoute(val characterId: Int) @Serializable data object ErrorDemoRoute ``` - **Intra-feature** navigation (list → detail, list → error demo) is driven by the `NavController` passed into `charactersGraph(navController, …)`. - **Cross-feature / cross-toolkit** destinations (About, the Views renderer) are exposed as **lambda callbacks** supplied by `:app` - a feature never imports another feature's route. - **Nav args without a nav dependency:** type-safe nav serializes `CharacterDetailRoute.characterId` into the destination's arguments, which Navigation copies into the ViewModel's `SavedStateHandle`. `CharacterDetailViewModel` reads `savedStateHandle.get("characterId")` by field name - so the UI-agnostic `presentation` module needs **no** navigation dependency. The same `SavedStateHandle` also persists the list's loaded page across process death. --- ## Dependency injection (Koin) One Koin module per feature layer (only if it has something to provide), all assembled in `ArchitectureApp` - never inside feature modules. Prefer the **constructor DSL**: ```kotlin // :feature:characters:data val charactersDataModule = module { singleOf(::KtorCharacterDataSource) singleOf(::NetworkCharacterRepository) { bind() } } // :feature:characters:presentation val charactersPresentationModule = module { factoryOf(::GetCharactersPageUseCase) // stateless UseCase viewModelOf(::CharacterListViewModel) // same VM used by both renderers viewModelOf(::CharacterDetailViewModel) viewModelOf(::ErrorDemoViewModel) } ``` The lambda form (`single { … }`) appears only where a constructor reference can't express the binding - e.g. `single { HttpClientFactory.create(OkHttp.create()) }` in `coreDataModule` (a factory call, not a constructor). Compose roots inject with `koinViewModel()`; the Fragment uses `by viewModel()` - both resolve the **same** `CharacterListViewModel` class and supply its `SavedStateHandle`. --- ## Testing Tests prove the architecture, not just the code. Stack: **JUnit 5**, **MockK**, **Turbine** (Flow), **AssertK**, `kotlinx-coroutines-test`, Ktor **`MockEngine`**, and Compose UI test. | What | Where | Kind | |---|---|---| | `GetCharactersPageUseCase` | `:feature:characters:domain` `src/test` | pure JVM, JUnit 5 | | `CharacterListViewModel`, `CharacterDetailViewModel` | `:feature:characters:presentation` `src/test` | JVM unit, MockK + Turbine + `SavedStateHandle` | | `NetworkCharacterRepository` | `:feature:characters:data` `src/test` | JVM unit, Ktor `MockEngine` | | `CharacterListScreen` (robot) | `:feature:characters:presentation-compose` `src/androidTest` | instrumented Compose UI | Conventions demonstrated: - **MockK for collaborators.** The ViewModel/UseCase tests stub the `CharacterRepository` interface with MockK - `coEvery` scripts the suspend calls, `coVerify` asserts the paging/retry interactions. - **VM tested through its public MVI surface** (State/Action/Event) with a directly-constructed `SavedStateHandle`, so the same tests hold for either renderer. Coverage includes happy path, error → `UiText` + snackbar `Event`, pagination end-reached, **process-death restore**, and the rapid-duplicate-paging guard (which is why these use `StandardTestDispatcher`). - **Repository tested over a real Ktor client** with a swapped `MockEngine` (`HttpClientFactory.create(engine)`): success mapping, `404 → NOT_FOUND`, `500 → SERVER_ERROR`, malformed body `→ SERIALIZATION`. - **Robot pattern** for the Compose UI test: `CharacterListRobot` methods `return this` so a test reads as a scenario; it asserts a rendered item, the empty/error states, and that a tap fires the right `Action`. > **JUnit 5 on AGP 9:** the `de.mannodermaus.android-junit5` Gradle plugin targets AGP 8.x, so this > repo doesn't use it. `AndroidUnitTest` extends Gradle's `Test`, so the `architecture.android.unit.test` > convention plugin just calls `useJUnitPlatform()` and adds the `unit-test` bundle - including the > `junit-platform-launcher`, which Gradle 9 no longer bundles. > **Espresso + API 34+:** Compose's test rule drives Espresso's `onIdle`, and transitive Espresso > 3.5.0 calls the removed `InputManager.getInstance()`. The catalog pins espresso/runner to current > versions in the `compose-ui-test` bundle to fix that. What runs where: `./gradlew test` (all JVM unit tests) runs in **CI**; the instrumented Compose test runs on a device/emulator via `./gradlew :feature:characters:presentation-compose:connectedDebugAndroidTest` (CI compiles it via `assembleDebugAndroidTest`). An Espresso test for the Fragment renderer is possible but intentionally omitted (the VM logic is already covered by the shared unit tests). --- ## Build & run (`android` CLI) ```bash # Build ./gradlew assembleDebug # build the debug APK ./gradlew projects # print the module tree ./gradlew test # all JVM unit tests (JUnit 5) ./gradlew :feature:characters:presentation-compose:connectedDebugAndroidTest # Compose UI test (needs a device) ``` Using the `android` CLI for an emulator + run: ```bash android emulator list # list AVDs android emulator start # boot an emulator (returns when ready) android run # build & deploy the app android screenshot -o screen.png # capture the current screen android layout --pretty # dump the UI tree (faster than a screenshot for debugging) android docs search "" # search authoritative Android docs ``` Requires JDK 17+ (the Gradle build pins a Java 17 toolchain) and the Android SDK (`compileSdk 36`, `minSdk 24`). --- ## Optional: Room stretch Out of core scope and **not implemented** (an optional stretch). It would add a `room` convention plugin and a `:core:database` (or feature Room set) with `CharacterEntity` + DAO + `@Database` (prefer `autoMigrations`), then convert the repository to **offline-first** (`OfflineFirstCharacterRepository`: network → persist → expose a DB `Flow`; the ViewModel observes the DB, never the network response). The current `CharacterRepository` returning the `DataError` supertype already anticipates a multi-source implementation.