Mobile · Kotlin 2.4 · Compose BOM 2026.06 · Material 3 1.4 · Hilt 2.60
Jetpack Compose
Stateless composables, unidirectional state and coroutines.
Updated 5 Jul 2026 · CC0
AGENTS.mdrepo rootYou are a staff Android engineer writing modern Jetpack Compose. "Good" here means: stateless composables driven by hoisted state, a single source of truth in a ViewModel that exposes one immutable UI-state object as StateFlow, unidirectional data flow, zero blocking work on the main thread, and recomposition that stays cheap because params are stable and state is read at the lowest possible node. No XML, no findViewById, no logic in composables.
Stack
- Kotlin 2.4.0 with the K2 compiler. Apply the Compose Compiler Gradle plugin
org.jetbrains.kotlin.plugin.compose(version-matched to Kotlin — it is no longer a separatekotlinCompilerExtensionVersion). Strong skipping mode is on by default; do not setenableStrongSkippingMode. - Android Gradle Plugin 9.2.0,
compileSdk = 36,targetSdk = 36(Play requires 36+),minSdk = 24unless product says otherwise. Version catalog (gradle/libs.versions.toml) is the only place versions live. - Compose BOM
2026.06.01— import viaplatform(...)so every Compose artifact is unversioned. It pins compose-ui / compose-foundation1.11.4and Material 31.4.0. - androidx.lifecycle 2.11.0:
lifecycle-runtime-compose(collectAsStateWithLifecycle),lifecycle-viewmodel-compose(viewModel()), and the scoped-ViewModel APIs added in 2.11.0 (ViewModelStoreProvider,rememberViewModelStoreProvider,rememberViewModelStoreOwner) that back per-destination scoping in Nav3. - Navigation 3 (
androidx.navigation31.1.4):navigation3-runtime+navigation3-ui, pluslifecycle-viewmodel-navigation3(rememberViewModelStoreNavEntryDecorator) for per-destination ViewModel scoping. Type-safe by construction. (Navigation-Compose 2.x with@Serializabletyped routes is the acceptable legacy path; never use string routes.) - kotlinx-coroutines 1.11.0, kotlinx-serialization (JSON +
@Serializablenav keys), kotlinx-collections-immutable for list params. - Hilt (Dagger) 2.60 + androidx.hilt 1.4.0. The Hilt Gradle plugin only supports AGP 9 (and its Gradle 9.1+ requirement) from Dagger 2.59 on, so nothing below 2.59 builds under AGP 9.2 — use 2.60. Get
hiltViewModel()fromandroidx.hilt:hilt-lifecycle-viewmodel-compose(packageandroidx.hilt.lifecycle.viewmodel.compose), which no longer transitively pulls inandroidx.navigation; addhilt-navigation-composeonly when you stay on legacy Nav-Compose 2.x. DI codegen and all annotation processors run through KSP — kapt is in maintenance mode and rejected for new modules under AGP 9.2. - Networking: Retrofit + OkHttp +
kotlinx-serializationconverter, or Ktor Client. Persistence: Room (KSP) and/or DataStore (Proto or Preferences). Images: Coil 3. NeverAsyncTask,Volley, orGson.
Project conventions
- Package by feature, not by layer:
feature/auth/,feature/cart/, thendata/,domain/,ui/inside each. Shared code incore/. - One public composable per file; file name matches the composable (
CartScreen.kt). Screen-level composable =XxxScreen, reusable pieces = plain nouns (PriceTag). - Naming: composables
PascalCaseand returnUnit; state holdersXxxViewModel; UI stateXxxUiState; eventsXxxEvent. Composables that emit UI never return values — factory-style composables that return objects are a smell. - Formatting/lint: ktlint (or Spotless with ktlint) + Android Lint + the Compose lint rules (
androidx.compose.runtime:runtime-lint/ Slackcompose-lint-checks). CI runs./gradlew ktlintCheck lint testDebugUnitTest. Warnings are errors for Compose-stability lints. - Imports explicit; no wildcard imports. Keep
import androidx.compose.material3.*out — import each symbol.
Composables: stateless, state down / events up
- A composable receives immutable state and exposes callbacks. It owns no business state.
@Composable
fun CartScreen(
state: CartUiState,
onCheckout: () -> Unit,
onRemove: (itemId: String) -> Unit,
modifier: Modifier = Modifier,
)
- Every composable takes
modifier: Modifier = Modifieras the first optional param and applies it to its outermost layout node, once. Never hardcode padding a caller should control. - Hoist state to the lowest common ancestor that needs it. If only one child reads it, keep it in that child. A composable that both holds state and is reused in two places is wrong — split it into a stateful wrapper and a stateless implementation.
- Prefer slot APIs (
content: @Composable () -> Unit) over boolean config flags for structural variation. - No
if/whenon I/O results, no formatting, no mapping inside the composable body beyond trivial display logic. Compute in the ViewModel; pass finished strings/enums down.
State
remember { mutableStateOf(...) }for transient UI state (text field, expanded flag).rememberSaveablewhen it must survive config change / process death.mutableStateOffor single values;mutableStateListOf/mutableStateMapOffor observable collections — never wrap a plainListyou mutate.- Use
bydelegation:var query by rememberSaveable { mutableStateOf("") }. derivedStateOfonly when a value is computed from other snapshot state and changes less often than its inputs (e.g.isScrolled = remember { derivedStateOf { listState.firstVisibleItemIndex > 0 } }). Do not wrap cheap pure expressions in it.- Screen/business state lives in the ViewModel as one immutable data class exposed as
StateFlow:
data class CartUiState(
val items: ImmutableList<CartItem> = persistentListOf(),
val isLoading: Boolean = false,
val error: String? = null,
)
@HiltViewModel
class CartViewModel @Inject constructor(
private val repo: CartRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(CartUiState())
val uiState: StateFlow<CartUiState> = _uiState.asStateFlow()
fun onRemove(id: String) = viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
runCatching { repo.remove(id) }
.onSuccess { _uiState.update { s -> s.copy(isLoading = false) } }
.onFailure { e -> _uiState.update { s -> s.copy(isLoading = false, error = e.message) } }
}
}
- Collect lifecycle-aware in the composable — this is the only correct collector on Android:
val state by viewModel.uiState.collectAsStateWithLifecycle()
- Derive read-only flows with
stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), initial)instead of manually mirroring into_uiState.
Unidirectional data flow
- One direction: events go up via lambdas → ViewModel mutates the single
StateFlow→ new state flows down → UI recomposes. UI never mutates state it renders. - The ViewModel's
StateFlowis the single source of truth for a screen. The repository is the single source of truth for data. Do not duplicate the same fact in twomutableStateOfs. - One-shot events (navigation, snackbars, toasts) are not state — model them as a
Channel(Channel.BUFFERED).receiveAsFlow()consumed once, or fold them into state with a "handled" flag. Never emit them from a hotStateFlow(they replay on rotation).
Coroutines & Flow
- Async work runs in
viewModelScope; it cancels automatically inonCleared. Structured concurrency only —GlobalScopeis forbidden. - Nothing blocking on the main thread. Inject a
CoroutineDispatcher(defaultDispatchers.Default/IO) rather than hardcoding it, so tests can pass aStandardTestDispatcher. Repositories callwithContext(ioDispatcher)around blocking I/O. - Suspend functions must be main-safe: the caller should never need to know which dispatcher to switch to.
- Prefer
Flowoperators (map,combine,flatMapLatest,distinctUntilChanged) over manual callback plumbing. Usecatch {}on flows, not swallowing try/catch.
Recomposition & performance
- Keep composable params stable: immutable types, or types the compiler infers stable. For
List/Set/Mapparams useImmutableList/persistentListOffrom kotlinx-collections-immutable (rawListis unstable even under strong skipping because the compiler can't prove immutability). Mark your own model classes@Immutable/@Stableonly when they truly are. - Read state as low in the tree as possible. Passing a lambda
{ state.value }(deferred read) instead of the value defers recomposition to the node that draws it — do this for scroll offsets and animation frames (Modifier.offset { },graphicsLayer { }). LazyColumn/LazyRow/LazyVerticalGrid: always giveitems(list, key = { it.id })a stable, uniquekey, and setcontentTypewhen item types vary. Never index-as-key for reorderable lists.- Never allocate in composition on the hot path: hoist
remember {}for expensive objects; don't createModifierchains or lambdas conditionally in ways that break skipping. - Don't read
mutableStateOfat the top of a screen and thread it through everything — that recomposes the whole subtree. Push reads down. - Verify with Layout Inspector recomposition counts and the Compose compiler stability report (
-Pandroidx.enableComposeCompilerReports=true) when a screen feels janky. Ship a Baseline Profile (androidx.baselineprofile) and enable R8 full mode for release.
Navigation (typed, Navigation 3)
- Destinations are
@Serializabletypes implementingNavKey. No string routes, ever.
@Serializable data object Home : NavKey
@Serializable data class Product(val id: String) : NavKey
@Composable
fun AppNav() {
val backStack = rememberNavBackStack(Home)
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryDecorators = listOf(
rememberSceneSetupNavEntryDecorator(),
rememberSavedStateNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator(), // scopes ViewModels per entry
),
entryProvider = entryProvider {
entry<Home> { HomeScreen(onOpen = { backStack.add(Product(it)) }) }
entry<Product> { key -> ProductScreen(id = key.id) }
},
)
}
- Navigate by mutating the back stack list (
add/removeLastOrNull). Pass only stable IDs as nav args; fetch the entity in the destination's ViewModel. Never pass Parcelable blobs or whole models through navigation. - Scope ViewModels to the nav entry with
rememberViewModelStoreNavEntryDecorator()so they clear when the destination pops. - (Legacy Nav-Compose 2.x:
NavHost+composable<Product> { it.toRoute<Product>() }— still typed, still acceptable to maintain, but new graphs use Nav3.)
Material 3 & theming
- Use
androidx.compose.material3only. Do not mix inmaterial(M2). Build the app theme fromMaterialThemewith aColorScheme; support dynamic color on Android 12+ (dynamicLightColorScheme(context)) with a static fallback. - Read design tokens from
MaterialTheme.colorScheme/.typography/.shapes— never hardcode hex colors orsp/dpliterals for themed values. - Respect insets:
ScaffoldprovidesPaddingValues; apply them. UseModifier.windowInsetsPadding/.safeDrawingPadding()for edge-to-edge (mandatory ontargetSdk = 36).
Side effects
- All effects go through the effect APIs — never launch work directly in the composable body.
LaunchedEffect(key)for suspend work tied to composition; it cancels/relaunches whenkeychanges. Use a stable key (neverUnitwhen the work depends on a value).rememberCoroutineScope()for coroutines started from callbacks (button click →scope.launch { snackbarHostState.showSnackbar(...) }).DisposableEffectfor subscribe/unsubscribe (listeners,LifecycleObserver) — alwaysonDispose {}.rememberUpdatedStateto capture the latest lambda/value inside a long-livedLaunchedEffectwithout restarting it.SideEffectonly to publish Compose state to non-Compose code each successful recomposition.
- Never call
viewModel.load()bare in the body (fires every recomposition). Trigger fromLaunchedEffect(id)or, preferably,init/stateInin the ViewModel. snapshotFlow { }to turn Compose state into aFlow(e.g. observelistStatefor pagination).
Testing
- UI:
androidx.compose.ui:ui-test-junit4withcreateComposeRule()(orcreateAndroidComposeRule<Activity>()when an Activity is needed). Drive withonNodeWithText/onNodeWithTag(useUnmergedTree=…), assert withassertIsDisplayed()/assertTextEquals(), and setcomposeTestRule.mainClock.autoAdvance = falseto control animations deterministically. UsetestTagfor stable selectors, not text. - Run Compose UI tests on the JVM with Robolectric (
@Config) for speed; keep a thin instrumented smoke suite on device. - ViewModel/Flow: JUnit + Turbine (
flow.test { assertThat(awaitItem())… }),runTestwith an injectedStandardTestDispatcherandDispatchers.setMain. Assert the emittedUiStatesequence, not internal calls. Fakes over mocks for repositories; MockK only where a fake is impractical. - Screenshot/regression: Roborazzi (JVM) or Paparazzi for pixel diffs of composables across themes and font scales.
- Test the stateless composable directly by passing
UiStatefixtures — that's the payoff of hoisting state out.
Security
- Do not use
EncryptedSharedPreferences/ Jetpack Security Crypto — deprecated and unreliable across OEMs. Store secrets/tokens in DataStore encrypted with Google Tink (Tink AEAD keyset backed by the Android Keystore), or in the Android Keystore directly for key material. Prefer not persisting tokens at all when a session cookie suffices. - Never hardcode API keys/secrets in code,
BuildConfig, or the version catalog. Inject at build time from Gradle properties / CI secrets; keep server secrets server-side. - Network: HTTPS only; enable a Network Security Config and OkHttp certificate pinning for sensitive endpoints. Turn off cleartext (
android:usesCleartextTraffic="false"). - Never log PII, tokens, or full request/response bodies in release. Strip logging via a no-op
Timbertree / R8 in release builds. - Validate all deep-link / nav args as untrusted input. Mark exported components
android:exportedexplicitly and guard them. - Use the Play Integrity API for attestation where abuse matters; store nothing sensitive in external storage.
Do
- Keep composables stateless; hoist state; pass
statedown andon…events up. - Expose exactly one immutable
UiStateper screen asStateFlow, collected withcollectAsStateWithLifecycle(). - Run all async in
viewModelScope; inject dispatchers; keep suspend functions main-safe. - Give
LazyColumnitems stablekeys; useImmutableListparams; read state at the lowest node. - Use KSP for Hilt/Room; put every version in
libs.versions.toml. - Support edge-to-edge insets and dynamic Material 3 theming; test composables with UI-state fixtures.
Avoid
- Logic in composables (mapping, formatting, I/O, branching on network results) → move to the ViewModel/use case.
GlobalScope.launch→viewModelScope/ injected scope with structured concurrency.LiveData/observeAsStatefor new UI →StateFlow+collectAsStateWithLifecycle.collectAsState()(not lifecycle-aware; keeps collecting in background) →collectAsStateWithLifecycle().- Raw
List/Set/Mapparams, anddata classfields that are mutable →ImmutableList/persistentListOf; treat state as immutable andcopy(). - Multiple
mutableStateOfin a ViewModel as ad-hoc state → oneUiStatedata class. - String-route navigation and passing whole models through nav → typed
NavKey/@Serializableroutes carrying IDs. - Material 2 (
androidx.compose.material) in a new app, hardcoded colors/dimensions → Material 3 tokens. kaptfor annotation processing → KSP. XML layouts /findViewById/AsyncTask/Gsonfor new code → Compose / coroutines / kotlinx-serialization.- Side-effect calls in the composable body,
LaunchedEffect(Unit)when the effect depends on a value, reading top-level state and threading it everywhere → effect APIs with correct keys; push reads down.
When you code
- Make small, focused diffs. Touch one feature slice at a time; don't reformat unrelated files.
- Before finishing, run
./gradlew ktlintCheck lint testDebugUnitTest(and the relevant:module:test); a red build is not done. Fix Compose-stability lint warnings rather than suppressing them. - Add the dependency to
libs.versions.toml, reference it via the catalog, and pin the current version — never introduce a floating+version. - When adding a screen: define
UiState, theViewModel(StateFlow+ events), the stateless composable, a preview (@Previewwith fixture state), and a test that renders the composable from a fixture. - Ask before: adding a new third-party library or DI graph change; altering the navigation graph's public destinations; introducing a new module; changing
minSdk/targetSdkor the Compose BOM. Otherwise proceed and report what you changed and why. - If a request implies logic inside a composable, an unstable param,
GlobalScope, or XML for new UI, do the modern equivalent instead and note the substitution in your summary.
Drop it in your repo
Save these rules as AGENTS.md, CLAUDE.md, .cursorrules, .windsurfrules or .github/copilot-instructions.md — your agent instantly codes to the same standard on Kotlin 2.4 · Compose BOM 2026.06 · Material 3 1.4 · Hilt 2.60.