Use when building or fixing a native Android app in Kotlin and Jetpack Compose on the UDF layered architecture — ViewModel/StateFlow, Hilt, Room, Retrofit, coroutines, type-safe Navigation, and the Gradle/AGP surface. NOT shared Android and iOS UI from one Kotlin codebase (that is `compose-multiplatform`).
npx skills add https://github.com/ericrisco/rsc-harness --skill kotlin-android
You build production native Android: **Kotlin + Jetpack Compose + the UDF layered
architecture (UI → domain → data). One rule governs everything: state flows down,
events flow up, and there is exactly one source of truth per piece of state.** Get that
rule right and recomposition, rotation, and testing fall into place; break it and you get
a screen that compiles but recomposes every frame, leaks coroutine scopes, blocks the main
thread, or drops state on rotation.
This skill exists to stop the agent from reaching for the 2015 playbook — XML layouts,
findViewById, AsyncTask, LiveData inside Compose, GlobalScope.launch, MutableState
exposed straight out of a ViewModel. Emit a buildable, idiomatic module, not a toy.
These are floors verified against current docs (2026-06). Re-check the matrix before
locking — Compose and AGP move together and a mismatch fails the build.
| Piece | Floor | Why it matters |
|---|---|---|
| Kotlin (KGP) | 2.2.x | K2 compiler is default & Stable; KGP must align with AGP. |
| AGP | 9.2+ when using Compose | Compose's compileSdk 37 requires AGP ≥ 9.2.0. |
| compileSdk | 37 | Current Compose target. |
| minSdk | 24 | Practical modern floor (~99% devices); Compose technically supports 21+. |
| JDK (build) | 17 | Required by current AGP/Compose toolchain. |
| Compose BOM | 2026.05.01 → Compose 1.11.2 | Pin artifacts via the BOM, never per-artifact versions. |
| Navigation | 2.8+ | Type-safe @Serializable routes land here. |
| Hilt + Room + Retrofit | current | DI / persistence / network. Wire Room & Hilt with KSP, not kapt. |
| Serialization | kotlinx.serialization | Retrofit converter + Navigation route types. |
Three layers. Higher layers depend on lower; never the reverse.
UI layer Composable (stateless) ── reads ──> UiState
│ events up ▲ state down
ViewModel exposes StateFlow<UiState> │
│ calls
domain layer (optional) UseCase — only when logic spans repos or is reused
│ calls
data layer Repository (interface in domain, impl here)
├── Room DAO → Flow (single source of truth)
└── Retrofit → suspend (refreshes the source of truth)
UiState, send events. No business logic in a @Composable.Offline-first means Room is the source of truth. The network writes into Room; the UI
observes Room's Flow. WorkManager does background sync. The UI never reads the network
directly.
Each rule has a one-line reason.
state in, send events out.
remember survives recomposition; rememberSaveable survives rotation/process death.Use rememberSaveable for anything the user would be annoyed to lose (scroll, input).
derivedStateOf for computed state — recomputes only when its inputs change, not everyrecomposition.
key() / stable keys in LazyColumn — without a stable item key, reorder/insert churnsthe whole list.
@Composable — composables run on every recomposition.// Bad — logic + I/O in the composable; runs on every recomposition.
@Composable
fun ArticlesScreen(repo: ArticleRepository) {
val articles = runBlocking { repo.fetch() } // blocks UI thread, refetches constantly
LazyColumn { items(articles) { Text(it.title) } }
}
// Good — stateless UI driven by hoisted state; logic in the ViewModel.
@Composable
fun ArticlesScreen(state: ArticlesUiState, onRetry: () -> Unit) {
when (state) {
ArticlesUiState.Loading -> CircularProgressIndicator()
is ArticlesUiState.Error -> ErrorView(state.message, onRetry)
is ArticlesUiState.Success ->
LazyColumn { items(state.articles, key = { it.id }) { Text(it.title) } }
}
}
Model the screen as a sealed UiState so impossible states (loading *and* error) can't
exist. Expose it as StateFlow, never a mutable type.
sealed interface ArticlesUiState {
data object Loading : ArticlesUiState
data class Success(val articles: List<Article>) : ArticlesUiState
data class Error(val message: String) : ArticlesUiState
}
@HiltViewModel
class ArticlesViewModel @Inject constructor(
repository: ArticleRepository,
) : ViewModel() {
val uiState: StateFlow<ArticlesUiState> =
repository.observeArticles()
.map<List<Article>, ArticlesUiState> { ArticlesUiState.Success(it) }
.catch { emit(ArticlesUiState.Error(it.message ?: "Unknown error")) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = ArticlesUiState.Loading,
)
}
Collect it lifecycle-aware so collection pauses below STARTED and you don't burn work
off-screen:
@Composable
fun ArticlesRoute(viewModel: ArticlesViewModel = hiltViewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
ArticlesScreen(state = state, onRetry = viewModel::refresh)
}
WhileSubscribed(5_000) keeps the flow alive 5s past the last collector so a rotation
doesn't restart the upstream pipeline.
The repository interface lives in domain; its implementation lives in data. Retrofit uses
suspend functions and a @Serializable DTO; Room DAOs return Flow. Errors surface as a
sealed result, not raw exceptions thrown into the UI.
interface ArticleRepository {
fun observeArticles(): Flow<List<Article>> // from Room — the source of truth
suspend fun refresh() // network -> Room
}
The full wired example — DTO, entity, DAO, Retrofit service, repository impl, mappers,
offline-first refresh — is in references/architecture.md.
Read it before scaffolding a feature end-to-end.
@HiltAndroidApp on the Application, @Module @InstallIn(SingletonComponent::class) for
bindings, @HiltViewModel + hiltViewModel() for screens. Prefer constructor injection;
reach for @Provides/@Binds only for interfaces and third-party types you don't own.
Classic error: MyRepo cannot be provided without an @Inject constructor. Fix one of:
either add @Inject constructor(...) to the concrete class, or @Binds the interface to its
implementation inside an installed module. See references/gradle-setup.md
for the KSP wiring that makes Hilt generate.
GlobalScope.launch — it outlives every screen and leaks. Use viewModelScope(UI logic) or a repository-scoped scope.
scope so navigation-away cancels it.
@IoDispatcher CoroutineDispatcher) instead of hardcodingDispatchers.IO — otherwise the work isn't testable.
flowOn to switch a flow's upstream context, not withContext inside a flow { }builder (which is a context-preservation violation).
Flow for streams, StateFlow for current-value state — don't collect a coldflow when the UI needs the latest value.
Since Navigation 2.8, routes are @Serializable types — the compiler checks your args.
@Serializable data object ArticleList
@Serializable data class ArticleDetail(val id: String)
NavHost(navController, startDestination = ArticleList) {
composable<ArticleList> {
ArticlesRoute(onOpen = { navController.navigate(ArticleDetail(it)) })
}
composable<ArticleDetail> { backStackEntry ->
val args = backStackEntry.toRoute<ArticleDetail>()
ArticleDetailRoute(id = args.id)
}
}
No string routes, no manual arguments = listOf(navArgument(...)), no fragile key parsing.
./gradlew :app:assembleDebug lintDebug testDebugUnitTest
assembleDebug proves it builds, lintDebug catches Android lint, testDebugUnitTest runs
JVM unit tests. For a fast, SDK-free static gate over the Kotlin/Gradle you just wrote,
run scripts/verify.sh — it greps for banned patterns and missing modern ones without
touching the Android SDK, so it works in CI or a bare checkout.
| Anti-pattern | Why it bites | Do instead |
|---|---|---|
| XML layouts + findViewById | Stale; no compile-time view safety; doesn't compose | Jetpack Compose @Composable |
| LiveData collected in a Composable | Lifecycle mismatch, extra dependency, not Compose-native | StateFlow + collectAsStateWithLifecycle() |
| GlobalScope.launch | Leaks; outlives the screen; uncancellable | viewModelScope / scoped coroutine |
| runBlocking / network call on the main thread | ANR, frozen UI | suspend on an injected dispatcher |
| Business logic in a @Composable | Runs every recomposition; untestable | Move to ViewModel / use-case |
| MutableStateFlow/mutableStateOf exposed publicly | UI can mutate state; breaks single source of truth | private mutable backing + public asStateFlow()/StateFlow |
| God-Activity holding all state | Untestable, rotation-fragile | One ViewModel per screen, hoisted state |
| kapt for Room/Hilt | Slow, legacy; K2-incompatible edge cases | KSP |
| collectAsState() in Compose | Keeps collecting off-screen, wastes work | collectAsStateWithLifecycle() |
| Reading the network directly in the repository's read path | No offline; UI flickers on every fetch | Room as source of truth; network refreshes it |
commonMain, expect/actual, or an iOS target appears, use
../compose-multiplatform/SKILL.md.
nearest JVM-language sibling (there is no standalone kotlin language skill).
swift-ios. Dart cross-platform → ../flutter/SKILL.md.React Native → react-native.
assembleDebug/tests → github-actions.(UiState + ViewModel + Repository + Room + Retrofit + Hilt), complete file contents.
libs.versions.toml, root +module build.gradle.kts, the Compose Kotlin plugin, Hilt/KSP wiring, and common
Gradle/build error fixes.
Take ericrisco/kotlin-android from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.