android-expert

State in Compose flows downward and events flow upward (unidirectional data flow).

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "android-expert" with this command: npx skills add oimiragieo/agent-studio/oimiragieo-agent-studio-android-expert

Android Expert

  1. Jetpack Compose

State Management

State in Compose flows downward and events flow upward (unidirectional data flow).

State hoisting pattern:

// Stateless composable — accepts state and callbacks @Composable fun LoginForm( email: String, password: String, onEmailChange: (String) -> Unit, onPasswordChange: (String) -> Unit, onSubmit: () -> Unit, ) { Column { TextField(value = email, onValueChange = onEmailChange, label = { Text("Email") }) TextField(value = password, onValueChange = onPasswordChange, label = { Text("Password") }) Button(onClick = onSubmit) { Text("Log in") } } }

// Stateful caller — owns state and passes it down @Composable fun LoginScreen(viewModel: LoginViewModel = hiltViewModel()) { val state by viewModel.uiState.collectAsStateWithLifecycle() LoginForm( email = state.email, password = state.password, onEmailChange = viewModel::onEmailChanged, onPasswordChange = viewModel::onPasswordChanged, onSubmit = viewModel::onSubmit, ) }

remember vs rememberSaveable :

  • remember : Survives recomposition only. Use for transient UI state.

  • rememberSaveable : Survives recomposition AND process death (saved to Bundle). Use for user-visible state (scroll position, form input).

// remember — lost on configuration change / process death var expanded by remember { mutableStateOf(false) }

// rememberSaveable — survives configuration change and process death var selectedTab by rememberSaveable { mutableIntStateOf(0) }

derivedStateOf : Use when derived state depends on other state objects and you want to avoid unnecessary recompositions.

val isSubmitEnabled by remember { derivedStateOf { email.isNotBlank() && password.length >= 8 } }

Side Effects

Use structured side effect APIs — never launch coroutines or perform side effects in composition.

API When to use

LaunchedEffect(key)

Launch a coroutine tied to a key; cancels/relaunches when key changes

rememberCoroutineScope()

Get a scope for event-driven coroutines (button click, etc.)

SideEffect

Run non-suspend side effects after every successful composition

DisposableEffect(key)

Side effects with cleanup (register/unregister callbacks)

// Navigate to destination after login success LaunchedEffect(uiState.isLoggedIn) { if (uiState.isLoggedIn) navController.navigate(Route.Home) }

// Scope for click-driven coroutine val scope = rememberCoroutineScope() Button(onClick = { scope.launch { /* ... */ } }) { Text("Save") }

// Register/unregister a callback DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> /* ... */ } lifecycleOwner.lifecycle.addObserver(observer) onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } }

Recomposition Optimization

Recomposition is the main performance concern in Compose. Minimize its scope.

// AVOID: Unstable lambda captures the entire parent scope @Composable fun ItemList(items: List<Item>, onItemClick: (Item) -> Unit) { LazyColumn { items(items, key = { it.id }) { item -> // New lambda instance created on each recomposition of ItemList ItemRow(item = item, onClick = { onItemClick(item) }) } } }

// PREFER: Stable key + remember to avoid unnecessary child recompositions @Composable fun ItemList(items: List<Item>, onItemClick: (Item) -> Unit) { val stableOnClick = rememberUpdatedState(onItemClick) LazyColumn { items(items, key = { it.id }) { item -> ItemRow(item = item, onClick = { stableOnClick.value(item) }) } } }

Rules for stable types:

  • Primitive types and String are always stable.

  • Data classes with only stable fields are stable if annotated @Stable or @Immutable .

  • List , Map , Set from stdlib are unstable — prefer kotlinx.collections.immutable .

@Immutable data class UserProfile(val name: String, val avatarUrl: String)

Modifier ordering matters: Apply modifiers in logical order (size → padding → background → clickable).

// Correct: padding inside clickable area Modifier .size(48.dp) .clip(CircleShape) .clickable(onClick = onClick) .padding(8.dp)

Compose Layouts and Custom Layouts

// Custom layout example: badge overlay @Composable fun BadgeBox(badgeCount: Int, content: @Composable () -> Unit) { Layout(content = { content() if (badgeCount > 0) { Box(Modifier.background(Color.Red, CircleShape)) { Text("$badgeCount", color = Color.White, fontSize = 10.sp) } } }) { measurables, constraints -> val contentPlaceable = measurables[0].measure(constraints) val badgePlaceable = measurables.getOrNull(1)?.measure(Constraints()) layout(contentPlaceable.width, contentPlaceable.height) { contentPlaceable.placeRelative(0, 0) badgePlaceable?.placeRelative( contentPlaceable.width - badgePlaceable.width / 2, -badgePlaceable.height / 2 ) } } }

CompositionLocal

Use CompositionLocal to propagate ambient data through the composition tree without threading it explicitly through every composable.

// Define val LocalSnackbarHostState = compositionLocalOf<SnackbarHostState> { error("No SnackbarHostState provided") }

// Provide at a high level CompositionLocalProvider(LocalSnackbarHostState provides snackbarHostState) { MyAppContent() }

// Consume anywhere below val snackbarHostState = LocalSnackbarHostState.current

When to use: User preferences (theme, locale), shared services (analytics, navigation). When to avoid: Data that changes frequently or should be passed explicitly.

Animations

// Simple animated visibility AnimatedVisibility(visible = showDetails) { DetailsPanel() }

// Animated value val alpha by animateFloatAsState( targetValue = if (isEnabled) 1f else 0.4f, animationSpec = tween(durationMillis = 300), label = "alpha", )

// Shared element transition (Compose 1.7+) SharedTransitionLayout { AnimatedContent(targetState = selectedItem) { item -> if (item == null) { ListScreen( onItemClick = { selectedItem = it }, animatedVisibilityScope = this, sharedTransitionScope = this@SharedTransitionLayout, ) } else { DetailScreen( item = item, animatedVisibilityScope = this, sharedTransitionScope = this@SharedTransitionLayout, ) } } }

  1. Kotlin Coroutines and Flow

Coroutines Fundamentals

// ViewModel: use viewModelScope (auto-cancelled on VM cleared) class OrderViewModel @Inject constructor( private val orderRepository: OrderRepository, ) : ViewModel() {

fun placeOrder(order: Order) {
    viewModelScope.launch {
        try {
            orderRepository.placeOrder(order)
        } catch (e: HttpException) {
            // handle error
        }
    }
}

}

// Repository: return suspend fun or Flow, never launch internally class OrderRepositoryImpl @Inject constructor( private val api: OrderApi, private val dao: OrderDao, ) : OrderRepository {

override suspend fun placeOrder(order: Order) {
    api.placeOrder(order.toRequest())
    dao.insert(order.toEntity())
}

}

Dispatcher guidelines:

  • Dispatchers.Main : UI interactions, state updates

  • Dispatchers.IO : Network calls, file/database I/O

  • Dispatchers.Default : CPU-intensive computations

// withContext switches dispatcher for a block suspend fun loadImage(url: String): Bitmap = withContext(Dispatchers.IO) { URL(url).readBytes().let { BitmapFactory.decodeByteArray(it, 0, it.size) } }

Flow

Use Flow for reactive streams. Prefer StateFlow /SharedFlow in ViewModels.

// Repository: expose cold Flow fun observeOrders(): Flow<List<Order>> = dao.observeAll().map { entities -> entities.map { it.toModel() } }

// ViewModel: convert to StateFlow for UI class OrderListViewModel @Inject constructor(repo: OrderRepository) : ViewModel() {

val orders: StateFlow&#x3C;List&#x3C;Order>> = repo.observeOrders()
    .stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5_000),
        initialValue = emptyList(),
    )

}

// Compose: collect safely with lifecycle awareness val orders by viewModel.orders.collectAsStateWithLifecycle()

Flow operators to know:

flow .filter { it.isActive } .map { it.toUiModel() } .debounce(300) // search input debounce .distinctUntilChanged() .catch { e -> emit(emptyList()) } // handle errors inline .flowOn(Dispatchers.IO) // run upstream on IO dispatcher

SharedFlow for one-shot events:

private val _events = MutableSharedFlow<UiEvent>() val events: SharedFlow<UiEvent> = _events.asSharedFlow()

// Emit from ViewModel fun onSubmit() { viewModelScope.launch { _events.emit(UiEvent.NavigateToHome) } }

// Collect in Composable LaunchedEffect(Unit) { viewModel.events.collect { event -> when (event) { is UiEvent.NavigateToHome -> navController.navigate(Route.Home) is UiEvent.ShowError -> snackbar.showSnackbar(event.message) } } }

  1. Android Architecture Components

ViewModel

@HiltViewModel class ProductDetailViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val getProductUseCase: GetProductUseCase, ) : ViewModel() {

private val productId: String = checkNotNull(savedStateHandle["productId"])

private val _uiState = MutableStateFlow&#x3C;ProductDetailUiState>(ProductDetailUiState.Loading)
val uiState: StateFlow&#x3C;ProductDetailUiState> = _uiState.asStateFlow()

init { loadProduct() }

private fun loadProduct() {
    viewModelScope.launch {
        _uiState.value = try {
            val product = getProductUseCase(productId)
            ProductDetailUiState.Success(product)
        } catch (e: Exception) {
            ProductDetailUiState.Error(e.message ?: "Unknown error")
        }
    }
}

}

sealed interface ProductDetailUiState { data object Loading : ProductDetailUiState data class Success(val product: Product) : ProductDetailUiState data class Error(val message: String) : ProductDetailUiState }

Room

@Entity(tableName = "orders") data class OrderEntity( @PrimaryKey val id: String, val customerId: String, val totalCents: Long, val status: String, val createdAt: Long, )

@Dao interface OrderDao { @Query("SELECT * FROM orders ORDER BY createdAt DESC") fun observeAll(): Flow<List<OrderEntity>>

@Query("SELECT * FROM orders WHERE id = :id")
suspend fun getById(id: String): OrderEntity?

@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(order: OrderEntity)

@Delete
suspend fun delete(order: OrderEntity)

}

@Database(entities = [OrderEntity::class], version = 2) @TypeConverters(Converters::class) abstract class AppDatabase : RoomDatabase() { abstract fun orderDao(): OrderDao }

Migration example:

val MIGRATION_1_2 = object : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("ALTER TABLE orders ADD COLUMN notes TEXT NOT NULL DEFAULT ''") } }

WorkManager

Use for deferrable, guaranteed background work.

class SyncWorker @AssistedInject constructor( @Assisted context: Context, @Assisted params: WorkerParameters, private val syncRepository: SyncRepository, ) : CoroutineWorker(context, params) {

override suspend fun doWork(): Result = try {
    syncRepository.sync()
    Result.success()
} catch (e: Exception) {
    if (runAttemptCount &#x3C; 3) Result.retry() else Result.failure()
}

@AssistedFactory
interface Factory : ChildWorkerFactory&#x3C;SyncWorker>

}

// Schedule periodic sync val syncRequest = PeriodicWorkRequestBuilder<SyncWorker>(1, TimeUnit.HOURS) .setConstraints(Constraints(requiredNetworkType = NetworkType.CONNECTED)) .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.MINUTES) .build()

WorkManager.getInstance(context).enqueueUniquePeriodicWork( "sync", ExistingPeriodicWorkPolicy.KEEP, syncRequest, )

  1. Dependency Injection with Hilt

Module Setup

@HiltAndroidApp class MyApplication : Application()

// Activity/Fragment @AndroidEntryPoint class MainActivity : ComponentActivity() { /* ... */ }

// Network module @Module @InstallIn(SingletonComponent::class) object NetworkModule {

@Provides
@Singleton
fun provideOkHttpClient(): OkHttpClient = OkHttpClient.Builder()
    .addInterceptor(AuthInterceptor())
    .build()

@Provides
@Singleton
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit = Retrofit.Builder()
    .baseUrl("https://api.example.com/")
    .client(okHttpClient)
    .addConverterFactory(GsonConverterFactory.create())
    .build()

@Provides
@Singleton
fun provideOrderApi(retrofit: Retrofit): OrderApi = retrofit.create(OrderApi::class.java)

}

// Repository binding @Module @InstallIn(SingletonComponent::class) abstract class RepositoryModule {

@Binds
@Singleton
abstract fun bindOrderRepository(impl: OrderRepositoryImpl): OrderRepository

}

Scopes

Scope Component Lifetime

@Singleton

SingletonComponent

Application lifetime

@ActivityRetainedScoped

ActivityRetainedComponent

ViewModel lifetime

@ActivityScoped

ActivityComponent

Activity lifetime

@ViewModelScoped

ViewModelComponent

ViewModel lifetime

@FragmentScoped

FragmentComponent

Fragment lifetime

Hilt with WorkManager

@HiltWorker class UploadWorker @AssistedInject constructor( @Assisted context: Context, @Assisted params: WorkerParameters, private val uploadService: UploadService, ) : CoroutineWorker(context, params) { /* ... */ }

  1. Navigation Component

NavGraph with Type-Safe Arguments (Navigation 2.8+)

// Define destinations as serializable objects/classes @Serializable object HomeRoute @Serializable object ProfileRoute @Serializable data class ProductDetailRoute(val productId: String)

// Build NavGraph @Composable fun AppNavGraph(navController: NavHostController) { NavHost(navController, startDestination = HomeRoute) { composable<HomeRoute> { HomeScreen(onProductClick = { id -> navController.navigate(ProductDetailRoute(id)) }) } composable<ProductDetailRoute> { backStackEntry -> val args = backStackEntry.toRoute<ProductDetailRoute>() ProductDetailScreen(productId = args.productId) } composable<ProfileRoute> { ProfileScreen() } } }

Deep Links

composable<ProductDetailRoute>( deepLinks = listOf( navDeepLink<ProductDetailRoute>(basePath = "https://example.com/product") ) ) { /* ... */ }

Declare in AndroidManifest.xml :

<activity android:name=".MainActivity"> <intent-filter android:autoVerify="true"> <action android:name="android.intent.action.VIEW" /> <category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.BROWSABLE" /> <data android:scheme="https" android:host="example.com" /> </intent-filter> </activity>

Bottom Navigation

@Composable fun MainScreen() { val navController = rememberNavController() val currentBackStack by navController.currentBackStackEntryAsState() val currentDestination = currentBackStack?.destination

Scaffold(
    bottomBar = {
        NavigationBar {
            TopLevelDestination.entries.forEach { dest ->
                NavigationBarItem(
                    icon = { Icon(dest.icon, contentDescription = dest.label) },
                    label = { Text(dest.label) },
                    selected = currentDestination?.hasRoute(dest.route::class) == true,
                    onClick = {
                        navController.navigate(dest.route) {
                            popUpTo(navController.graph.findStartDestination().id) {
                                saveState = true
                            }
                            launchSingleTop = true
                            restoreState = true
                        }
                    },
                )
            }
        }
    }
) { padding ->
    AppNavGraph(navController = navController, modifier = Modifier.padding(padding))
}

}

  1. Android Testing

Unit Testing (JUnit5 + MockK)

@ExtendWith(MockKExtension::class) class GetProductUseCaseTest {

@MockK lateinit var repository: ProductRepository
private lateinit var useCase: GetProductUseCase

@BeforeEach
fun setUp() {
    useCase = GetProductUseCase(repository)
}

@Test
fun `returns product when repository succeeds`() = runTest {
    val product = Product(id = "1", name = "Widget", priceCents = 999)
    coEvery { repository.getProduct("1") } returns product

    val result = useCase("1")

    assertThat(result).isEqualTo(product)
}

@Test
fun `throws exception when product not found`() = runTest {
    coEvery { repository.getProduct("missing") } throws NotFoundException("missing")

    assertThrows&#x3C;NotFoundException> { useCase("missing") }
}

}

ViewModel Testing

@OptIn(ExperimentalCoroutinesApi::class) class ProductDetailViewModelTest {

@get:Rule val mainDispatcherRule = MainDispatcherRule()

private val repository = mockk&#x3C;ProductRepository>()
private lateinit var viewModel: ProductDetailViewModel

@Before
fun setUp() {
    viewModel = ProductDetailViewModel(
        savedStateHandle = SavedStateHandle(mapOf("productId" to "abc")),
        getProductUseCase = GetProductUseCase(repository),
    )
}

@Test
fun `uiState is Loading initially then Success`() = runTest {
    val product = Product("abc", "Gizmo", 1299)
    coEvery { repository.getProduct("abc") } returns product

    val states = mutableListOf&#x3C;ProductDetailUiState>()
    val job = launch { viewModel.uiState.toList(states) }

    advanceUntilIdle()
    job.cancel()

    assertThat(states).contains(ProductDetailUiState.Loading)
    assertThat(states.last()).isEqualTo(ProductDetailUiState.Success(product))
}

}

class MainDispatcherRule( private val dispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher(), ) : TestWatcher() { override fun starting(description: Description) { Dispatchers.setMain(dispatcher) } override fun finished(description: Description) { Dispatchers.resetMain() dispatcher.cleanupTestCoroutines() } }

Compose UI Testing

class LoginScreenTest {

@get:Rule val composeTestRule = createComposeRule()

@Test
fun `submit button disabled when fields are empty`() {
    composeTestRule.setContent {
        LoginScreen(onLoginSuccess = {})
    }

    composeTestRule
        .onNodeWithText("Log in")
        .assertIsNotEnabled()
}

@Test
fun `displays error message on invalid credentials`() {
    composeTestRule.setContent {
        LoginScreen(onLoginSuccess = {})
    }

    composeTestRule.onNodeWithText("Email").performTextInput("bad@example.com")
    composeTestRule.onNodeWithText("Password").performTextInput("wrongpass")
    composeTestRule.onNodeWithText("Log in").performClick()

    composeTestRule
        .onNodeWithText("Invalid credentials")
        .assertIsDisplayed()
}

}

Espresso (Legacy / Hybrid Apps)

@RunWith(AndroidJUnit4::class) class MainActivityTest {

@get:Rule val activityRule = ActivityScenarioRule(MainActivity::class.java)

@Test
fun navigatesToDetailScreen() {
    onView(withId(R.id.product_list))
        .perform(RecyclerViewActions.actionOnItemAtPosition&#x3C;RecyclerView.ViewHolder>(0, click()))

    onView(withId(R.id.product_title)).check(matches(isDisplayed()))
}

}

Robolectric (Fast JVM Tests)

@RunWith(RobolectricTestRunner::class) @Config(sdk = [34]) class NotificationHelperTest {

@Test
fun `creates notification with correct channel`() {
    val context = ApplicationProvider.getApplicationContext&#x3C;Context>()
    val helper = NotificationHelper(context)

    helper.showOrderNotification(orderId = "42", message = "Your order shipped!")

    val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
    assertThat(nm.activeNotifications).hasSize(1)
}

}

  1. Performance

Baseline Profiles

Baseline Profiles pre-compile hot paths during app installation, reducing JIT overhead.

// app/src/main/baseline-prof.txt (auto-generated by Macrobenchmark) // Or use the Baseline Profile Gradle Plugin:

// build.gradle.kts (app) plugins { id("androidx.baselineprofile") }

// Generate: ./gradlew :app:generateBaselineProfile

Macrobenchmark for generation:

@RunWith(AndroidJUnit4::class) class BaselineProfileGenerator {

@get:Rule val rule = BaselineProfileRule()

@Test
fun generate() = rule.collect(packageName = "com.example.myapp") {
    pressHome()
    startActivityAndWait()
    // Interact with critical user journeys
    device.findObject(By.text("Products")).click()
    device.waitForIdle()
}

}

Tracing / Systrace

// Add custom trace sections trace("MyExpensiveOperation") { performExpensiveWork() }

// Compose compiler metrics — add to build.gradle.kts tasks.withType<KotlinCompile>().configureEach { compilerOptions.freeCompilerArgs.addAll( "-P", "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=${layout.buildDirectory.get()}/compose_metrics" ) }

Memory Profiling

  • Use Android Studio Memory Profiler to capture heap dumps.

  • Look for Bitmap leaks, Context leaks in static fields, and unclosed Cursor objects.

  • Use LeakCanary in debug builds for automatic leak detection.

// Avoid Context leaks: use applicationContext for long-lived objects class ImageCache @Inject constructor( @ApplicationContext private val context: Context // Safe: application scope ) { /* ... */ }

LazyList Performance

LazyColumn { items( items = itemList, key = { item -> item.id }, // Stable key prevents unnecessary recompositions contentType = { item -> item.type }, // Enables item recycling by type ) { item -> ItemRow(item = item) } }

  1. Material Design 3

Theming

// Define color scheme val LightColorScheme = lightColorScheme( primary = Color(0xFF6750A4), onPrimary = Color(0xFFFFFFFF), secondary = Color(0xFF625B71), // ... other tokens )

// Apply theme @Composable fun MyAppTheme( darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit, ) { val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme MaterialTheme( colorScheme = colorScheme, typography = AppTypography, shapes = AppShapes, content = content, ) }

Dynamic Color (Android 12+)

@Composable fun MyAppTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { val context = LocalContext.current val colorScheme = when { Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) } darkTheme -> DarkColorScheme else -> LightColorScheme } MaterialTheme(colorScheme = colorScheme, content = content) }

Key M3 Components

// Top App Bar TopAppBar( title = { Text("Orders") }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } }, actions = { IconButton(onClick = onSearch) { Icon(Icons.Default.Search, "Search") } }, )

// Card ElevatedCard( modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), ) { Column(Modifier.padding(16.dp)) { Text(text = title, style = MaterialTheme.typography.titleMedium) Text(text = subtitle, style = MaterialTheme.typography.bodyMedium) } }

// FAB FloatingActionButton(onClick = onAdd) { Icon(Icons.Default.Add, contentDescription = "Add") }

  1. Modern Android Patterns

MVI (Model-View-Intent)

MVI is the recommended pattern for Compose apps. State flows one direction; intents describe user actions.

// Intent (user actions) sealed interface ProductListIntent { data object LoadProducts : ProductListIntent data class SearchQueryChanged(val query: String) : ProductListIntent data class ProductClicked(val id: String) : ProductListIntent }

// UI State data class ProductListUiState( val isLoading: Boolean = false, val products: List<Product> = emptyList(), val error: String? = null, val searchQuery: String = "", )

// One-shot effects sealed interface ProductListEffect { data class NavigateToDetail(val productId: String) : ProductListEffect }

@HiltViewModel class ProductListViewModel @Inject constructor( private val getProductsUseCase: GetProductsUseCase, ) : ViewModel() {

private val _uiState = MutableStateFlow(ProductListUiState())
val uiState: StateFlow&#x3C;ProductListUiState> = _uiState.asStateFlow()

private val _effect = MutableSharedFlow&#x3C;ProductListEffect>()
val effect: SharedFlow&#x3C;ProductListEffect> = _effect.asSharedFlow()

fun handleIntent(intent: ProductListIntent) {
    when (intent) {
        is ProductListIntent.LoadProducts -> loadProducts()
        is ProductListIntent.SearchQueryChanged -> updateSearch(intent.query)
        is ProductListIntent.ProductClicked -> {
            viewModelScope.launch { _effect.emit(ProductListEffect.NavigateToDetail(intent.id)) }
        }
    }
}

private fun loadProducts() {
    viewModelScope.launch {
        _uiState.update { it.copy(isLoading = true, error = null) }
        _uiState.update {
            try {
                it.copy(isLoading = false, products = getProductsUseCase())
            } catch (e: Exception) {
                it.copy(isLoading = false, error = e.message)
            }
        }
    }
}

}

Clean Architecture Layers

presentation/ ui/ — Composables, screens viewmodel/ — ViewModels, UI State, Intents domain/ model/ — Domain entities (pure Kotlin, no Android deps) repository/ — Repository interfaces usecase/ — Business logic (one use case per file) data/ repository/ — Repository implementations remote/ — API service interfaces, DTOs, mappers local/ — Room entities, DAOs, mappers di/ — Hilt modules

Use Case example:

class GetFilteredProductsUseCase @Inject constructor( private val productRepository: ProductRepository, ) { suspend operator fun invoke(query: String): List<Product> = productRepository.getProducts() .filter { it.name.contains(query, ignoreCase = true) } .sortedBy { it.name } }

  1. App Bundle and Publishing

Build Configuration

// build.gradle.kts (app) android { defaultConfig { applicationId = "com.example.myapp" minSdk = 26 targetSdk = 35 versionCode = 10 versionName = "1.2.0" } buildTypes { release { isMinifyEnabled = true isShrinkResources = true proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") signingConfig = signingConfigs.getByName("release") } debug { applicationIdSuffix = ".debug" versionNameSuffix = "-DEBUG" } } bundle { language { enableSplit = true } density { enableSplit = true } abi { enableSplit = true } } }

Signing Configuration (via environment variables — never commit keystores)

signingConfigs { create("release") { storeFile = file(System.getenv("KEYSTORE_PATH") ?: "release.jks") storePassword = System.getenv("KEYSTORE_PASSWORD") keyAlias = System.getenv("KEY_ALIAS") keyPassword = System.getenv("KEY_PASSWORD") } }

ProGuard Rules

Keep data classes used for serialization

-keep class com.example.myapp.data.remote.dto.** { *; }

Keep Hilt-generated classes

-keepnames @dagger.hilt.android.lifecycle.HiltViewModel class * extends androidx.lifecycle.ViewModel

Retrofit

-keepattributes Signature, Exceptions -keep class retrofit2.** { *; }

Iron Laws

  • ALWAYS collect Flow in Compose with collectAsStateWithLifecycle() — never use collectAsState() which ignores lifecycle; collectAsStateWithLifecycle() pauses collection when the app is backgrounded, preventing resource waste.

  • NEVER expose mutable state from ViewModel — expose StateFlow /SharedFlow via asStateFlow() /asSharedFlow() ; keep MutableStateFlow /MutableSharedFlow private to prevent external mutation.

  • ALWAYS provide content descriptions for icon-only buttons — screen readers cannot convey icon meaning without contentDescription ; never pass null to icons in interactive elements.

  • NEVER use runBlocking in production code — runBlocking blocks the calling thread; use viewModelScope.launch or lifecycleScope.launch for all coroutine launches.

  • ALWAYS provide stable keys in LazyColumn /LazyRow — missing key lambda causes full list recomposition on any data change; always use key = { item.id } .

Anti-Patterns to Avoid

Anti-pattern Preferred

StateFlow in init {} without WhileSubscribed

Use SharingStarted.WhileSubscribed(5_000) to avoid upstreams when no UI is present

Calling collect in LaunchedEffect without lifecycle awareness Use collectAsStateWithLifecycle()

Passing Activity /Fragment context to ViewModel Use @ApplicationContext or SavedStateHandle

Business logic in Composables Put logic in ViewModel/UseCase

mutableListOf() as Compose state Use mutableStateListOf() or MutableStateFlow<List<T>>

Hardcoded strings in Composables Use stringResource(R.string.key)

runBlocking in production code Use coroutines properly; runBlocking blocks the thread

GlobalScope.launch

Use viewModelScope or lifecycleScope

Mutable state exposed from ViewModel Expose StateFlow /SharedFlow ; keep mutable state private

Accessibility

// Provide content descriptions for icon-only buttons IconButton(onClick = onFavorite) { Icon( imageVector = if (isFavorite) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder, contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites", ) }

// Use semantic roles for custom components Box( modifier = Modifier .semantics { role = Role.Switch stateDescription = if (isChecked) "On" else "Off" } .clickable(onClick = onToggle) )

// Merge descendants to reduce TalkBack verbosity Row(modifier = Modifier.semantics(mergeDescendants = true) {}) { Icon(Icons.Default.Star, contentDescription = null) // null = decorative Text("4.5 stars") }

Reviewing Compose State

User: "Is this pattern correct for search?"

@Composable fun SearchBar(onQueryChange: (String) -> Unit) { var query by remember { mutableStateOf("") } TextField( value = query, onValueChange = { query = it; onQueryChange(it) }, label = { Text("Search") } ) }

Review:

  • remember is appropriate for transient UI input.

  • Consider rememberSaveable if you want the query to survive configuration changes.

  • Add debounce in the ViewModel rather than calling onQueryChange on every keystroke; this avoids unnecessary searches.

  • Missing: modifier parameter for the caller to control layout.

Diagnosing Excessive Recomposition

User: "My list recomposes entirely when one item changes"

Root cause and fix:

  • Add key = { item.id } to items() in LazyColumn so Compose can track items by identity.

  • Ensure Item data class is @Stable or @Immutable with stable field types.

  • Use kotlinx.collections.immutable.ImmutableList instead of List<T> .

Assigned Agents

This skill is used by:

  • developer — Android feature implementation

  • code-reviewer — Android code review

  • architect — Android architecture decisions

  • qa — Android test strategy

Integration Points

  • Related skills: kotlin-expert , mobile-app-patterns , accessibility-tester , security-architect

  • Related rules: .claude/rules/android-expert.md

Memory Protocol (MANDATORY)

Before starting:

cat .claude/context/memory/learnings.md

Check for:

  • Previously discovered Android-specific patterns in this codebase

  • Known issues with Android tooling on this platform

  • Architecture decisions already made (ADRs)

After completing:

  • New pattern or Compose API insight → .claude/context/memory/learnings.md

  • Build/tooling issue encountered → .claude/context/memory/issues.md

  • Architecture decision made → .claude/context/memory/decisions.md

ASSUME INTERRUPTION: Your context may reset. If it's not in memory, it didn't happen.

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

Automation

filesystem

No summary provided by upstream source.

Repository SourceNeeds Review
Automation

slack-notifications

No summary provided by upstream source.

Repository SourceNeeds Review
Automation

chrome-browser

No summary provided by upstream source.

Repository SourceNeeds Review
Automation

diagram-generator

No summary provided by upstream source.

Repository SourceNeeds Review