CLAUDE.md for Android and Jetpack Compose: 13 Rules That Make AI Write Modern, Production-Ready Android Code
Android development has two eras of AI output quality.
The first era: AI that reaches for XML layouts, AsyncTask, SharedPreferences, and findViewById. Code that compiles on Android 14 but was written for Android 4. Code that passes lint but violates every modern architecture guideline.
The second era: AI that defaults to Compose, coroutines, ViewModel, StateFlow, Hilt, and the patterns from the Now in Android reference project.
The difference between these outcomes is a CLAUDE.md that specifies which Android you're building.
These 13 rules cover the patterns that matter most — the ones where AI drifts to legacy APIs without explicit instruction.
Rule 1: Jetpack Compose only — no XML layouts
UI: Jetpack Compose exclusively. No XML layouts, no View system.
Forbidden: ConstraintLayout XML, LinearLayout, RelativeLayout, findViewById.
Composables: stateless where possible. State hoisted to ViewModel.
Preview: @Preview annotation on every composable component.
Material3 only — not Material2.
AI has seen enormous volumes of XML-based Android code. Without a rule, it will generate setContentView(R.layout.activity_main) and ViewBinding for new screens in a Compose project.
// Banned — XML/View system
class UserFragment : Fragment() {
private lateinit var binding: FragmentUserBinding
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.nameText.text = viewModel.userName
}
}
// Required — Jetpack Compose
@Composable
fun UserScreen(
uiState: UserUiState,
onAction: (UserAction) -> Unit
) {
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Text(text = uiState.userName, style = MaterialTheme.typography.headlineMedium)
}
}
Rule 2: ViewModel + UiState — no logic in Composables
Architecture:
- ViewModel: holds UI state, handles user actions, calls use cases.
- UiState: sealed class or data class representing screen state.
- Composables: render UiState. No business logic. No direct repository calls.
- Events: one-time events (navigation, toasts) via Channel<UiEvent>.
- StateFlow<UiState> exposed from ViewModel. Collected with collectAsStateWithLifecycle().
AI generates Composables that call repositories directly, fetch data in LaunchedEffect without a ViewModel, and hold mutable state locally for things that should survive rotation.
// Wrong — business logic and state in Composable
@Composable
fun UserScreen(repository: UserRepository) {
var userName by remember { mutableStateOf("") }
LaunchedEffect(Unit) {
userName = repository.getUser().name // Direct repo call, no ViewModel
}
Text(text = userName)
}
// Right — ViewModel owns state
@HiltViewModel
class UserViewModel @Inject constructor(
private val getUserUseCase: GetUserUseCase
) : ViewModel() {
private val _uiState = MutableStateFlow<UserUiState>(UserUiState.Loading)
val uiState: StateFlow<UserUiState> = _uiState.asStateFlow()
init { loadUser() }
private fun loadUser() = viewModelScope.launch {
_uiState.value = getUserUseCase().fold(
onSuccess = { UserUiState.Content(it) },
onFailure = { UserUiState.Error(it.message) }
)
}
}
Rule 3: Coroutines and Flow — no callbacks, no RxJava
Async: Kotlin coroutines everywhere. No callbacks. No RxJava. No AsyncTask.
Repositories: return Flow<T> for streams, suspend fun for one-shot operations.
Dispatchers: IO for network/disk, Default for CPU-intensive. Never Main for background work.
viewModelScope: use for ViewModel coroutines. Never GlobalScope.
Cancellation: coroutines are cooperative — check cancellation in long loops.
AI generates callback-style code and sometimes RxJava (from older training data). Both are banned in modern Android.
// Banned — callback style
fun getUser(id: String, callback: (User) -> Unit) {
thread { callback(api.getUser(id)) } // No cancellation, no error handling
}
// Required — coroutines
suspend fun getUser(id: String): Result<User> = withContext(Dispatchers.IO) {
runCatching { api.getUser(id) }
}
// Repository stream
fun observeMessages(): Flow<List<Message>> = messageDao.observeAll()
.flowOn(Dispatchers.IO)
.catch { emit(emptyList()) }
Rule 4: Hilt for dependency injection — no manual DI, no Koin
DI: Hilt exclusively.
Modules: @InstallIn with appropriate component scope.
ViewModel: @HiltViewModel + @Inject constructor.
Application: @HiltAndroidApp on Application class.
No: manual object graphs, service locators, companion object getInstance().
AI generates singleton patterns and manual DI because they're simpler to write inline. Hilt makes dependencies explicit, scoped, and testable.
// Banned — manual singleton
object NetworkClient {
val api: ApiService by lazy { Retrofit.Builder()... }
}
// Required — Hilt module
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides @Singleton
fun provideApiService(client: OkHttpClient): ApiService =
Retrofit.Builder().client(client).baseUrl(BASE_URL).build()
.create(ApiService::class.java)
}
Rule 5: Repository pattern — data layer is abstract
Data layer:
- Repository interface in domain layer. Implementation in data layer.
- Repositories return domain models — never network DTOs or Room entities.
- Mapping: explicit mappers between layers (DTO → Domain, Entity → Domain).
- Local-first: Room as source of truth. Network syncs to Room. UI observes Room.
- No direct Retrofit calls from ViewModel or use cases.
// Domain layer — interface
interface UserRepository {
fun observeUser(id: String): Flow<User>
suspend fun syncUser(id: String): Result<Unit>
}
// Data layer — implementation
class UserRepositoryImpl @Inject constructor(
private val api: UserApi,
private val dao: UserDao,
private val mapper: UserMapper
) : UserRepository {
override fun observeUser(id: String): Flow<User> =
dao.observeById(id).map(mapper::toDomain)
override suspend fun syncUser(id: String): Result<Unit> = runCatching {
val dto = api.getUser(id)
dao.upsert(mapper.toEntity(dto))
}
}
Rule 6: Room for local persistence — no SharedPreferences for structured data
Local data:
- Room for all structured data. No raw SQLite.
- SharedPreferences / DataStore: only for simple key-value preferences (theme, onboarding flag).
- DataStore (Proto or Preferences): replace SharedPreferences for new code.
- Room DAOs: return Flow<T> for observed queries, suspend fun for mutations.
- Migrations: explicit RoomDatabase.Migration — never destructive migration in production.
@Dao
interface UserDao {
@Query("SELECT * FROM users WHERE id = :id")
fun observeById(id: String): Flow<UserEntity?>
@Upsert
suspend fun upsert(user: UserEntity)
@Query("DELETE FROM users WHERE id = :id")
suspend fun deleteById(id: String)
}
Rule 7: Navigation — Jetpack Navigation Compose with typed routes
Navigation:
- Jetpack Navigation Compose with type-safe routes (Navigation 2.8+).
- Routes: sealed class or @Serializable data class — no string literals.
- Single NavHost per feature graph. Nested graphs for feature modules.
- Navigate from ViewModel via UiEvent channel — not directly from Composable.
- Deep links: declared in NavGraph, not hardcoded in AndroidManifest.
Rule 8: State management — immutable UiState, unidirectional data flow
State rules:
- UiState: immutable data class. Use copy() for updates.
- MutableStateFlow inside ViewModel, exposed as StateFlow (read-only).
- No mutableStateOf in ViewModel — use MutableStateFlow.
- Composables: collect with collectAsStateWithLifecycle() (lifecycle-aware).
- No shared mutable state between ViewModels.
data class SearchUiState(
val query: String = "",
val results: List<SearchResult> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null
)
// In ViewModel
_uiState.update { it.copy(isLoading = true) }
Rule 9: Testing — ViewModel unit tests, Composable screenshot tests
Testing:
- ViewModel: JUnit 5 + MockK + Turbine (for Flow testing). No Android dependencies.
- Repository: JUnit 5 + MockK for unit tests. Room in-memory DB for integration.
- Composables: Compose UI test (composeTestRule) or Paparazzi for screenshot tests.
- No Robolectric for new tests — prefer pure JVM or instrumented tests.
- coroutinesTestRule: StandardTestDispatcher for deterministic coroutine tests.
@Test
fun `search query updates state`() = runTest {
val viewModel = SearchViewModel(mockRepository)
viewModel.uiState.test {
assertThat(awaitItem().query).isEmpty()
viewModel.onQueryChanged("kotlin")
assertThat(awaitItem().query).isEqualTo("kotlin")
}
}
Rule 10: Permissions — request at point of use, handle denial gracefully
Permissions:
- Request via rememberLauncherForActivityResult — never startActivityForResult.
- Check permission before use: ContextCompat.checkSelfPermission.
- Handle denial: show rationale UI, offer settings redirect after permanent denial.
- Never crash or silently fail on permission denial.
- Dangerous permissions: request only when the user triggers the action.
Rule 11: API communication — Retrofit + OkHttp + kotlinx.serialization
Network:
- Retrofit for REST. OkHttp with logging interceptor (debug builds only).
- Serialization: kotlinx.serialization. No Gson, no Moshi.
- Response wrapper: Result<T> or sealed class — never nullable responses.
- Timeouts: explicit connect (10s), read (30s), write (30s) timeouts.
- Certificate pinning for production builds.
Rule 12: Build config — Gradle Kotlin DSL, version catalog
Build:
- Gradle Kotlin DSL (.kts) only — no Groovy.
- Version catalog (libs.versions.toml) for all dependencies.
- Build variants: debug (logging, mock data) / release (ProGuard, no logging).
- No hardcoded API keys or secrets in BuildConfig — use local.properties + secrets-gradle-plugin.
- Baseline profiles for startup performance.
Rule 13: The CLAUDE.md block for Android
## Android Standards
**Min SDK:** 26 | **Target SDK:** 35 | **Language:** Kotlin 2.x
**UI:** Jetpack Compose + Material3 only (no XML layouts)
**Architecture:** MVVM + Clean Architecture (presentation / domain / data)
### UI Layer
- Composables: stateless. State hoisted to ViewModel.
- UiState: immutable data class, exposed as StateFlow<UiState>
- Collect with collectAsStateWithLifecycle() — not collectAsState()
- One-time events: Channel<UiEvent> in ViewModel
### Data Layer
- Repository pattern: interface in domain, impl in data
- Room as source of truth. Network syncs to Room.
- Coroutines: suspend fun for one-shot, Flow for streams
- No callbacks, no RxJava, no AsyncTask
### DI
- Hilt exclusively. @HiltViewModel on all ViewModels.
- No manual DI, no Koin, no service locators.
### Testing
- ViewModel: JUnit 5 + MockK + Turbine
- No Robolectric. Pure JVM unit tests preferred.
### Build
- Gradle Kotlin DSL + version catalog (libs.versions.toml)
- No secrets in source code or BuildConfig
Why Android specifically needs this
Android's API surface has been evolving continuously for 15 years. AI models have absorbed code from every era — from AsyncTask through RxJava to coroutines, from XML inflation through DataBinding to Compose.
Without explicit rules, the model defaults to the mean of that training distribution. That mean includes a lot of code that works on current Android but violates current best practices, creates maintenance debt, and will break when APIs are deprecated.
The rules above aren't a style guide. They're the line between code that a senior Android engineer would merge and code that gets sent back for a full rewrite.
The CLAUDE.md block at the end is what you add to your repo. The AI reads it at the start of every session, and every Composable, ViewModel, and repository it generates starts from those constraints rather than from the average of all Android code ever written.
Top comments (0)