Some checks failed
CI / build (push) Has been cancelled
Bump the version catalog, Gradle wrapper, and convention plugins to the latest stable releases. Verified with `./gradlew assembleDebug test` (BUILD SUCCESSFUL, 21 unit tests pass). Toolchain: - Gradle 9.1.0 -> 9.5.1 - AGP 9.0.1 -> 9.2.1 - Kotlin 2.3.20 -> 2.4.0 Libraries: - androidx-core 1.18.0 -> 1.19.0, appcompat 1.7.0 -> 1.7.1, fragment 1.8.5 -> 1.8.9, navigation 2.9.0 -> 2.9.8 - compose-bom 2026.03.01 -> 2026.05.01, material 1.12.0 -> 1.14.0 - coroutines 1.10.2 -> 1.11.0, serialization 1.8.1 -> 1.11.0, collections-immutable 0.3.8 -> 0.5.0 - koin 4.1.0 -> 4.2.1, ktor 3.1.3 -> 3.5.0, coil 3.1.0 -> 3.5.0 - JUnit 5.11.4 -> 6.1.0, turbine 1.2.0 -> 1.2.1, mockk 1.14.3 -> 1.14.11 Required side-effect: - compileSdk 36 -> 37 (mandated by androidx.core 1.19.0); targetSdk left at 36. Also refresh stale JUnit 5 / AGP 9.0 / compileSdk 36 references in the README and convention-plugin docs.
354 lines
18 KiB
Markdown
354 lines
18 KiB
Markdown
# 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.2.1, Kotlin 2.4.0, Gradle 9.5.1, `compileSdk` 37 / `targetSdk` 36, `minSdk` 24, Java 17 |
|
|
| UI | Jetpack Compose (Material 3) + one classic **Views/XML** renderer |
|
|
| DI | Koin 4.2 (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 6, MockK, Turbine, AssertK, `kotlinx-coroutines-test`, Ktor `MockEngine`, Compose UI test |
|
|
|
|
> **AGP 9 gotcha:** AGP 9.2 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<out D, out E : Error> { 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<Int>("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<CharacterRepository>() }
|
|
}
|
|
|
|
// :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 6**, **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 6 |
|
|
| `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 6 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 6)
|
|
./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 <avd-name> # 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 "<topic>" # search authoritative Android docs
|
|
```
|
|
|
|
Requires JDK 17+ (the Gradle build pins a Java 17 toolchain) and the Android SDK
|
|
(`compileSdk 37`, `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.
|