REDI-97: comprehensive architecture README
Document module structure + dependency rules, the full data->UI flow (DTO -> mapper -> domain -> UseCase -> VM -> UiModel), MVI vs MVVM, one-ViewModel-two- renderers (Compose vs Views + interop + the Material3-XML-theme gotcha), Result/DataError/UiText with the error-demo walkthrough, navigation, Koin constructor DSL, the testing approach (incl. the JUnit5-on-AGP9 and Espresso notes), build/run via the android CLI, and the optional Room stretch. Each section cites its convention skill.
This commit is contained in:
400
README.md
400
README.md
@@ -1,84 +1,388 @@
|
||||
# Android Architecture Showcase
|
||||
|
||||
A single runnable **Android-only (Jetpack Compose)** reference app that demonstrates good
|
||||
architecture conventions — each in its own module/example. Teaching repo: every module is meant to
|
||||
be minimal but complete and idiomatic.
|
||||
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 from the
|
||||
> [Linear backlog](https://linear.app/adrian-kuta/project/android-architecture-showcase-b5ecdeddda6c).
|
||||
> **Foundation**, **Core Infrastructure**, the **Flagship MVI** characters feature, and
|
||||
> **Breadth & Contrast** (character detail, the MVVM About screen, the Views renderer, and
|
||||
> Compose↔View interop) are complete and the project assembles green. Full architecture docs land
|
||||
> with the *Quality & Docs* 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)
|
||||
- [Convention skills index](#convention-skills-index)
|
||||
|
||||
---
|
||||
|
||||
## Stack
|
||||
|
||||
Multi-module Gradle + `build-logic` convention plugins · Koin (constructor DSL) · Ktor ·
|
||||
KotlinX Serialization · Coil · Timber · type-safe Compose Navigation. Data comes from the no-key
|
||||
[Rick & Morty API](https://rickandmortyapi.com/).
|
||||
| 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, Turbine, AssertK, `kotlinx-coroutines-test`, Ktor `MockEngine`, Compose UI test |
|
||||
|
||||
What it showcases: **MVI** as the primary presentation pattern (flagship *characters* feature),
|
||||
an **MVVM** contrast screen (*about*), and the same MVI `ViewModel` driven by **two renderers** —
|
||||
Jetpack Compose and classic **XML + ViewBinding + RecyclerView** — proving the presentation logic is
|
||||
UI-toolkit-agnostic. See [Presentation patterns](#presentation-patterns-mvi-vs-mvvm) below.
|
||||
> **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
|
||||
---
|
||||
|
||||
## 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
|
||||
:build-logic → Gradle convention plugins (the only place versions/config live)
|
||||
:core:domain → Result/error types, shared domain models (pure Kotlin)
|
||||
:core:data → Ktor HttpClient factory, safe-call helpers
|
||||
:core:presentation → UiText, ObserveAsEvents, DataError → UiText
|
||||
:core:design-system → AppTheme + reusable composables
|
||||
:feature:characters:domain → models + repository interface (pure Kotlin)
|
||||
:feature:characters:data → DTOs, mappers, data source, repository impl
|
||||
:feature:characters:presentation → MVI ViewModel/State/Action/Event (UI-agnostic: no Compose, no Views)
|
||||
:feature:characters:presentation-compose → Compose renderer
|
||||
:feature:characters:presentation-views → Views/XML renderer (same ViewModel)
|
||||
:feature:about:presentation → MVVM contrast screen
|
||||
: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:** `presentation → domain ← data`; `domain` depends only on `:core:domain`;
|
||||
features never depend on other features; `:app` wires the graph.
|
||||
**Dependency rules** (enforced by what each convention plugin exposes):
|
||||
|
||||
## Presentation patterns (MVI vs MVVM)
|
||||
| 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 |
|
||||
|
||||
Both patterns live side by side so the trade-off is concrete, not theoretical.
|
||||
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.
|
||||
|
||||
See **android-module-structure**.
|
||||
|
||||
---
|
||||
|
||||
## 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()`). See
|
||||
**android-data-layer**, **android-data-layer-mappers**.
|
||||
- **UI models** (`*Ui`) live in `presentation` and carry display-ready data (e.g. blank detail fields
|
||||
pre-formatted to an em dash). See **android-presentation-mvi**.
|
||||
|
||||
### 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. See **android-module-structure**, **android-di-koin**.
|
||||
|
||||
---
|
||||
|
||||
## 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` directly |
|
||||
| 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 characters list is MVI because its state is genuinely complex — pagination, loading
|
||||
vs. next-page loading, error surfacing, and `SavedStateHandle` restore after process death — and it
|
||||
emits navigation/snackbar effects. The About screen 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. Rule of thumb: **reach for MVI when state is complex and side
|
||||
effects matter; reach for MVVM when the screen is simple.**
|
||||
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.
|
||||
|
||||
### One ViewModel, two renderers
|
||||
### Note — Events vs State
|
||||
|
||||
`:feature:characters:presentation` is **UI-toolkit-agnostic** — it has no Compose *and* no Views
|
||||
dependency (state stays Compose-stable via `kotlinx-collections-immutable` rather than `@Stable`).
|
||||
The exact same `CharacterListViewModel` (State/Action/Event/UI-model) is rendered twice:
|
||||
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.
|
||||
|
||||
See **android-presentation-mvi**, **android-compose-ui**.
|
||||
|
||||
---
|
||||
|
||||
## 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`.
|
||||
- `: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.
|
||||
interop) and injects all navigation as callbacks, so the renderers stay decoupled from each other and
|
||||
from navigation.
|
||||
|
||||
## Build & run
|
||||
> **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.
|
||||
|
||||
See **android-compose-ui**, **android-module-structure**.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
See **android-error-handling**, **android-presentation-mvi**.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
See **android-navigation**.
|
||||
|
||||
---
|
||||
|
||||
## 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`.
|
||||
|
||||
See **android-di-koin**, **koin-constructor-dsl**.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
Tests prove the architecture, not just the code. Stack: **JUnit 5**, **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, fakes + 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:
|
||||
|
||||
- **Fakes, not mocks.** `FakeCharacterRepository` is a real in-memory implementation with a
|
||||
`failWith` toggle and call counts — tests assert against working behaviour, not recorded calls.
|
||||
- **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).
|
||||
|
||||
See **android-testing**.
|
||||
|
||||
---
|
||||
|
||||
## Build & run (`android` CLI)
|
||||
|
||||
```bash
|
||||
./gradlew assembleDebug # build the APK
|
||||
./gradlew projects # print the module tree
|
||||
./gradlew check # tests + lint (added in the Quality & Docs milestone)
|
||||
# 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 <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 36`, `minSdk 24`).
|
||||
|
||||
---
|
||||
|
||||
## Optional: Room stretch
|
||||
|
||||
Out of core scope and **not implemented** (tracked as the optional REDI-99). 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. See **android-data-layer**.
|
||||
|
||||
---
|
||||
|
||||
## Convention skills index
|
||||
|
||||
This repo is a narrative index of these conventions:
|
||||
|
||||
| Skill | Where it shows up |
|
||||
|---|---|
|
||||
| android-module-structure | module graph, dependency rules, convention plugins |
|
||||
| android-presentation-mvi | characters list/detail/error-demo (State/Action/Event/VM) |
|
||||
| android-compose-ui | Compose renderers, design-system, previews, stability |
|
||||
| android-navigation | type-safe routes, per-feature graphs, callback decoupling |
|
||||
| android-di-koin / koin-constructor-dsl | feature Koin modules, `*Of` constructor DSL |
|
||||
| android-data-layer / android-data-layer-mappers | data sources, repository, DTOs, mappers |
|
||||
| android-error-handling | `Result`/`DataError`/`UiText`, `safeCall`, the error demo |
|
||||
| android-testing | unit tests, fakes, `MockEngine`, the robot UI test |
|
||||
| android-cli | build/run/emulator steps above |
|
||||
|
||||
Reference in New Issue
Block a user