Promptheus/rules53 rule sets · CC0Promptheus hub ↗

Mobile · Kotlin 2.4 · Compose BOM 2026.06 · Material 3 1.4 · Hilt 2.60

Jetpack Compose

Stateless composables, unidirectional state and coroutines.

kotlincomposeandroidmobile

Updated 5 Jul 2026 · CC0

AGENTS.mdrepo root

You 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 separate kotlinCompilerExtensionVersion). Strong skipping mode is on by default; do not set enableStrongSkippingMode.
  • Android Gradle Plugin 9.2.0, compileSdk = 36, targetSdk = 36 (Play requires 36+), minSdk = 24 unless product says otherwise. Version catalog (gradle/libs.versions.toml) is the only place versions live.
  • Compose BOM 2026.06.01 — import via platform(...) so every Compose artifact is unversioned. It pins compose-ui / compose-foundation 1.11.4 and Material 3 1.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.navigation3 1.1.4): navigation3-runtime + navigation3-ui, plus lifecycle-viewmodel-navigation3 (rememberViewModelStoreNavEntryDecorator) for per-destination ViewModel scoping. Type-safe by construction. (Navigation-Compose 2.x with @Serializable typed routes is the acceptable legacy path; never use string routes.)
  • kotlinx-coroutines 1.11.0, kotlinx-serialization (JSON + @Serializable nav 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() from androidx.hilt:hilt-lifecycle-viewmodel-compose (package androidx.hilt.lifecycle.viewmodel.compose), which no longer transitively pulls in androidx.navigation; add hilt-navigation-compose only 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-serialization converter, or Ktor Client. Persistence: Room (KSP) and/or DataStore (Proto or Preferences). Images: Coil 3. Never AsyncTask, Volley, or Gson.

Project conventions

  • Package by feature, not by layer: feature/auth/, feature/cart/, then data/, domain/, ui/ inside each. Shared code in core/.
  • One public composable per file; file name matches the composable (CartScreen.kt). Screen-level composable = XxxScreen, reusable pieces = plain nouns (PriceTag).
  • Naming: composables PascalCase and return Unit; state holders XxxViewModel; UI state XxxUiState; events XxxEvent. 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 / Slack compose-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 = Modifier as 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/when on 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). rememberSaveable when it must survive config change / process death.
  • mutableStateOf for single values; mutableStateListOf / mutableStateMapOf for observable collections — never wrap a plain List you mutate.
  • Use by delegation: var query by rememberSaveable { mutableStateOf("") }.
  • derivedStateOf only 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 StateFlow is 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 two mutableStateOfs.
  • 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 hot StateFlow (they replay on rotation).

Coroutines & Flow

  • Async work runs in viewModelScope; it cancels automatically in onCleared. Structured concurrency only — GlobalScope is forbidden.
  • Nothing blocking on the main thread. Inject a CoroutineDispatcher (default Dispatchers.Default/IO) rather than hardcoding it, so tests can pass a StandardTestDispatcher. Repositories call withContext(ioDispatcher) around blocking I/O.
  • Suspend functions must be main-safe: the caller should never need to know which dispatcher to switch to.
  • Prefer Flow operators (map, combine, flatMapLatest, distinctUntilChanged) over manual callback plumbing. Use catch {} on flows, not swallowing try/catch.

Recomposition & performance

  • Keep composable params stable: immutable types, or types the compiler infers stable. For List/Set/Map params use ImmutableList/persistentListOf from kotlinx-collections-immutable (raw List is unstable even under strong skipping because the compiler can't prove immutability). Mark your own model classes @Immutable/@Stable only 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 give items(list, key = { it.id }) a stable, unique key, and set contentType when 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 create Modifier chains or lambdas conditionally in ways that break skipping.
  • Don't read mutableStateOf at 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.
  • Destinations are @Serializable types implementing NavKey. 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.material3 only. Do not mix in material (M2). Build the app theme from MaterialTheme with a ColorScheme; support dynamic color on Android 12+ (dynamicLightColorScheme(context)) with a static fallback.
  • Read design tokens from MaterialTheme.colorScheme / .typography / .shapes — never hardcode hex colors or sp/dp literals for themed values.
  • Respect insets: Scaffold provides PaddingValues; apply them. Use Modifier.windowInsetsPadding / .safeDrawingPadding() for edge-to-edge (mandatory on targetSdk = 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 when key changes. Use a stable key (never Unit when the work depends on a value).
    • rememberCoroutineScope() for coroutines started from callbacks (button click → scope.launch { snackbarHostState.showSnackbar(...) }).
    • DisposableEffect for subscribe/unsubscribe (listeners, LifecycleObserver) — always onDispose {}.
    • rememberUpdatedState to capture the latest lambda/value inside a long-lived LaunchedEffect without restarting it.
    • SideEffect only to publish Compose state to non-Compose code each successful recomposition.
  • Never call viewModel.load() bare in the body (fires every recomposition). Trigger from LaunchedEffect(id) or, preferably, init/stateIn in the ViewModel.
  • snapshotFlow { } to turn Compose state into a Flow (e.g. observe listState for pagination).

Testing

  • UI: androidx.compose.ui:ui-test-junit4 with createComposeRule() (or createAndroidComposeRule<Activity>() when an Activity is needed). Drive with onNodeWithText / onNodeWithTag(useUnmergedTree=…), assert with assertIsDisplayed() / assertTextEquals(), and set composeTestRule.mainClock.autoAdvance = false to control animations deterministically. Use testTag for 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())… }), runTest with an injected StandardTestDispatcher and Dispatchers.setMain. Assert the emitted UiState sequence, 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 UiState fixtures — 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 Timber tree / R8 in release builds.
  • Validate all deep-link / nav args as untrusted input. Mark exported components android:exported explicitly 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 state down and on… events up.
  • Expose exactly one immutable UiState per screen as StateFlow, collected with collectAsStateWithLifecycle().
  • Run all async in viewModelScope; inject dispatchers; keep suspend functions main-safe.
  • Give LazyColumn items stable keys; use ImmutableList params; 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.launchviewModelScope / injected scope with structured concurrency.
  • LiveData / observeAsState for new UI → StateFlow + collectAsStateWithLifecycle.
  • collectAsState() (not lifecycle-aware; keeps collecting in background) → collectAsStateWithLifecycle().
  • Raw List/Set/Map params, and data class fields that are mutableImmutableList/persistentListOf; treat state as immutable and copy().
  • Multiple mutableStateOf in a ViewModel as ad-hoc state → one UiState data class.
  • String-route navigation and passing whole models through nav → typed NavKey/@Serializable routes carrying IDs.
  • Material 2 (androidx.compose.material) in a new app, hardcoded colors/dimensions → Material 3 tokens.
  • kapt for annotation processing → KSP. XML layouts / findViewById / AsyncTask / Gson for 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, the ViewModel (StateFlow + events), the stateless composable, a preview (@Preview with 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/targetSdk or 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.

Back to top ↑