01 — Architecture
THE LAYER CONTRACT
Google's Guide to App Architecture mandates three layers. Dependencies flow inward only. The domain layer has zero Android SDK imports.
02 — Project Structure
FEATURE-FIRST MODULES
Organised by feature, not by layer. Each feature owns its slice of every layer. Scales to multi-module Gradle projects cleanly.
03 — Code Patterns
KOTLIN BLUEPRINTS
Production-ready Kotlin. Every snippet below compiles and integrates with the architecture above.
sealed interface AuthUiState { data object Idle : AuthUiState data object Loading : AuthUiState data class Success( val user: User, // signals data came from local cache val isFromCache: Boolean = false, ) : AuthUiState data class Error( val message: String, ) : AuthUiState }
@HiltViewModel class AuthViewModel @Inject constructor( private val loginUseCase: LoginUseCase, private val logoutUseCase: LogoutUseCase, ) : ViewModel() { private val _uiState = MutableStateFlow<AuthUiState>(AuthUiState.Idle) val uiState: StateFlow<AuthUiState> = _uiState.asStateFlow() fun login(email: String, password: String) { viewModelScope.launch { _uiState.value = AuthUiState.Loading loginUseCase(email, password) .onSuccess { user -> _uiState.value = AuthUiState.Success(user) } .onFailure { e -> _uiState.value = AuthUiState.Error( e.message ?: "Unknown error") } } } fun logout() { viewModelScope.launch { logoutUseCase() } } }
@HiltViewModel class SearchViewModel @Inject constructor( private val searchUseCase: SearchProductsUseCase, ) : ViewModel() { private val _query = MutableStateFlow("") val results = _query .debounce(350L) // kotlinx.coroutines .distinctUntilChanged() .filter { it.length >= 2 } .flatMapLatest { q -> // cancels prev search searchUseCase(q) } .stateIn( viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList(), ) fun onQueryChange(q: String) { _query.value = q } }
// Pure Kotlin — zero Android imports data class User( val id: String, val email: String, val name: String, val avatarUrl: String?, )
// Defined in domain, implemented in data interface AuthRepository { suspend fun login( email: String, password: String, ): Result<User> suspend fun logout(): Result<Unit> suspend fun getCurrentUser(): User? // Flow for reactive cache observation fun observeCurrentUser(): Flow<User?> }
// operator fun invoke = callable like a function class LoginUseCase @Inject constructor( private val authRepository: AuthRepository, ) { suspend operator fun invoke( email: String, password: String, ): Result<User> = authRepository.login(email, password) } // KISS — one class, one responsibility. // ViewModel calls it like a lambda: // loginUseCase(email, password)
class AuthRepositoryImpl @Inject constructor( private val api: AuthApiService, private val userDao: UserDao, private val mapper: UserMapper, ) : AuthRepository { override suspend fun login( email: String, password: String, ): Result<User> = runCatching { val dto = api.login(LoginRequest(email, password)) val entity = mapper.dtoToEntity(dto) userDao.upsert(entity) // write-through cache mapper.entityToDomain(entity) } // reactive — emits cached then live override fun observeCurrentUser(): Flow<User?> = userDao.observeCurrentUser() .map { entity -> entity?.let(mapper::entityToDomain) } }
// Generic offline-first Flow builder inline fun <Local, Remote, Domain> networkBoundResource( crossinline query: () -> Flow<Local>, crossinline fetch: suspend () -> Remote, crossinline saveFetch: suspend (Remote) -> Unit, crossinline toDomain: (Local) -> Domain, crossinline shouldFetch: (Local) -> Boolean, ) = flow { val local = query().first() emit(Resource.Loading(toDomain(local))) if (shouldFetch(local)) { try { saveFetch(fetch()) } catch (t: Throwable) { emit(Resource.Error(t, toDomain(local))) } } query().map { Resource.Success(toDomain(it)) } .collect { emit(it) } }
@Dao interface UserDao { @Query("SELECT * FROM users WHERE id = :id") fun observeUser(id: String): Flow<UserEntity?> @Query("SELECT * FROM users LIMIT 1") fun observeCurrentUser(): Flow<UserEntity?> @Upsert suspend fun upsert(user: UserEntity) @Query("DELETE FROM users") suspend fun clearAll() } @Entity(tableName = "users") data class UserEntity( @PrimaryKey val id: String, val email: String, val name: String, val avatarUrl: String?, val cachedAt: Long, // for TTL checks )
class UserMapper @Inject constructor() { fun dtoToEntity(dto: UserDto): UserEntity = UserEntity( id = dto.id, email = dto.email, name = dto.name, avatarUrl = dto.avatarUrl, cachedAt = System.currentTimeMillis(), ) fun entityToDomain(e: UserEntity): User = User( id = e.id, email = e.email, name = e.name, avatarUrl = e.avatarUrl, ) fun dtoToDomain(dto: UserDto): User = entityToDomain(dtoToEntity(dto)) }
interface AuthApiService { @POST("/auth/login") suspend fun login( @Body request: LoginRequest, ): UserDto @POST("/auth/refresh") suspend fun refreshToken( @Body body: RefreshRequest, ): TokenDto @GET("/products") suspend fun getProducts( @Query("q") query: String? = null, @Query("page") page: Int = 1, ): List<ProductDto> }
class AuthInterceptor @Inject constructor( private val tokenDataStore: TokenDataStore, private val refreshApi: AuthApiService, ) : Interceptor { override fun intercept(chain: Chain): Response { val token = runBlocking { tokenDataStore.getAccessToken() } val req = chain.request().newBuilder() .apply { token?.let { header("Authorization", "Bearer $it") } } .build() val res = chain.proceed(req) if (res.code == 401) { res.close() val newToken = runBlocking { refreshToken() } if (newToken != null) { return chain.proceed( req.newBuilder() .header("Authorization", "Bearer $newToken") .build() ) } } return res } }
class EncryptInterceptor @Inject constructor( private val crypto: CryptoManager, ) : Interceptor { override fun intercept(chain: Chain): Response { val req = chain.request() val encryptedReq = req.body?.let { body -> val iv = crypto.generateIv() val plain = body.readUtf8Bytes() val cipher = crypto.encrypt(plain, iv) req.newBuilder() .post(EncryptedBody(iv, cipher).toRequestBody()) .build() } ?: req val res = chain.proceed(encryptedReq) return if (res.isSuccessful) { val decrypted = crypto.decrypt(res) res.newBuilder().body(decrypted.toResponseBody()).build() } else res } }
class TokenDataStore @Inject constructor( @ApplicationContext context: Context, ) { private val prefs = EncryptedSharedPreferences.create( context, "secure_prefs", MasterKey.Builder(context) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .build(), EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, ) suspend fun getAccessToken(): String? = prefs.getString("access_token", null) suspend fun saveTokens(access: String, refresh: String) { prefs.edit { putString("access_token", access) putString("refresh_token", refresh) } } suspend fun clearAll() = prefs.edit { clear() } }
@Composable fun LoginScreen( viewModel: AuthViewModel = hiltViewModel(), onSuccess: () -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val snackbar = remember { SnackbarHostState() } LaunchedEffect(uiState) { when (val s = uiState) { is AuthUiState.Success -> onSuccess() is AuthUiState.Error -> snackbar.showSnackbar(s.message) else -> {} } } Scaffold(snackbarHost = { SnackbarHost(snackbar) }) { pad -> Box(modifier = Modifier.padding(pad)) { LoginForm( isLoading = uiState is AuthUiState.Loading, onLogin = viewModel::login, ) } } }
@Composable fun AppNavGraph(navController: NavHostController) { NavHost( navController = navController, startDestination = "login", ) { composable("login") { LoginScreen( onSuccess = { navController.navigate("home") { popUpTo("login") { inclusive = true } }} ) } composable( route = "product/{id}", arguments = listOf(navArgument("id") { type = NavType.StringType }), // shared element transition destination enterTransition = { fadeIn(tween(300)) + slideIntoContainer( towards = SlideDirection.Start, animationSpec = tween(300, easing = EaseOutCubic), ) }, exitTransition = { fadeOut(tween(200)) + slideOutOfContainer( towards = SlideDirection.End, animationSpec = tween(200), ) }, ) { backStack -> val id = backStack.arguments?.getString("id")!! ProductDetailScreen(productId = id) } } }
class SttManager @Inject constructor( @ApplicationContext private val ctx: Context, ) { private var recognizer: SpeechRecognizer? = null private val _results = MutableSharedFlow<SttResult>(extraBufferCapacity = 10) val results: SharedFlow<SttResult> = _results fun startListening(locale: Locale = Locale.ENGLISH) { recognizer = SpeechRecognizer.createSpeechRecognizer(ctx) recognizer?.setRecognitionListener(object : RecognitionListener { override fun onResults(bundle: Bundle) { val words = bundle.getStringArrayList( SpeechRecognizer.RESULTS_RECOGNITION)?.firstOrNull() words?.let { _results.tryEmit(SttResult(it, isFinal = true)) } } override fun onPartialResults(b: Bundle) { b.getStringArrayList("android.speech.extra.UNSTABLE_TEXT") ?.firstOrNull() ?.let { _results.tryEmit(SttResult(it, isFinal = false)) } } override fun onError(error: Int) {} override fun onReadyForSpeech(p: Bundle) {} override fun onBeginningOfSpeech() {} override fun onRmsChanged(v: Float) {} override fun onBufferReceived(b: ByteArray) {} override fun onEndOfSpeech() {} override fun onEvent(t: Int, p: Bundle) {} }) recognizer?.startListening( Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) putExtra(RecognizerIntent.EXTRA_LANGUAGE, locale) putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true) } ) } fun stop() { recognizer?.stopListening() } fun destroy() { recognizer?.destroy(); recognizer = null } }
class TtsManager @Inject constructor( @ApplicationContext private val ctx: Context, ) { private var tts: TextToSpeech? = null private val _ready = MutableStateFlow(false) fun init() { tts = TextToSpeech(ctx) { status -> if (status == TextToSpeech.SUCCESS) { tts?.language = Locale.ENGLISH tts?.setSpeechRate(0.95f) _ready.value = true } } } suspend fun speak(text: String) { _ready.first { it } // await init tts?.speak(text, TextToSpeech.QUEUE_FLUSH, null, null) } fun stop() { tts?.stop() } fun destroy() { tts?.shutdown(); tts = null } }
class WebSocketManager @Inject constructor( private val okHttpClient: OkHttpClient, ) { private var ws: WebSocket? = null private val _messages = MutableSharedFlow<String>(extraBufferCapacity = 64) val messages: SharedFlow<String> = _messages private var retryCount = 0 private val maxRetries = 5 fun connect(url: String, token: String) { val req = Request.Builder() .url("$url?token=$token") .build() ws = okHttpClient.newWebSocket(req, object : WebSocketListener() { override fun onMessage(ws: WebSocket, text: String) { retryCount = 0 _messages.tryEmit(text) } override fun onFailure(ws: WebSocket, t: Throwable, r: Response?) { scheduleReconnect(url, token) } override fun onClosed(ws: WebSocket, code: Int, reason: String) { if (code != 1000) scheduleReconnect(url, token) } }) } private fun scheduleReconnect(url: String, token: String) { if (retryCount >= maxRetries) return val delay = (2.0.pow(retryCount) * 1000).toLong() retryCount++ GlobalScope.launch { delay(delay) connect(url, token) } } fun send(json: String) { ws?.send(json) } fun disconnect() { ws?.close(1000, "App closed"); ws = null } }
@Composable fun AnimatedProductList(products: List<Product>) { LazyColumn { itemsIndexed(products) { index, product -> var visible by remember { mutableStateOf(false) } LaunchedEffect(Unit) { delay(index * 60L) // stagger per item visible = true } AnimatedVisibility( visible = visible, enter = fadeIn(tween(350)) + slideInVertically( initialOffsetY = { it / 5 }, animationSpec = tween(350, easing = EaseOutCubic), ), ) { ProductCard(product = product) } } } }
// Requires Compose 1.7+ / BOM 2024.09+ // SharedTransitionLayout wraps NavHost @Composable fun AppNavGraph() { SharedTransitionLayout { NavHost(...) { composable("products") { ProductListScreen( sharedTransitionScope = this@SharedTransitionLayout, animatedVisibilityScope = this@composable, ) } composable("product/{id}") { ProductDetailScreen( sharedTransitionScope = this@SharedTransitionLayout, animatedVisibilityScope = this@composable, ) } } } } // In list item — mark the shared element Image( painter = ..., modifier = Modifier.sharedElement( state = rememberSharedContentState( key = "product-image-${product.id}"), animatedVisibilityScope = animatedVisibilityScope, boundsTransform = { _, _ -> tween(400, easing = EaseInOutCubic) }, ), ) // Same key on detail screen — framework // handles the morph automatically
@Composable fun SearchResultsArea(uiState: SearchUiState) { AnimatedContent( targetState = uiState, transitionSpec = { (fadeIn(tween(220)) + slideInVertically { it / 10 }) .togetherWith( fadeOut(tween(180)) + slideOutVertically { -it / 10 } ) }, label = "search-results", ) { state -> when (state) { is SearchUiState.Loading -> ShimmerList() is SearchUiState.Loaded -> ResultList(state.items) is SearchUiState.Empty -> EmptyState() is SearchUiState.Error -> ErrorView(state.msg) } } }
@Composable fun LottieView( assetRes: Int, modifier: Modifier = Modifier, iterations: Int = LottieConstants.IterateForever, ) { val composition by rememberLottieComposition( LottieCompositionSpec.RawRes(assetRes) ) val progress by animateLottieCompositionAsState( composition = composition, iterations = iterations, isPlaying = true, restartOnPlay = false, ) LottieAnimation( composition = composition, progress = { progress }, modifier = modifier, ) } // Usage: LottieView(assetRes = R.raw.success_check, modifier = Modifier.size(120.dp)) LottieView(assetRes = R.raw.empty_state, iterations = LottieConstants.IterateForever)
@Module @InstallIn(SingletonComponent::class) object NetworkModule { @Provides @Singleton fun provideOkHttp( authInterceptor: AuthInterceptor, encryptInterceptor: EncryptInterceptor, ): OkHttpClient = OkHttpClient.Builder() .addInterceptor(authInterceptor) .addInterceptor(encryptInterceptor) .addInterceptor(HttpLoggingInterceptor().apply { level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.NONE }) .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .build() @Provides @Singleton fun provideRetrofit(okHttp: OkHttpClient): Retrofit = Retrofit.Builder() .baseUrl(BuildConfig.API_URL) .client(okHttp) .addConverterFactory(GsonConverterFactory.create()) .build() @Provides @Singleton fun provideAuthApi(retrofit: Retrofit): AuthApiService = retrofit.create(AuthApiService::class.java) }
@Module @InstallIn(SingletonComponent::class) object DatabaseModule { @Provides @Singleton fun provideDatabase( @ApplicationContext ctx: Context, ): AppDatabase = Room.databaseBuilder( ctx, AppDatabase::class.java, "app.db" ).fallbackToDestructiveMigration().build() @Provides fun provideUserDao(db: AppDatabase) = db.userDao() } @Module @InstallIn(SingletonComponent::class) abstract class RepositoryModule { // Hilt binds interface → impl automatically @Binds @Singleton abstract fun bindAuthRepository( impl: AuthRepositoryImpl, ): AuthRepository @Binds @Singleton abstract fun bindProductRepository( impl: ProductRepositoryImpl, ): ProductRepository }
@HiltAndroidApp class App : Application() @AndroidEntryPoint class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { AppTheme { val navController = rememberNavController() AppNavGraph(navController) } } } } // In ViewModel — injected automatically by Hilt @HiltViewModel class SomeViewModel @Inject constructor( private val useCase: SomeUseCase, ) : ViewModel() { ... } // In Composable — zero manual instantiation val vm: SomeViewModel = hiltViewModel()
04 — Data Flows
EVERY PATH TRACED
How data moves through each Android layer for every scenario.
Happy path — UI to API
Response → UI + Room cache
Offline path — network unavailable
Search debounce with flatMapLatest
Real-time WebSocket → Compose
401 token refresh
Room reactive — Flow observation
05 — Feature Matrix
EVERY REQUIREMENT COVERED
All features from the original brief — mapped to Android's idiomatic solution.
android.speech.SpeechRecognizer
android.speech.tts.TextToSpeech
android.security.keystore
javax.crypto (built-in)
kotlinx.coroutines.flow — built-in
retrofit2 suspend support
room-runtime:2.7.x
room-ktx:2.7.x
security-crypto:1.1.x
datastore-preferences:1.1.x
compose.animation — built-in
lottie-compose:6.x
compose.animation:1.7.x
okhttp:4.12.x WebSocket
compose.material3 — built-in
hilt-android:2.52
hilt-navigation-compose
06 — Gradle Dependencies
THE GRADLE REGISTRY
Stable, production-proven. Using Gradle version catalog (libs.versions.toml) format.
gradle/libs.versions.toml and reference as libs.hilt.android etc. — the modern Gradle approach.
| Artifact | Version | Purpose | Layer |
|---|---|---|---|
androidx.compose.bom | 2025.05.x | Compose BOM — pins all compose-* versions consistently | ui |
compose.material3 | BOM | Material 3 components, theming, ColorScheme | ui |
compose.animation | BOM 1.7+ | AnimatedVisibility, AnimatedContent, SharedTransitionLayout | ui |
compose.ui | BOM | Core Compose runtime, layout, drawing | ui |
navigation-compose | 2.8.x | NavHost, composable routes, type-safe navigation | ui |
hilt-navigation-compose | 1.2.x | hiltViewModel() in composables, nav backstack scoping | ui |
lottie-compose | 6.x | Lottie JSON animations with Compose APIs | ui |
coil-compose | 2.7.x | Async image loading with Compose — AsyncImage composable | ui |
lifecycle-viewmodel-compose | 2.8.x | viewModel(), collectAsStateWithLifecycle() | vm |
lifecycle-runtime-compose | 2.8.x | collectAsStateWithLifecycle — lifecycle-aware collection | vm |
kotlinx.coroutines.android | 1.9.x | viewModelScope, Dispatchers, Flow, debounce, flatMapLatest | vm |
hilt-android | 2.52 | Hilt DI — @HiltAndroidApp, @HiltViewModel, @Module | di |
hilt-android-compiler | 2.52 | KSP annotation processor for Hilt code generation | di |
retrofit2 | 2.11.x | Type-safe HTTP client with suspend function support | data |
retrofit2-converter-gson | 2.11.x | JSON serialization — or swap for moshi-converter | data |
okhttp3 | 4.12.x | HTTP + WebSocket client, interceptor chain | data |
okhttp3-logging-interceptor | 4.12.x | HTTP body logging — debug builds only | data |
room-runtime | 2.7.x | SQLite ORM — @Entity, @Dao, @Database, Flow queries | data |
room-ktx | 2.7.x | Coroutines and Flow extensions for Room | data |
room-compiler | 2.7.x | KSP processor for Room code generation | data |
datastore-preferences | 1.1.x | Async, coroutine-based key-value storage — SharedPreferences replacement | data |
security-crypto | 1.1.x | EncryptedSharedPreferences + MasterKey — Keystore-backed | core |
kotlinx.serialization.json | 1.7.x | Kotlin-native JSON — alternative to Gson, better performance | data |
hilt-android-testing | 2.52 | Hilt test utilities, @HiltAndroidTest | test |
kotlinx.coroutines.test | 1.9.x | TestCoroutineDispatcher, runTest, turbine-compatible | test |
turbine | 1.2.x | Flow testing — awaitItem(), assertValues() | test |
mockk | 1.14.x | Kotlin-native mocking — coEvery, coVerify, relaxed mocks | test |
✦ — AI Scaffold
Generate with AI
Paste into Claude, ChatGPT, Gemini, or any AI agent to scaffold this exact architecture from scratch.
You are a senior Android architect. Scaffold a production-ready Android app using Clean Architecture. Stack: - Language: Kotlin - UI: Jetpack Compose with Material3 - Architecture: MVVM + MVI (UiState, UiEvent, UiEffect) - DI: Hilt - Async: Kotlin Coroutines + Flow - Navigation: Compose Navigation with type-safe args - Network: Retrofit + OkHttp + kotlinx.serialization - Local DB: Room with TypeConverters - Image: Coil - Testing: JUnit5, MockK, Turbine, Compose UI tests Provide: 1. Multi-module Gradle project structure (app, core, feature:*) 2. Clean Architecture layers per feature (data/domain/presentation) 3. BaseViewModel with UiState/UiEffect pattern 4. Hilt module setup (NetworkModule, DatabaseModule, RepositoryModule) 5. Repository pattern with Resultwrapper 6. Navigation graph with sealed class routes 7. Unit test for a ViewModel and Repository 8. Gradle version catalog (libs.versions.toml)