android-kotlin
Use this skill when building Android applications with Kotlin. Triggers on Jetpack Compose UI, Room database, Kotlin coroutines, Play Store publishing, MVVM/MVI architecture, ViewModel, StateFlow, Hilt dependency injection, Navigation Compose, Material 3, APK/AAB builds, ProGuard, and Android app lifecycle management. Covers modern Android development with declarative UI, reactive state, structured concurrency, and production release workflows.
engineering androidkotlinjetpack-composeroomcoroutinesplay-storeWhat is android-kotlin?
Use this skill when building Android applications with Kotlin. Triggers on Jetpack Compose UI, Room database, Kotlin coroutines, Play Store publishing, MVVM/MVI architecture, ViewModel, StateFlow, Hilt dependency injection, Navigation Compose, Material 3, APK/AAB builds, ProGuard, and Android app lifecycle management. Covers modern Android development with declarative UI, reactive state, structured concurrency, and production release workflows.
android-kotlin
android-kotlin is a production-ready AI agent skill for claude-code, gemini-cli, openai-codex, and 1 more. Building Android applications with Kotlin.
Quick Facts
| Field | Value |
|---|---|
| Category | engineering |
| Version | 0.1.0 |
| Platforms | claude-code, gemini-cli, openai-codex, mcp |
| License | MIT |
How to Install
- Make sure you have Node.js installed on your machine.
- Run the following command in your terminal:
npx skills add AbsolutelySkilled/AbsolutelySkilled --skill android-kotlin- The android-kotlin skill is now available in your AI coding agent (Claude Code, Gemini CLI, OpenAI Codex, etc.).
Overview
Modern Android development uses Kotlin as the primary language with Jetpack Compose for declarative UI, Room for local persistence, coroutines for structured concurrency, and a layered architecture (MVVM or MVI) to separate concerns. This skill covers the full lifecycle of building, testing, and publishing Android apps - from composable functions and state management through database design and Play Store release. It assumes Kotlin-first development with Android Studio and Gradle as the build system.
Tags
android kotlin jetpack-compose room coroutines play-store
Platforms
- claude-code
- gemini-cli
- openai-codex
- mcp
Related Skills
Pair android-kotlin with these complementary skills:
Frequently Asked Questions
What is android-kotlin?
Use this skill when building Android applications with Kotlin. Triggers on Jetpack Compose UI, Room database, Kotlin coroutines, Play Store publishing, MVVM/MVI architecture, ViewModel, StateFlow, Hilt dependency injection, Navigation Compose, Material 3, APK/AAB builds, ProGuard, and Android app lifecycle management. Covers modern Android development with declarative UI, reactive state, structured concurrency, and production release workflows.
How do I install android-kotlin?
Run npx skills add AbsolutelySkilled/AbsolutelySkilled --skill android-kotlin in your terminal. The skill will be immediately available in your AI coding agent.
What AI agents support android-kotlin?
This skill works with claude-code, gemini-cli, openai-codex, mcp. Install it once and use it across any supported AI coding agent.
Maintainers
Generated from AbsolutelySkilled
SKILL.md
Android Kotlin
Modern Android development uses Kotlin as the primary language with Jetpack Compose for declarative UI, Room for local persistence, coroutines for structured concurrency, and a layered architecture (MVVM or MVI) to separate concerns. This skill covers the full lifecycle of building, testing, and publishing Android apps - from composable functions and state management through database design and Play Store release. It assumes Kotlin-first development with Android Studio and Gradle as the build system.
When to use this skill
Trigger this skill when the user:
- Wants to build or modify a Jetpack Compose UI (screens, components, themes)
- Needs to set up Room database with entities, DAOs, and migrations
- Asks about Kotlin coroutines, Flows, or StateFlow for async work
- Wants to structure an Android project with MVVM or MVI architecture
- Needs to publish an app to Google Play Store (AAB, signing, release tracks)
- Asks about ViewModel, Hilt/Dagger dependency injection, or Navigation Compose
- Wants to handle Android lifecycle (Activity, Fragment, process death)
- Needs to optimize app performance (startup time, memory, ProGuard/R8)
Do NOT trigger this skill for:
- Cross-platform frameworks (Flutter, React Native, KMP shared logic) - use their dedicated skills
- Backend Kotlin development (Ktor, Spring Boot) without Android UI concerns
Setup & authentication
Environment
# Required: Android Studio (latest stable) with SDK 34+
# Required: JDK 17 (bundled with Android Studio)
# Required: Gradle 8.x (via wrapper)
# Key SDK environment variables
export ANDROID_HOME=$HOME/Android/Sdk # Linux
export ANDROID_HOME=$HOME/Library/Android/sdk # macOSProject-level build.gradle.kts (Kotlin DSL)
plugins {
id("com.android.application") version "8.7.0" apply false
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.1.0" apply false
id("com.google.dagger.hilt.android") version "2.51.1" apply false
id("com.google.devtools.ksp") version "2.1.0-1.0.29" apply false
}App-level build.gradle.kts essentials
android {
namespace = "com.example.app"
compileSdk = 35
defaultConfig {
minSdk = 26
targetSdk = 35
}
buildFeatures { compose = true }
}
dependencies {
// Compose BOM - single version for all Compose libs
val composeBom = platform("androidx.compose:compose-bom:2024.12.01")
implementation(composeBom)
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.ui:ui-tooling-preview")
debugImplementation("androidx.compose.ui:ui-tooling")
// Architecture
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
implementation("androidx.navigation:navigation-compose:2.8.5")
// Room
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
// Hilt
implementation("com.google.dagger:hilt-android:2.51.1")
ksp("com.google.dagger:hilt-android-compiler:2.51.1")
implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
}Core concepts
Jetpack Compose replaces XML layouts with composable functions. UI is a
function of state: when state changes, Compose recomposes only the affected
parts of the tree. Key primitives are @Composable functions, remember,
mutableStateOf, and LaunchedEffect for side effects. Material 3 provides
the design system (colors, typography, shapes).
Room is the persistence layer built on SQLite. Define @Entity classes for
tables, @Dao interfaces for queries, and a @Database abstract class to tie
them together. Room validates SQL at compile time and returns Flow<T> for
reactive queries. Always define migrations for schema changes in production.
Coroutines and Flow provide structured concurrency. Use viewModelScope
for ViewModel-scoped work, Dispatchers.IO for blocking I/O, and StateFlow
to expose reactive state to the UI. Never launch coroutines from composables
directly - use LaunchedEffect or collect flows with collectAsStateWithLifecycle().
Architecture (MVVM) separates UI (Compose), state holder (ViewModel), and
data (Repository/Room). The ViewModel exposes StateFlow<UiState> and the
composable collects it. User events flow up as lambdas, state flows down as
data. This unidirectional data flow makes state predictable and testable.
Common tasks
Build a Compose screen with state
data class TaskListUiState(
val tasks: List<Task> = emptyList(),
val isLoading: Boolean = false,
)
@HiltViewModel
class TaskListViewModel @Inject constructor(
private val repository: TaskRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(TaskListUiState())
val uiState: StateFlow<TaskListUiState> = _uiState.asStateFlow()
init {
viewModelScope.launch {
repository.getTasks().collect { tasks ->
_uiState.update { it.copy(tasks = tasks, isLoading = false) }
}
}
}
fun addTask(title: String) {
viewModelScope.launch {
repository.insert(Task(title = title))
}
}
}
@Composable
fun TaskListScreen(viewModel: TaskListViewModel = hiltViewModel()) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
LazyColumn {
items(uiState.tasks, key = { it.id }) { task ->
Text(text = task.title, modifier = Modifier.padding(16.dp))
}
}
}Always use
collectAsStateWithLifecycle()instead ofcollectAsState()- it respects the lifecycle and stops collection when the UI is not visible.
Set up Room database
@Entity(tableName = "tasks")
data class Task(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val title: String,
val isCompleted: Boolean = false,
val createdAt: Long = System.currentTimeMillis(),
)
@Dao
interface TaskDao {
@Query("SELECT * FROM tasks ORDER BY createdAt DESC")
fun getAll(): Flow<List<Task>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(task: Task)
@Delete
suspend fun delete(task: Task)
}
@Database(entities = [Task::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun taskDao(): TaskDao
}Mark DAO query methods returning
Flowas non-suspend. Mark write operations (@Insert,@Update,@Delete) assuspend.
Set up Hilt dependency injection
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): AppDatabase =
Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
.addMigrations(MIGRATION_1_2)
.build()
@Provides
fun provideTaskDao(db: AppDatabase): TaskDao = db.taskDao()
}
@Module
@InstallIn(SingletonComponent::class)
object RepositoryModule {
@Provides
@Singleton
fun provideTaskRepository(dao: TaskDao): TaskRepository =
TaskRepositoryImpl(dao)
}Annotate the Application class with
@HiltAndroidAppand each Activity with@AndroidEntryPoint.
Set up Navigation Compose
@Composable
fun AppNavHost(navController: NavHostController = rememberNavController()) {
NavHost(navController = navController, startDestination = "tasks") {
composable("tasks") {
TaskListScreen(onTaskClick = { id ->
navController.navigate("tasks/$id")
})
}
composable(
"tasks/{taskId}",
arguments = listOf(navArgument("taskId") { type = NavType.LongType })
) {
TaskDetailScreen()
}
}
}Use type-safe navigation with route objects (available in Navigation 2.8+) for compile-time route safety instead of raw strings.
Handle Room migrations
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE tasks ADD COLUMN priority INTEGER NOT NULL DEFAULT 0")
}
}
// In database builder:
Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
.addMigrations(MIGRATION_1_2)
.build()Always write migrations for production apps.
fallbackToDestructiveMigration()deletes all user data and should only be used during development.
Publish to Google Play Store
- Generate a signed AAB (Android App Bundle):
./gradlew bundleRelease - Configure signing in
build.gradle.kts:android { signingConfigs { create("release") { storeFile = file("keystore.jks") storePassword = System.getenv("KEYSTORE_PASSWORD") keyAlias = System.getenv("KEY_ALIAS") keyPassword = System.getenv("KEY_PASSWORD") } } buildTypes { release { signingConfig = signingConfigs.getByName("release") isMinifyEnabled = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) } } } - Upload to Play Console via internal/closed/open testing tracks before production.
- Ensure
versionCodeincrements with every upload andversionNamefollows semver.
Enable R8 minification (
isMinifyEnabled = true) for release builds. Add ProGuard keep rules for any reflection-based libraries (Gson, Retrofit).
Error handling
| Error | Cause | Resolution |
|---|---|---|
IllegalStateException: Room cannot verify the data integrity |
Database schema changed without migration | Write a Migration(oldVersion, newVersion) or use fallbackToDestructiveMigration() during development |
NetworkOnMainThreadException |
Blocking network call on main thread | Move network calls to Dispatchers.IO using withContext(Dispatchers.IO) { ... } |
ViewModelStore recomposition crash |
Creating ViewModel inside a composable without hiltViewModel() or viewModel() |
Always use hiltViewModel() or viewModel() factory functions, never manual instantiation |
Compose recomposition loop |
Modifying state during composition (e.g. calling a setter in the composable body) | Use LaunchedEffect or SideEffect for state changes. Never mutate state directly in composition |
ProGuard strips required class |
R8 removes class used via reflection | Add -keep rule in proguard-rules.pro for the affected class |
Gotchas
collectAsState()vscollectAsStateWithLifecycle()-collectAsState()continues collecting flow emissions even when the app is in the background, wasting battery and potentially causing crashes. Always usecollectAsStateWithLifecycle()fromlifecycle-runtime-composewhich automatically pauses collection when the lifecycle is not at leastSTARTED.Room migrations are required in production - Changing any
@Entityclass without a correspondingMigrationobject will crash the app on launch withIllegalStateException.fallbackToDestructiveMigration()deletes all user data silently; never use it in a published app. Write migrations for every schema change before release.Process death drops ViewModel state -
ViewModelsurvives configuration changes (rotation) but NOT process death. If the OS kills the app,StateFlowstate is lost. For state that must survive process death, useSavedStateHandlein the ViewModel constructor.Composable recompositions on every state change - Lambdas and objects created inside composables are recreated on every recomposition, causing excessive child recompositions. Wrap event handlers in
remember { }or define them in the ViewModel. Unstable function parameters also break Compose's skipping optimization.versionCodemust increment for every Play Store upload - Uploading an AAB with the same or lowerversionCodethan an existing track will be rejected by the Play Console. AutomateversionCodeincrementing in CI; never rely on manual updates.
References
For detailed content on specific topics, read the relevant file from references/:
references/compose-patterns.md- Compose state management, recomposition optimization, theming, custom layoutsreferences/room-advanced.md- Complex queries, type converters, relations, testing, FTSreferences/coroutines-flows.md- Structured concurrency, Flow operators, error handling, testing coroutinesreferences/play-store-checklist.md- Complete release checklist, store listing, review guidelines, staged rollouts
Only load a references file if the current task requires deep detail on that topic.
References
compose-patterns.md
Compose Patterns
State management patterns
State hoisting
Move state up to the caller so composables remain stateless and reusable.
// Stateless composable - receives state and events
@Composable
fun SearchBar(
query: String,
onQueryChange: (String) -> Unit,
modifier: Modifier = Modifier,
) {
TextField(
value = query,
onValueChange = onQueryChange,
modifier = modifier,
placeholder = { Text("Search...") },
)
}
// Stateful wrapper - owns the state
@Composable
fun SearchBarStateful() {
var query by remember { mutableStateOf("") }
SearchBar(query = query, onQueryChange = { query = it })
}UiState pattern
Model screen state as a single sealed interface or data class.
sealed interface ProductsUiState {
data object Loading : ProductsUiState
data class Success(val products: List<Product>) : ProductsUiState
data class Error(val message: String) : ProductsUiState
}
@HiltViewModel
class ProductsViewModel @Inject constructor(
private val repository: ProductRepository,
) : ViewModel() {
val uiState: StateFlow<ProductsUiState> = repository.getProducts()
.map<List<Product>, ProductsUiState> { ProductsUiState.Success(it) }
.catch { emit(ProductsUiState.Error(it.message ?: "Unknown error")) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ProductsUiState.Loading)
}
@Composable
fun ProductsScreen(viewModel: ProductsViewModel = hiltViewModel()) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
when (val state = uiState) {
is ProductsUiState.Loading -> CircularProgressIndicator()
is ProductsUiState.Success -> ProductList(state.products)
is ProductsUiState.Error -> ErrorMessage(state.message)
}
}Derived state
Use derivedStateOf when one state value is computed from another to avoid
unnecessary recompositions.
val items = remember { mutableStateListOf<Item>() }
val sortedItems by remember { derivedStateOf { items.sortedBy { it.name } } }Recomposition optimization
Stability
Compose skips recomposition for composables whose parameters have not changed.
For this to work, parameters must be stable (immutable data classes, primitives,
or annotated with @Stable/@Immutable).
@Immutable
data class Product(
val id: String,
val name: String,
val price: Double,
)Lists are unstable by default. Wrap in an immutable holder:
@Immutable
data class ProductList(val items: List<Product>)Key for LazyColumn
Always provide a key in LazyColumn/LazyRow to preserve state across reorderings.
LazyColumn {
items(products, key = { it.id }) { product ->
ProductCard(product)
}
}Material 3 theming
Dynamic color
@Composable
fun AppTheme(content: @Composable () -> Unit) {
val colorScheme = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val context = LocalContext.current
if (isSystemInDarkTheme()) dynamicDarkColorScheme(context)
else dynamicLightColorScheme(context)
} else {
if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content,
)
}Custom color scheme
private val LightColors = lightColorScheme(
primary = Color(0xFF6750A4),
onPrimary = Color.White,
primaryContainer = Color(0xFFEADDFF),
secondary = Color(0xFF625B71),
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
)Side effects
| Effect | Use when |
|---|---|
LaunchedEffect(key) |
Run a suspend function when key changes (API calls, animations) |
DisposableEffect(key) |
Set up and tear down non-suspend resources (listeners, callbacks) |
SideEffect |
Publish compose state to non-compose code on every successful recomposition |
rememberCoroutineScope() |
Need a scope tied to composition for event handlers (not composition itself) |
snapshotFlow { } |
Convert compose State reads into a Flow for use in coroutines |
// Launch a one-time effect when the screen appears
LaunchedEffect(Unit) {
viewModel.loadData()
}
// Clean up a listener
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event -> /* handle */ }
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}Custom layouts
Use Layout composable for custom measurement and placement.
@Composable
fun FlowRow(
modifier: Modifier = Modifier,
spacing: Dp = 8.dp,
content: @Composable () -> Unit,
) {
Layout(content = content, modifier = modifier) { measurables, constraints ->
val placeables = measurables.map { it.measure(constraints) }
var x = 0
var y = 0
var rowHeight = 0
layout(constraints.maxWidth, constraints.maxHeight) {
placeables.forEach { placeable ->
if (x + placeable.width > constraints.maxWidth) {
x = 0
y += rowHeight + spacing.roundToPx()
rowHeight = 0
}
placeable.placeRelative(x, y)
x += placeable.width + spacing.roundToPx()
rowHeight = maxOf(rowHeight, placeable.height)
}
}
}
}Prefer
FlowRowandFlowColumnfromandroidx.compose.foundation.layout(available in Compose Foundation 1.4+) over custom implementations.
coroutines-flows.md
Coroutines and Flows
Coroutine scopes in Android
| Scope | Lifecycle | Use for |
|---|---|---|
viewModelScope |
ViewModel (survives config changes) | Data loading, business logic |
lifecycleScope |
Activity/Fragment lifecycle | UI-specific one-shot work |
rememberCoroutineScope() |
Composition lifetime | Event handlers in Compose |
GlobalScope |
Application lifetime | Almost never - use a custom Application-scoped scope |
// ViewModel - preferred for most Android work
class MyViewModel : ViewModel() {
init {
viewModelScope.launch {
// Automatically cancelled when ViewModel is cleared
}
}
}
// Compose event handler
@Composable
fun MyScreen() {
val scope = rememberCoroutineScope()
Button(onClick = {
scope.launch { /* handle event */ }
}) { Text("Click") }
}Dispatchers
| Dispatcher | Thread | Use for |
|---|---|---|
Dispatchers.Main |
Main/UI thread | UI updates, light work (default in viewModelScope) |
Dispatchers.IO |
Shared IO pool | Network, database, file I/O |
Dispatchers.Default |
CPU pool | Heavy computation, sorting, parsing |
Dispatchers.Main.immediate |
Main thread, no re-dispatch | When already on main and want to avoid queue hop |
viewModelScope.launch {
// On Main by default
_uiState.update { it.copy(isLoading = true) }
val result = withContext(Dispatchers.IO) {
repository.fetchData() // blocking I/O
}
// Back on Main
_uiState.update { it.copy(data = result, isLoading = false) }
}Flow types
Cold flows
Regular Flow is cold - it starts producing values only when collected.
fun getUsers(): Flow<List<User>> = flow {
val users = api.fetchUsers()
emit(users)
}
// Or from Room (already cold)
@Query("SELECT * FROM users")
fun getAll(): Flow<List<User>>Hot flows (StateFlow and SharedFlow)
// StateFlow - always has a value, replays latest to new collectors
private val _uiState = MutableStateFlow(UiState())
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
// SharedFlow - no initial value, configurable replay
private val _events = MutableSharedFlow<UiEvent>()
val events: SharedFlow<UiEvent> = _events.asSharedFlow()Converting cold to hot
val uiState: StateFlow<UiState> = repository.getItems()
.map { items -> UiState(items = items) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = UiState(),
)Use
SharingStarted.WhileSubscribed(5000)(5 second timeout) to keep the upstream alive during configuration changes but stop it when truly not needed.
Flow operators
Common transformations
repository.getProducts()
.map { products -> products.filter { it.inStock } } // transform
.distinctUntilChanged() // skip duplicates
.debounce(300) // for search input
.catch { emit(emptyList()) } // handle errors
.onEach { analytics.logProductsLoaded(it.size) } // side effect
.flowOn(Dispatchers.Default) // change upstream dispatcher
.collect { products -> /* use result */ }Combining flows
// Combine latest values from multiple flows
val uiState: StateFlow<SearchUiState> = combine(
searchQuery,
repository.getProducts(),
selectedCategory,
) { query, products, category ->
val filtered = products
.filter { it.category == category }
.filter { it.name.contains(query, ignoreCase = true) }
SearchUiState(results = filtered)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), SearchUiState())
// Zip - pairs elements one-to-one
flowA.zip(flowB) { a, b -> Pair(a, b) }
// FlatMapLatest - cancel previous inner flow when new value arrives
searchQuery.flatMapLatest { query ->
repository.search(query)
}Structured concurrency patterns
Parallel decomposition
viewModelScope.launch {
// Run two operations in parallel
coroutineScope {
val user = async { userRepository.getUser(id) }
val orders = async { orderRepository.getOrders(id) }
_uiState.update {
it.copy(user = user.await(), orders = orders.await())
}
}
}SupervisorScope
Use when you want sibling coroutines to be independent (one failure doesn't cancel others).
viewModelScope.launch {
supervisorScope {
launch { syncUsers() } // failure here won't cancel syncOrders
launch { syncOrders() } // failure here won't cancel syncUsers
}
}Error handling
In coroutines
viewModelScope.launch {
try {
val data = withContext(Dispatchers.IO) { api.fetchData() }
_uiState.update { it.copy(data = data) }
} catch (e: IOException) {
_uiState.update { it.copy(error = "Network error: ${e.message}") }
} catch (e: HttpException) {
_uiState.update { it.copy(error = "Server error: ${e.code()}") }
}
}In flows
repository.getItems()
.catch { e ->
// Catch upstream errors, emit fallback
emit(emptyList())
_errors.emit("Failed to load: ${e.message}")
}
.collect { items -> /* use items */ }
catchonly catches upstream errors. If thecollectblock throws, it propagates to the coroutine's exception handler.
CoroutineExceptionHandler
private val handler = CoroutineExceptionHandler { _, exception ->
_uiState.update { it.copy(error = exception.message) }
}
viewModelScope.launch(handler) {
riskyOperation()
}Testing coroutines
Setup
@OptIn(ExperimentalCoroutinesApi::class)
class MyViewModelTest {
private val testDispatcher = UnconfinedTestDispatcher()
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
}
@After
fun teardown() {
Dispatchers.resetMain()
}
}Testing StateFlow
@Test
fun `loads products on init`() = runTest {
val fakeRepo = FakeProductRepository(listOf(Product("1", "Widget")))
val viewModel = ProductsViewModel(fakeRepo)
val state = viewModel.uiState.first { it is ProductsUiState.Success }
assertThat((state as ProductsUiState.Success).products).hasSize(1)
}Turbine for Flow testing
// Add: testImplementation("app.cash.turbine:turbine:1.1.0")
@Test
fun `emits loading then success`() = runTest {
viewModel.uiState.test {
assertThat(awaitItem()).isEqualTo(ProductsUiState.Loading)
assertThat(awaitItem()).isInstanceOf(ProductsUiState.Success::class.java)
cancelAndConsumeRemainingEvents()
}
}Use
UnconfinedTestDispatcherfor tests where you want eager execution. UseStandardTestDispatcherwhen you need to control virtual time withadvanceTimeBy()oradvanceUntilIdle().
play-store-checklist.md
Play Store Release Checklist
Pre-release build checklist
1. Version management
// app/build.gradle.kts
android {
defaultConfig {
versionCode = 12 // Must increment every upload. Integer only.
versionName = "2.3.0" // User-visible. Follow semver.
}
}The Play Store rejects uploads where
versionCodeis not strictly greater than the previously uploaded version.
2. Release signing
android {
signingConfigs {
create("release") {
storeFile = file(System.getenv("KEYSTORE_PATH") ?: "release.keystore")
storePassword = System.getenv("KEYSTORE_PASSWORD") ?: ""
keyAlias = System.getenv("KEY_ALIAS") ?: ""
keyPassword = System.getenv("KEY_PASSWORD") ?: ""
}
}
buildTypes {
release {
signingConfig = signingConfigs.getByName("release")
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
}Use Google Play App Signing (recommended). Upload your app signing key to Play Console and sign uploads with a separate upload key. If the upload key is compromised, Google can reset it without affecting end users.
3. Build the release AAB
./gradlew bundleRelease
# Output: app/build/outputs/bundle/release/app-release.aabAlways use AAB (Android App Bundle) instead of APK for Play Store. AAB enables Dynamic Delivery, reducing download size by 15-30%.
4. Test the release build locally
# Install release build on device
./gradlew installRelease
# Or use bundletool to test AAB locally
bundletool build-apks --bundle=app-release.aab --output=app.apks \
--ks=release.keystore --ks-key-alias=key0
bundletool install-apks --apks=app.apksProGuard / R8 configuration
Common keep rules
# Kotlin serialization
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.AnnotationsKt
-keepclassmembers class kotlinx.serialization.json.** {
*** Companion;
}
# Retrofit
-keepattributes Signature, InnerClasses, EnclosingMethod
-keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations
-keepclassmembers,allowshrinking,allowobfuscation interface * {
@retrofit2.http.* <methods>;
}
# Room
-keep class * extends androidx.room.RoomDatabase
-keep @androidx.room.Entity class *
# Gson (if used)
-keepattributes Signature
-keep class com.example.app.data.model.** { *; }
# Hilt
-keep class dagger.hilt.** { *; }
-keep class javax.inject.** { *; }
-keep @dagger.hilt.android.lifecycle.HiltViewModel class * { *; }Debugging R8 issues
# Generate mapping file for crash deobfuscation
android {
buildTypes {
release {
proguardFiles(...)
// mapping.txt generated at build/outputs/mapping/release/mapping.txt
}
}
}Upload
mapping.txtto Play Console (App bundle explorer > Downloads tab) for deobfuscated crash reports in Android Vitals.
Store listing requirements
Required assets
| Asset | Spec |
|---|---|
| App icon | 512x512 PNG, 32-bit, no alpha |
| Feature graphic | 1024x500 PNG or JPG |
| Phone screenshots | Min 2, max 8. 16:9 or 9:16. Min 320px, max 3840px |
| Tablet screenshots | Required if targeting tablets. 7-inch and 10-inch |
| Short description | Max 80 characters |
| Full description | Max 4000 characters |
Content rating
Complete the IARC content rating questionnaire in Play Console. Without it, the app may be removed from the store. Covers violence, sexual content, language, and other categories.
Privacy policy
Required for all apps that:
- Request sensitive permissions (camera, location, contacts, etc.)
- Access personal or sensitive user data
- Target children (COPPA compliance)
Host the privacy policy at a publicly accessible URL.
Data safety form
Declare all data collection and sharing practices. Required since July 2022. Categories include: name, email, location, photos, app activity, device IDs.
Release tracks
| Track | Purpose | Audience |
|---|---|---|
| Internal testing | Quick iteration, no review needed | Up to 100 internal testers |
| Closed testing | Beta testing with selected users | Invite-only, up to 100K |
| Open testing | Public beta, discoverable on Play Store | Anyone can join |
| Production | Full release | All users |
Staged rollout
Production release flow:
1. Upload AAB to production track
2. Set rollout percentage (start at 5-10%)
3. Monitor Android Vitals for 24-48 hours
4. Check crash rate, ANR rate, user feedback
5. Increase to 25% -> 50% -> 100%
6. Halt rollout immediately if crash rate exceeds baselineA staged rollout to 5% for 48 hours catches most critical issues before they affect the full user base.
Android Vitals thresholds
Google penalizes apps in search ranking if they exceed these thresholds:
| Metric | Bad threshold | What it measures |
|---|---|---|
| Crash rate | > 1.09% | Percentage of sessions with crashes |
| ANR rate | > 0.47% | Percentage of sessions with Application Not Responding |
| Excessive wakeups | > 10 per hour | Background wakeups draining battery |
| Stuck wake locks | > 0.10% | Sessions with wake locks held > 1 hour |
Reducing ANRs
- Never do I/O on the main thread
- Use
StrictModeduring development to detect violations - Keep
BroadcastReceiver.onReceive()under 10 seconds - Use
WorkManagerfor background tasks instead of long-running services
Reducing crashes
- Handle nullable data from APIs defensively
- Test on low-memory devices (use
isLowRamDevice) - Test with and without network connectivity
- Use
ProcessLifecycleOwnerto handle process death gracefully
CI/CD integration
GitHub Actions example
name: Release
on:
push:
tags: ['v*']
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- name: Build release AAB
run: ./gradlew bundleRelease
env:
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
- name: Upload to Play Store
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT }}
packageName: com.example.app
releaseFiles: app/build/outputs/bundle/release/app-release.aab
track: internal
mappingFile: app/build/outputs/mapping/release/mapping.txtStore the keystore file as a base64-encoded secret, decode it in CI. Never commit keystores or credentials to version control.
room-advanced.md
Room Advanced
Type converters
Room only supports primitive types and strings natively. Use @TypeConverter
for complex types.
class Converters {
@TypeConverter
fun fromTimestamp(value: Long?): Date? = value?.let { Date(it) }
@TypeConverter
fun dateToTimestamp(date: Date?): Long? = date?.time
@TypeConverter
fun fromStringList(value: String?): List<String> =
value?.split(",")?.map { it.trim() } ?: emptyList()
@TypeConverter
fun stringListToString(list: List<String>): String = list.joinToString(",")
}
@Database(entities = [Task::class], version = 1)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun taskDao(): TaskDao
}For complex objects, prefer storing as JSON with a Gson/Moshi converter. For lists of IDs, consider a junction table instead of serialized strings.
Relations
One-to-many
@Entity
data class User(
@PrimaryKey val userId: Long,
val name: String,
)
@Entity(
foreignKeys = [ForeignKey(
entity = User::class,
parentColumns = ["userId"],
childColumns = ["ownerUserId"],
onDelete = ForeignKey.CASCADE,
)]
)
data class Playlist(
@PrimaryKey val playlistId: Long,
val ownerUserId: Long,
val name: String,
)
data class UserWithPlaylists(
@Embedded val user: User,
@Relation(parentColumn = "userId", entityColumn = "ownerUserId")
val playlists: List<Playlist>,
)
@Dao
interface UserDao {
@Transaction
@Query("SELECT * FROM User WHERE userId = :id")
fun getUserWithPlaylists(id: Long): Flow<UserWithPlaylists>
}Always annotate relation queries with
@Transactionto ensure data consistency across the multiple queries Room executes internally.
Many-to-many
@Entity(primaryKeys = ["playlistId", "songId"])
data class PlaylistSongCrossRef(
val playlistId: Long,
val songId: Long,
)
data class PlaylistWithSongs(
@Embedded val playlist: Playlist,
@Relation(
parentColumn = "playlistId",
entityColumn = "songId",
associateBy = Junction(PlaylistSongCrossRef::class),
)
val songs: List<Song>,
)Complex queries
Dynamic queries with RawQuery
@Dao
interface SearchDao {
@RawQuery(observedEntities = [Task::class])
fun search(query: SupportSQLiteQuery): Flow<List<Task>>
}
// Usage
val query = SimpleSQLiteQuery(
"SELECT * FROM tasks WHERE title LIKE ? ORDER BY createdAt DESC",
arrayOf("%$searchTerm%"),
)
searchDao.search(query)Full-text search (FTS4)
@Fts4(contentEntity = Task::class)
@Entity(tableName = "tasks_fts")
data class TaskFts(
val title: String,
val description: String,
)
@Dao
interface TaskDao {
@Query("""
SELECT tasks.* FROM tasks
JOIN tasks_fts ON tasks.rowid = tasks_fts.rowid
WHERE tasks_fts MATCH :query
""")
fun search(query: String): Flow<List<Task>>
}FTS queries use the MATCH operator, not LIKE. Search terms support prefix matching with
*(e.g."prod*") and boolean operators (AND,OR,NOT).
Migrations
Auto-migration (Room 2.4+)
For simple schema changes (add column, add table), Room can generate migrations automatically.
@Database(
entities = [Task::class],
version = 2,
autoMigrations = [AutoMigration(from = 1, to = 2)],
)
abstract class AppDatabase : RoomDatabase()For renames or deletes, provide a spec:
@RenameColumn(tableName = "tasks", fromColumnName = "done", toColumnName = "isCompleted")
class Migration1To2 : AutoMigrationSpec
@Database(
entities = [Task::class],
version = 2,
autoMigrations = [AutoMigration(from = 1, to = 2, spec = Migration1To2::class)],
)
abstract class AppDatabase : RoomDatabase()Manual migration
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
// Create new table with new schema
db.execSQL("""
CREATE TABLE tasks_new (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
title TEXT NOT NULL,
isCompleted INTEGER NOT NULL DEFAULT 0,
priority INTEGER NOT NULL DEFAULT 0
)
""")
// Copy data
db.execSQL("""
INSERT INTO tasks_new (id, title, isCompleted)
SELECT id, title, isCompleted FROM tasks
""")
// Swap
db.execSQL("DROP TABLE tasks")
db.execSQL("ALTER TABLE tasks_new RENAME TO tasks")
}
}Testing Room
@RunWith(AndroidJUnit4::class)
class TaskDaoTest {
private lateinit var db: AppDatabase
private lateinit var taskDao: TaskDao
@Before
fun setup() {
db = Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext(),
AppDatabase::class.java,
).allowMainThreadQueries().build()
taskDao = db.taskDao()
}
@After
fun teardown() {
db.close()
}
@Test
fun insertAndRead() = runTest {
val task = Task(title = "Test task")
taskDao.insert(task)
val tasks = taskDao.getAll().first()
assertThat(tasks).hasSize(1)
assertThat(tasks[0].title).isEqualTo("Test task")
}
}Use
inMemoryDatabaseBuilderfor tests - it's fast and doesn't persist. UseallowMainThreadQueries()only in tests, never in production.
Performance tips
- Use
@ColumnInfo(index = true)on columns used in WHERE or JOIN clauses - Avoid
SELECT *in queries returning many rows - select only needed columns - Use
PagingSourcefrom Paging 3 for large datasets instead ofFlow<List<T>> - Enable WAL mode (default since Room 2.0) for concurrent read/write performance
- Use
@Upsert(Room 2.5+) instead of@Insert(onConflict = REPLACE)to avoid unnecessary deletes and re-inserts
Frequently Asked Questions
What is android-kotlin?
Use this skill when building Android applications with Kotlin. Triggers on Jetpack Compose UI, Room database, Kotlin coroutines, Play Store publishing, MVVM/MVI architecture, ViewModel, StateFlow, Hilt dependency injection, Navigation Compose, Material 3, APK/AAB builds, ProGuard, and Android app lifecycle management. Covers modern Android development with declarative UI, reactive state, structured concurrency, and production release workflows.
How do I install android-kotlin?
Run npx skills add AbsolutelySkilled/AbsolutelySkilled --skill android-kotlin in your terminal. The skill will be immediately available in your AI coding agent.
What AI agents support android-kotlin?
android-kotlin works with claude-code, gemini-cli, openai-codex, mcp. Install it once and use it across any supported AI coding agent.