Kotlin patterns
Skill piyushverma0/android-agent-skills/skills/kotlin-patterns
27 Android skills for AI agents (Claude Code, Codex, Cursor). Fixes Supabase auth, Hilt errors, design inconsistency, kapt→ksp, missing UiState states. Reduced my token bills 5×. FitGenZ AI shipped in 18 days.
npx -y skills add piyushverma0/android-agent-skills --skill kotlin-patternsAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 14 stars14 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Idiomatic Kotlin patterns for Android AI agents. Use this skill whenever writing Kotlin code for Android, especially: coroutines, Flow, StateFlow, SharedFlow, viewModelScope, lifecycleScope, Dispatchers, withContext, suspend functions, sealed classes, sealed interfaces, data classes, extension functions, scope functions (let, apply, also, run, with), null safety, !! operator, runCatching, Result, lazy, by delegate, stateIn, flatMapLatest, combine, error handling, structured concurrency, or any Kotlin-specific patterns. Always apply before writing coroutines, Flow chains, or data modeling code.
SKILL.md
10.2 KB, as published. Nobody here has run it
Kotlin Patterns for Android
12 rules for idiomatic, production-safe Kotlin on Android.
Rule 1: Coroutine scope — always use structured concurrency
// ✅ viewModelScope — auto-cancelled when ViewModel is cleared
class MyViewModel : ViewModel() {
fun load() {
viewModelScope.launch {
val result = fetchData() // suspend, cancellable
}
}
}
// ✅ lifecycleScope — tied to Activity/Fragment lifecycle
class MyActivity : ComponentActivity() {
override fun onStart() {
super.onStart()
lifecycleScope.launch {
viewModel.events.collect { handleEvent(it) }
}
}
}
// ❌ GlobalScope — not structured, leaks, not cancellable
GlobalScope.launch { fetchData() }
// ❌ CoroutineScope(Dispatchers.IO) without proper lifecycle binding
val scope = CoroutineScope(Dispatchers.IO)
scope.launch { fetchData() } // never cancelled
Rule 2: Dispatcher discipline — always switch off Main
// ✅ IO-bound work: switch to IO dispatcher
suspend fun fetchUser(id: String): User = withContext(Dispatchers.IO) {
api.getUser(id)
}
// ✅ CPU-intensive work: use Default dispatcher
suspend fun processLargeList(items: List<Item>): List<Result> = withContext(Dispatchers.Default) {
items.map { processItem(it) }
}
// ✅ Inject dispatcher for testability
class UserRepository @Inject constructor(
private val api: UserApi,
@IoDispatcher private val dispatcher: CoroutineDispatcher
) {
suspend fun getUser(id: String): User = withContext(dispatcher) {
api.getUser(id)
}
}
// ❌ Network call on Main thread — crashes with NetworkOnMainThreadException
suspend fun fetchUser(): User = api.getUser() // on Main, wrong
Rule 3: StateFlow — expose, never expose MutableStateFlow
// ✅ Mutable private, immutable public
private val _uiState = MutableStateFlow(HomeUiState.Loading)
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
// ✅ Update state correctly
_uiState.value = HomeUiState.Success(items) // from coroutine on Main
_uiState.update { current -> current.copy(isLoading = false) } // thread-safe update
// ❌ Exposing MutableStateFlow — external code can change state
val uiState = MutableStateFlow(HomeUiState.Loading) // anyone can set this
Rule 4: stateIn — convert cold Flow to StateFlow
// ✅ stateIn with correct parameters
val items: StateFlow<List<Item>> = repository.getItemsFlow()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000), // 5s timeout before cancelling
initialValue = emptyList()
)
// ✅ WhileSubscribed(5000) — keeps upstream alive 5s after last subscriber
// This handles configuration changes without re-fetching
Rule 5: runCatching — safe error handling without try/catch
// ✅ runCatching wraps any exception in Result<T>
suspend fun getItems(): Result<List<Item>> = runCatching {
api.getItems().map { it.toDomain() }
}
// ✅ Chain Result transformations
suspend fun getActiveItems(): Result<List<Item>> =
getItems()
.map { items -> items.filter { it.isActive } }
.onFailure { error -> logger.e(error, "Failed to get items") }
// ✅ In ViewModel
viewModelScope.launch {
getItems()
.onSuccess { items -> _uiState.value = HomeUiState.Success(items) }
.onFailure { error -> _uiState.value = HomeUiState.Error(error.message ?: "Error") }
}
// ❌ Try/catch scattered through ViewModel — inconsistent, hard to compose
try {
val items = api.getItems()
_uiState.value = HomeUiState.Success(items)
} catch (e: Exception) {
_uiState.value = HomeUiState.Error(e.message ?: "Error")
}
Rule 6: Sealed interfaces — prefer over sealed classes for state/events
// ✅ sealed interface — no constructor, lighter, more composable
sealed interface LoginResult {
data object Success : LoginResult
data class Error(val code: Int, val message: String) : LoginResult
data object NetworkError : LoginResult
}
// ✅ sealed interface allows a class to implement multiple sealed hierarchies
class AuthError : LoginResult.Error(401, "Unauthorized"), ProfileResult.Unauthorized
// ❌ sealed class — requires a superclass constructor
sealed class LoginResult {
object Success : LoginResult()
data class Error(val message: String) : LoginResult()
}
Rule 7: Scope functions — use the right one
// ✅ let — transform nullable value, chain operations
val length = name?.let { it.trim().length } ?: 0
// ✅ apply — configure an object, return the object
val intent = Intent(context, MainActivity::class.java).apply {
putExtra("id", itemId)
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
// ✅ also — side effects, return the same object
val items = repository.getItems().also { logger.d("Loaded ${it.size} items") }
// ✅ run — execute block, return result (combine let + with)
val message = user.run {
if (isPremium) "Welcome, Premium $name!" else "Welcome, $name!"
}
// ✅ with — call multiple methods on an object, return result
val summary = with(order) {
"Order #$id: $itemCount items, total: $$total"
}
// ❌ Nested let/run — unreadable, use named functions instead
val result = a?.let { b?.let { c?.run { ... } } } // pyramid of doom
Rule 8: No !! operator in production code
// ✅ Safe alternatives to !!
val name = user?.name ?: "Anonymous" // Elvis operator
val id = savedStateHandle.get<String>("id") ?: return // early return
val file = getFile() ?: throw IllegalStateException("File required") // explicit throw
// ✅ requireNotNull with message
val config = requireNotNull(buildConfig) { "BuildConfig must be initialized before use" }
// ❌ !! crashes with NullPointerException — never use in production
val name = user!!.name // NPE if user is null
val id = args!!.getString("id") // NPE in production
Rule 9: flatMapLatest — cancel previous on new emission
// ✅ flatMapLatest cancels in-flight request when new search query arrives
val searchResults: StateFlow<List<Item>> = searchQuery
.debounce(300L)
.flatMapLatest { query ->
if (query.isBlank()) flowOf(emptyList())
else repository.search(query)
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
// ❌ flatMapMerge — all emissions run concurrently, results arrive out of order
val searchResults = searchQuery.flatMapMerge { repository.search(it) }
Rule 10: Data classes — correct usage
// ✅ data class for value objects with meaningful equality
data class Item(
val id: String,
val title: String,
val description: String,
val createdAt: Instant
)
// ✅ copy() for immutable updates
val updated = item.copy(title = "New Title", description = "Updated")
// ✅ data object for singletons in sealed hierarchies
sealed interface AuthState {
data object Unauthenticated : AuthState
data object Loading : AuthState
data class Authenticated(val user: User) : AuthState
}
// ❌ Regular class for domain models — loses equals/hashCode/copy
class Item(val id: String, val title: String) // no equals, no copy
// ❌ Mutable data class — breaks StateFlow comparison, causes missed updates
data class UiState(var isLoading: Boolean = false) // var in data class
Rule 11: Lazy initialization
// ✅ by lazy — compute once, reuse, thread-safe by default
val regex: Regex by lazy { Regex("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$") }
// ✅ lateinit var — for dependency injection and test setup
@Inject lateinit var repository: ItemRepository
// ✅ lateinit with isInitialized check when needed
if (::repository.isInitialized) repository.close()
// ❌ lazy without thread-safety consideration in concurrent contexts
val cache by lazy(LazyThreadSafetyMode.NONE) { mutableMapOf<String, Item>() }
// LazyThreadSafetyMode.NONE is only safe if accessed from single thread
Rule 12: Extension functions — don't abuse them
// ✅ Extension for adding behavior to existing types
fun String.isValidEmail(): Boolean =
android.util.Patterns.EMAIL_ADDRESS.matcher(this).matches()
fun Context.showToast(message: String, duration: Int = Toast.LENGTH_SHORT) {
Toast.makeText(this, message, duration).show()
}
fun <T> Flow<T>.throttleFirst(windowDuration: Long): Flow<T> = flow {
var lastEmission = 0L
collect { value ->
val now = System.currentTimeMillis()
if (now - lastEmission >= windowDuration) {
lastEmission = now
emit(value)
}
}
}
// ❌ Extension that should be a UseCase — logic belongs in domain
fun List<Item>.filterAndSort(): List<Item> {
// business logic in extension = untestable, not reusable across modules
return filter { it.isActive }.sortedBy { it.title }
}
Common Mistakes Quick Reference
| ❌ Wrong | ✅ Right |
|---|---|
GlobalScope.launch | viewModelScope.launch |
user!!.name | user?.name ?: "default" |
collectAsState() | collectAsStateWithLifecycle() |
MutableStateFlow exposed | _private.asStateFlow() |
| Try/catch in ViewModel | runCatching in Repository |
var in data class | val — immutable |
flatMapMerge for search | flatMapLatest |
| Computation on Main | withContext(Dispatchers.IO) |