| name | lang-kotlin-code-writing |
| description | Use when writing, reviewing, or refactoring Kotlin code (*.kt) for Android App development (IVI, HUD, RSE on Android Automotive / AOSP). Covers Kotlin idioms, null safety, coroutines, Flow, MVVM/MVI architecture, Jetpack Compose, immutability, extension functions, design patterns, KDoc documentation, and a full pre-commit review checklist. Focused on the App layer — not Android Framework / System Services.
|
| argument-hint | <class-or-module-name> [write|review|refactor] |
Kotlin Code Writing — Android App
Expert-level best practices for writing production-quality Kotlin Android Apps
on Android Automotive / AOSP platforms, guided by Kotlin's official style guide,
Android Architecture Guide, and Clean Architecture principles.
Standards baseline: Kotlin 1.9 (default) · Kotlin 1.5 min · Coroutines, Flow, Jetpack.
When to Use This Skill
- Writing a new Kotlin class, ViewModel, Repository, or composable from scratch.
- Reviewing a Gerrit / GitHub PR that touches
.kt files in an Android App module.
- Refactoring Java-style Kotlin to idiomatic Kotlin.
- Designing coroutine / Flow pipelines, MVVM state management, or Compose UI.
- Generating KDoc class / function documentation.
Prerequisites
- Kotlin version confirmed: 1.9 (default), 1.5 min.
- Android project confirmed (Gradle with
kotlin-android plugin).
- Coroutines + Flow available (
org.jetbrains.kotlinx:kotlinx-coroutines-android).
- Jetpack (ViewModel, LiveData or StateFlow, Navigation, Compose) available.
- Android Framework / System Services are out of scope — use
java-code-writing for those.
1. File & Package Organization
Goal: one concept per file, clear module boundaries, zero circular dependencies.
- File name = top-level class / object name. For top-level functions and extensions, use a descriptive name:
SensorExtensions.kt, DateFormatters.kt.
- Package names: all lowercase, reverse-domain —
com.company.feature.ui.
- One top-level
class / object / interface per file. Multiple closely related small classes or top-level functions in the same file is acceptable — use judgement.
- Group imports: Android / Jetpack → third-party →
kotlin.* / java.*. Remove unused imports.
- Keep files focused: aim for < 300 lines. Extract delegates, extensions, or helper objects when a file grows beyond that.
- Put extension functions for a type in a dedicated file:
ContextExtensions.kt, never scattered across unrelated files.
package com.automotive.sensor.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import java.util.Locale
2. Kotlin Null Safety
Kotlin's type system eliminates null at compile time — use it fully. !! is a code smell.
- Prefer non-nullable types as the default. Add
? only when null is a genuine valid state for the domain.
- Never use
!! (null-assertion) in production code — it converts a compile-time guarantee into a runtime crash. Use safe-call ?., Elvis ?:, requireNotNull, or checkNotNull instead.
- Use the Elvis operator
?: to provide a default or throw with context:
val name = input ?: throw IllegalArgumentException("name must not be null").
let, run, also, apply scope functions for null-safe access chains — choose the right one:
| Scope fn | Receiver | Returns | Best for |
|---|
let | it | lambda result | Null check + transform: value?.let { transform(it) } |
run | this | lambda result | Object config + compute result |
apply | this | receiver | Object initialisation / builder-style config |
also | it | receiver | Side effects (logging) without breaking the chain |
with | this | lambda result | Multiple calls on the same non-null object |
- Use
lateinit var only for DI-injected fields guaranteed non-null after onCreate / inject. Always prefer val + initialiser.
- Use
by lazy { } for expensive one-time initialisations — thread-safe by default.
val tempLabel = sensor.readDegC()?.let { "%.1f °C".format(it) } ?: "N/A"
val tempLabel = sensor.readDegC()!!.toString()
3. Immutability & Data Modelling
Immutable state is easier to reason about, test, and share across threads.
- Prefer
val over var everywhere. Use var only when mutation is genuinely required (e.g., loop accumulator, mutable ViewModel state backing property).
- Use
data class for value objects (UI state, domain models, DTOs). They give equals, hashCode, toString, and copy for free.
- Use
sealed class / sealed interface to model exhaustive domain states — the compiler enforces handling every branch in when.
- Use
object for singletons and companion-object factories; not for stateful global mutable state.
- Use
enum class for a fixed set of named constants with no additional state. Prefer sealed class when variants carry data.
- Use
value class (inline class) to wrap primitives with domain semantics at zero runtime cost.
- Expose state as
val with a private var backing property (ViewModels):
sealed interface SensorUiState {
object Loading : SensorUiState
data class Success(val tempDegC: Float) : SensorUiState
data class Error(val message: String) : SensorUiState
}
private val _uiState = MutableStateFlow<SensorUiState>(SensorUiState.Loading)
val uiState: StateFlow<SensorUiState> = _uiState.asStateFlow()
4. Functions & Kotlin Idioms
Prefer expressive, concise Kotlin over verbose Java-style patterns.
- Single-expression functions — use
= body for functions that return a single expression.
- Named arguments — use when a function has ≥ 2 parameters of the same type, or when meaning is not obvious from position.
- Default parameters — eliminate overload proliferation. One function with defaults beats 3 overloads.
- Destructuring —
val (x, y) = point for data class; for ((key, value) in map).
when expressions — prefer over if-else if chains; use as expression (returns a value). Always add else unless when is over a sealed type (exhaustive).
- Trailing lambdas — move the last lambda argument outside parentheses:
list.filter { it > 0 }.
apply / also for fluent object construction — replaces builder boilerplate for simple cases.
require / check / error for precondition validation — throw IllegalArgumentException / IllegalStateException with a message.
@JvmStatic / @JvmOverloads only when Java interop requires it — do not add them blindly.
typealias to give meaningful names to function types or complex generics.
fun Float.toTempLabel(): String = "%.1f °C".format(this)
fun schedulePoll(channelId: Int, intervalMs: Long = 1_000L, retries: Int = 3) { … }
schedulePoll(channelId = 2, retries = 5)
val message = when (val state = uiState.value) {
is SensorUiState.Loading -> "Loading…"
is SensorUiState.Success -> state.tempDegC.toTempLabel()
is SensorUiState.Error -> "Error: ${state.message}"
}
fun readChannel(channel: Int): Float {
require(channel in 0..7) { "channel must be 0–7, got $channel" }
check(mIsInitialised) { "Sensor not initialised" }
return mDriver.read(channel)
}
5. Coroutines & Structured Concurrency
Coroutines replace threads and callbacks. Structured concurrency prevents leaks.
- Never use
GlobalScope — always use a structured scope (viewModelScope, lifecycleScope, or a custom CoroutineScope tied to a component lifecycle).
- Use the correct dispatcher:
| Dispatcher | Use |
|---|
Dispatchers.Main | UI updates, LiveData / StateFlow emissions |
Dispatchers.IO | File I/O, network, database (suspends, many threads) |
Dispatchers.Default | CPU-intensive work (parsing, sorting, crypto) |
Dispatchers.Main.immediate | Already on main thread; skip re-dispatch overhead |
- Inject dispatchers — never hardcode
Dispatchers.IO in production classes. Pass a CoroutineDispatcher parameter defaulting to Dispatchers.IO so tests can inject StandardTestDispatcher.
suspend functions should be main-safe: switch to the right dispatcher internally with withContext. Callers should not need to worry about which thread to call from.
- Use
supervisorScope when child coroutine failures should not cancel siblings.
- Handle exceptions:
CoroutineExceptionHandler for top-level launch; try/catch inside async / suspend functions.
- Cancel gracefully: always check
isActive in long loops; prefer ensureActive().
class SensorRepository(
private val driver: IAdcDriver,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) {
suspend fun readDegC(channel: Int): Result<Float> = withContext(ioDispatcher) {
runCatching { driver.readChannel(channel) }
}
}
@HiltViewModel
class SensorViewModel @Inject constructor(
private val repo: SensorRepository
) : ViewModel() {
private val _uiState = MutableStateFlow<SensorUiState>(SensorUiState.Loading)
val uiState: StateFlow<SensorUiState> = _uiState.asStateFlow()
fun load(channel: Int) {
viewModelScope.launch {
_uiState.value = SensorUiState.Loading
repo.readDegC(channel)
.onSuccess { _uiState.value = SensorUiState.Success(it) }
.onFailure { _uiState.value = SensorUiState.Error(it.message ?: "Unknown") }
}
}
}
6. Flow
Flow replaces LiveData for reactive streams in Kotlin-first codebases.
- Prefer
StateFlow for UI state (single current value, always has value, hot).
- Prefer
SharedFlow for events that should not be replayed (navigation, one-shot toasts).
- Use
callbackFlow or flow { } to wrap callback-based APIs as cold flows.
- Never collect a Flow inside another
flow { } builder — compose with operators (map, flatMapLatest, combine, zip).
- Collect in the UI layer with
repeatOnLifecycle(Lifecycle.State.STARTED) — not lifecycleScope.launch directly (which keeps collecting in the background).
- Use
stateIn(scope, SharingStarted.WhileSubscribed(5_000), initial) to convert cold flows to hot StateFlow in ViewModel.
- Use
flowOn(dispatcher) at the source to switch context; do not use withContext inside a flow { } builder.
- Backpressure: use
buffer(), conflate(), or collectLatest to handle fast producers.
fun temperatureStream(channel: Int): Flow<Float> = flow {
while (true) {
emit(driver.readChannel(channel))
delay(1_000L)
}
}.flowOn(ioDispatcher)
val temperature: StateFlow<Float> = repo.temperatureStream(channel = 3)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), initialValue = 0f)
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.temperature.collect { temp -> updateUi(temp) }
}
}
7. MVVM Architecture & Clean Layers
Separate concerns strictly — each layer has one job and one direction of dependency.
UI (Compose / View)
↓ observes
ViewModel (state holder; survives config changes)
↓ calls
Repository (single source of truth; abstracts data sources)
↓ uses
DataSource / DAO / Remote API / HAL
Rules:
- UI knows only ViewModel. Never calls Repository or DataSource directly.
- ViewModel knows only Repository interfaces. Never imports
android.view.* or android.widget.*.
- Repository owns data-source selection logic (cache vs. network vs. HAL). Exposes
suspend functions or Flow.
- DataSource wraps a single origin (Room DAO, Retrofit service, Binder HAL call). No business logic.
- Domain models (data classes) live in a shared
domain package — no Android imports.
- Use
Result<T> or a sealed DomainResult type to propagate success/error across layer boundaries — not raw exceptions.
- All Repository / DataSource methods are
suspend or return Flow — never blocking.
interface SensorRepository {
suspend fun readDegC(channel: Int): Result<Float>
fun temperatureStream(channel: Int): Flow<Float>
}
@HiltViewModel
class DashboardViewModel @Inject constructor(
private val sensorRepo: SensorRepository
) : ViewModel() { … }
8. Dependency Injection (Hilt)
DI removes manual wiring, enables testing, and enforces layer boundaries.
- Annotate every ViewModel with
@HiltViewModel + @Inject constructor(...).
- Provide dependencies in
@Module + @InstallIn classes, not in Application.onCreate.
- Bind interfaces to implementations with
@Binds in abstract modules — not @Provides (cleaner, no unnecessary instantiation).
- Use scopes correctly:
@Singleton (app lifetime), @ViewModelScoped (ViewModel lifetime), @ActivityScoped (Activity lifetime). Never use @Singleton for something that holds UI state.
- Do not pass
Context down to Repository or lower — inject @ApplicationContext Context at the module level.
- For coroutine dispatchers: define a
@Qualifier (@IoDispatcher, @DefaultDispatcher) and bind in a module. Inject the qualifier, not the raw Dispatchers object.
@Module
@InstallIn(SingletonComponent::class)
abstract class SensorModule {
@Binds
@Singleton
abstract fun bindSensorRepository(
impl: SensorRepositoryImpl
): SensorRepository
}
@Qualifier @Retention(AnnotationRetention.BINARY)
annotation class IoDispatcher
@Module @InstallIn(SingletonComponent::class)
object DispatchersModule {
@Provides @IoDispatcher
fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
}
9. Jetpack Compose
Compose replaces XML layouts. Composables are pure functions of state.
- Composables are stateless by default — state lives in ViewModel, not inside composables (except for ephemeral UI state like
TextField text before submission).
- Hoist state up to the lowest common ancestor that needs it. Pass state and callbacks (lambdas) down — never pass ViewModel references into deep composables.
- Annotate composables with
@Composable. Preview with @Preview.
- Name composables as nouns or noun-phrases in PascalCase:
TemperatureCard, SensorDashboard. Not verbs.
- Side effects only inside
LaunchedEffect, DisposableEffect, SideEffect — never directly in the composable body.
- Use
remember to survive recompositions; rememberSaveable to survive process death.
- Avoid unnecessary recompositions: pass only the data a composable needs, not the entire state object. Use
derivedStateOf for computed values.
- Extract reusable composables into their own files. Keep each composable ≤ 50 lines; extract sub-composables when it grows.
@Composable
fun TemperatureCard(
tempDegC: Float,
onRefreshClick: () -> Unit,
modifier: Modifier = Modifier
) {
Card(modifier = modifier) {
Column(Modifier.padding(16.dp)) {
Text(text = "%.1f °C".format(tempDegC), style = MaterialTheme.typography.headlineMedium)
Button(onClick = onRefreshClick) { Text("Refresh") }
}
}
}
@Composable
fun SensorScreen(viewModel: SensorViewModel = hiltViewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
when (state) {
is SensorUiState.Loading -> CircularProgressIndicator()
is SensorUiState.Success -> TemperatureCard(
tempDegC = (state as SensorUiState.Success).tempDegC,
onRefreshClick = viewModel::refresh
)
is SensorUiState.Error -> ErrorView(message = (state as SensorUiState.Error).message)
}
}
10. Documentation (KDoc)
If the intent is not obvious from the code alone, document it before writing the code.
- Every public class / interface needs a KDoc summary block (
/** … */).
- Every public function: summary line,
@param for non-obvious parameters, @return, @throws for exceptions that can propagate.
- Extension functions: document what type they extend and what they add — the receiver type is often not obvious from a call site.
- Coroutines: note
@Throws if the suspend function can throw; note which dispatcher it expects to be called from if it is not main-safe.
- Use
[ClassName] / [ClassName.functionName] for cross-references.
suspend fun readDegC(channel: Int): Result<Float>
Step-by-Step Workflows
Step 1: Set up the file structure
Use the correct package; prefer one top-level class per file (except for extension functions).
Step 2: Design with Kotlin idioms
Prefer data class for state; use sealed class for result types; eliminate the !! operator.
Step 3: Apply the patterns from this skill
Follow sections 1–10 below in order: null safety → coroutines → MVVM → Compose, etc.
Step 4: Run Detekt and Android Lint
Fix all errors; add documented @Suppress annotations only when strictly necessary.
Step 5: Write unit tests
Use MockK + runTest {} for coroutine tests; Turbine for Flow/StateFlow assertions.
Pre-Commit Review Checklist
Before pushing to Gerrit / GitHub, verify:
Package & file
Null safety
Immutability & modelling
Coroutines & Flow
Architecture
Hilt / DI
Compose (if applicable)
Documentation
Examples
See companion files:
Troubleshooting
| Symptom | Likely cause | Fix |
|---|
NullPointerException from Kotlin code | !! or Java interop returning null | Replace !! with ?./?:, annotate Java API with @Nullable |
| Flow stops updating after screen rotation | Collecting outside repeatOnLifecycle | Wrap collection in repeatOnLifecycle(STARTED) |
| ViewModel leaks / coroutine keeps running | GlobalScope.launch or wrong scope | Use viewModelScope; cancel scope when done |
| Recomposition storm in Compose | Passing unstable objects / entire state to leaf composable | Pass only needed fields; use @Stable / @Immutable; derivedStateOf |
Hilt @Inject not found | Missing @HiltViewModel or component annotation | Add @HiltViewModel; ensure module is @InstallIn correct component |
| Coroutine exception silently swallowed | async exception not awaited | await() the Deferred, or add CoroutineExceptionHandler |
References