.claude/skills/android-expert/SKILL.md
Comprehensive Android development expert covering Jetpack Compose, Kotlin coroutines/Flow, Architecture Components, Hilt DI, Navigation, testing, performance, Material Design 3, and modern Android patterns (MVI, Clean Architecture).
npx skillsauth add oimiragieo/agent-studio android-expertInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
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 }
}
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 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:
String are always stable.@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)
// 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
)
}
}
}
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.
// 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,
)
}
}
}
// 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 updatesDispatchers.IO: Network calls, file/database I/ODispatchers.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) }
}
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<List<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)
}
}
}
@HiltViewModel
class ProductDetailViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val getProductUseCase: GetProductUseCase,
) : ViewModel() {
private val productId: String = checkNotNull(savedStateHandle["productId"])
private val _uiState = MutableStateFlow<ProductDetailUiState>(ProductDetailUiState.Loading)
val uiState: StateFlow<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
}
@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 ''")
}
}
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 < 3) Result.retry() else Result.failure()
}
@AssistedFactory
interface Factory : ChildWorkerFactory<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,
)
@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
}
| Scope | Component | Lifetime |
| ------------------------- | --------------------------- | -------------------- |
| @Singleton | SingletonComponent | Application lifetime |
| @ActivityRetainedScoped | ActivityRetainedComponent | ViewModel lifetime |
| @ActivityScoped | ActivityComponent | Activity lifetime |
| @ViewModelScoped | ViewModelComponent | ViewModel lifetime |
| @FragmentScoped | FragmentComponent | Fragment lifetime |
@HiltWorker
class UploadWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted params: WorkerParameters,
private val uploadService: UploadService,
) : CoroutineWorker(context, params) { /* ... */ }
// 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() }
}
}
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>
@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))
}
}
@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<NotFoundException> { useCase("missing") }
}
}
@OptIn(ExperimentalCoroutinesApi::class)
class ProductDetailViewModelTest {
@get:Rule val mainDispatcherRule = MainDispatcherRule()
private val repository = mockk<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<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()
}
}
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("[email protected]")
composeTestRule.onNodeWithText("Password").performTextInput("wrongpass")
composeTestRule.onNodeWithText("Log in").performClick()
composeTestRule
.onNodeWithText("Invalid credentials")
.assertIsDisplayed()
}
}
@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<RecyclerView.ViewHolder>(0, click()))
onView(withId(R.id.product_title)).check(matches(isDisplayed()))
}
}
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class NotificationHelperTest {
@Test
fun `creates notification with correct channel`() {
val context = ApplicationProvider.getApplicationContext<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)
}
}
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()
}
}
// 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"
)
}
Bitmap leaks, Context leaks in static fields, and unclosed Cursor objects.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
) { /* ... */ }
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)
}
}
// 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,
)
}
@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)
}
// 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")
}
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<ProductListUiState> = _uiState.asStateFlow()
private val _effect = MutableSharedFlow<ProductListEffect>()
val effect: SharedFlow<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)
}
}
}
}
}
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 }
}
// 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 }
}
}
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")
}
}
# 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.** { *; }
collectAsStateWithLifecycle() — never use collectAsState() which ignores lifecycle; collectAsStateWithLifecycle() pauses collection when the app is backgrounded, preventing resource waste.StateFlow/SharedFlow via asStateFlow()/asSharedFlow(); keep MutableStateFlow/MutableSharedFlow private to prevent external mutation.contentDescription; never pass null to icons in interactive elements.runBlocking in production code — runBlocking blocks the calling thread; use viewModelScope.launch or lifecycleScope.launch for all coroutine launches.LazyColumn/LazyRow — missing key lambda causes full list recomposition on any data change; always use key = { item.id }.| 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 |
// 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")
}
</instructions>
<examples>
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.rememberSaveable if you want the query to survive configuration changes.debounce in the ViewModel rather than calling onQueryChange on every keystroke; this avoids unnecessary searches.modifier parameter for the caller to control layout.User: "My list recomposes entirely when one item changes"
Root cause and fix:
key = { item.id } to items() in LazyColumn so Compose can track items by identity.Item data class is @Stable or @Immutable with stable field types.kotlinx.collections.immutable.ImmutableList instead of List<T>.This skill is used by:
developer — Android feature implementationcode-reviewer — Android code reviewarchitect — Android architecture decisionsqa — Android test strategykotlin-expert, mobile-app-patterns, accessibility-tester, security-architect.claude/rules/android-expert.mdBefore starting:
cat .claude/context/memory/learnings.md
Check for:
After completing:
.claude/context/memory/learnings.md.claude/context/memory/issues.md.claude/context/memory/decisions.mdASSUME INTERRUPTION: Your context may reset. If it's not in memory, it didn't happen.
tools
Comprehensive biosignal processing toolkit for analyzing physiological data including ECG, EEG, EDA, RSP, PPG, EMG, and EOG signals. Use this skill when processing cardiovascular signals, brain activity, electrodermal responses, respiratory patterns, muscle activity, or eye movements. Applicable for heart rate variability analysis, event-related potentials, complexity measures, autonomic nervous system assessment, psychophysiology research, and multi-modal physiological signal integration.
tools
Comprehensive toolkit for creating, analyzing, and visualizing complex networks and graphs in Python. Use when working with network/graph data structures, analyzing relationships between entities, computing graph algorithms (shortest paths, centrality, clustering), detecting communities, generating synthetic networks, or visualizing network topologies. Applicable to social networks, biological networks, transportation systems, citation networks, and any domain involving pairwise relationships.
data-ai
Molecular featurization for ML (100+ featurizers). ECFP, MACCS, descriptors, pretrained models (ChemBERTa), convert SMILES to features, for QSAR and molecular ML.
development
Run Python code in the cloud with serverless containers, GPUs, and autoscaling. Use when deploying ML models, running batch processing jobs, scheduling compute-intensive tasks, or serving APIs that require GPU acceleration or dynamic scaling.