Google's recommended architecture · Kotlin · Jetpack

Android Clean Architecture Field Guide

MVVM + Clean Architecture with Kotlin Coroutines, Jetpack Compose, Hilt, Room, Retrofit — the complete production blueprint following Google's official guidance.

UI Layer Compose screens · ViewModels · UI state · Navigation Stateful
↓ observes state from ↓
ViewModel StateFlow · business logic bridge · lifecycle-aware MVVM
↓ calls ↓
Domain Layer Use cases · Entities · Repository interfaces · Pure Kotlin No Android
↓ implemented by ↓
Data Layer Repositories · Retrofit · Room · DataStore · Mappers I/O

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.

The rule: UI → Domain ← Data. Nothing in Domain knows about Retrofit or Room. Nothing in UI knows about Room. Hilt wires everything together at compile time — zero manual instantiation.
UI
UI Layer
Jetpack Compose screens observe ViewModel StateFlow. UI is a pure function of state — no imperative logic. Navigation via Navigation Compose. Shared element transitions with Compose animation APIs.
VM
ViewModel
Survives configuration changes. Holds and exposes UI state as StateFlow. Calls use cases on viewModelScope. Transforms domain results into UI-friendly models. Never holds Context.
D
Domain Layer
Pure Kotlin — no Android imports. Use cases (interactors) each do one thing. Repository interfaces defined here. Entities are simple data classes. Result<T> or sealed class for errors.
Da
Data Layer
Implements repository interfaces. Remote: Retrofit + OkHttp interceptors. Local: Room database + DataStore Preferences. Mappers convert DTOs → domain entities. Offline-first with NetworkBoundResource pattern.

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.

app / src / main / java / com.app /
core/
di/Hilt
AppModule.kt— @Module @InstallIn(SingletonComponent)
NetworkModule.kt— OkHttp, Retrofit, interceptors
DatabaseModule.kt— Room, DataStore
network/
AuthInterceptor.kt— attach + refresh Bearer
EncryptInterceptor.kt— AES-256 body encryption
NetworkResult.kt— sealed Result wrapper
NetworkBoundResource.kt— offline-first Flow emitter
storage/
SecurePreferences.kt— EncryptedSharedPreferences
TokenDataStore.kt— DataStore<Preferences>
speech/
SttManager.kt— SpeechRecognizer wrapper
TtsManager.kt— TextToSpeech wrapper
realtime/
WebSocketManager.kt— OkHttp WS + reconnect
ui/
theme/AppTheme.kt— Material3 color + typography
components/— reusable composables
features/
auth/— repeat per feature
domain/Domain
model/User.kt— pure data class entity
repository/AuthRepository.kt— interface
usecase/LoginUseCase.kt
usecase/LogoutUseCase.kt
data/Data
remote/AuthApiService.kt— @GET/@POST Retrofit interface
remote/dto/UserDto.kt— @SerializedName JSON DTO
local/UserDao.kt— Room @Dao
local/entity/UserEntity.kt— @Entity table
mapper/UserMapper.kt— DTO/Entity ↔ Domain
repository/AuthRepositoryImpl.kt
presentation/UI
AuthViewModel.kt
LoginScreen.kt— @Composable
AuthUiState.kt— sealed UI state
app/
App.kt— @HiltAndroidApp Application class
MainActivity.kt— @AndroidEntryPoint, setContent {}
NavGraph.kt— NavHost + composable routes

03 — Code Patterns

KOTLIN BLUEPRINTS

Production-ready Kotlin. Every snippet below compiles and integrates with the architecture above.

AuthUiState.kt — sealed UI statepres
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
}
AuthViewModel.ktpres
@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() }
  }
}
SearchViewModel.kt — debouncepres
@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 }
}
flatMapLatest replaces the debouncer + CancelToken from the Flutter guide. It automatically cancels the previous coroutine the instant a new query arrives — zero boilerplate, idiomatic Kotlin.
User.kt — domain entitydomain
// Pure Kotlin — zero Android imports
data class User(
  val id:        String,
  val email:     String,
  val name:      String,
  val avatarUrl: String?,
)
AuthRepository.kt — interfacedomain
// 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?>
}
LoginUseCase.kt — SOLID SRPdomain
// 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)
AuthRepositoryImpl.kt — offline firstdata
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) }
}
NetworkBoundResource.kt — offline patterndata · core
// 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) }
  }
UserDao.kt — Roomdata
@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
)
UserMapper.ktdata
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))
}
AuthApiService.kt — Retrofitdata · remote
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>
}
AuthInterceptor.kt — token + refreshcore · network
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
  }
}
EncryptInterceptor.kt — AES-256core · network
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
  }
}
TokenDataStore.kt — DataStorecore · storage
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() }
}
LoginScreen.kt — Compose + ViewModelpres · ui
@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,
      )
    }
  }
}
NavGraph.kt — Navigation Composepres · navigation
@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)
    }
  }
}
SttManager.kt — SpeechRecognizercore · speech
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 }
}
TtsManager.kt — TextToSpeechcore · speech
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 }
}
WebSocketManager.kt — OkHttp WScore · realtime
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 }
}
Staggered list — AnimatedVisibilitypres · ui
@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)
      }
    }
  }
}
Shared element transitions — Composepres · ui
// 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
AnimatedContent — state transitionspres · ui
@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)
    }
  }
}
Lottie in Composepres · ui
@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)
NetworkModule.kt — @Modulecore · di
@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)
}
DatabaseModule.kt + RepositoryModule.ktcore · di
@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
}
App.kt + MainActivity.kt — entry pointsapp
@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

Composable
ViewModel.fn()
UseCase.invoke()
Repository impl
Retrofit suspend
API + decrypt

Response → UI + Room cache

DTO received
Mapper.toDomain()
Room.upsert()
Result.success()
_uiState.value = Success
collectAsState()

Offline path — network unavailable

IOException thrown
runCatching.onFailure
Room.query() cached
Result.success(cached)
Success(isFromCache=true)

Search debounce with flatMapLatest

TextField
_query.value = q
debounce(350ms)
flatMapLatest { }
Retrofit (prev cancelled)

Real-time WebSocket → Compose

WS.onMessage
_messages.tryEmit()
messages.collect {}
_uiState.update {}
recomposition

401 token refresh

OkHttp intercept
401 detected
POST /auth/refresh
EncryptedSharedPrefs.save()
retry original

Room reactive — Flow observation

Room @Query Flow<T>
Repository.observe()
.stateIn(viewModelScope)
collectAsStateWithLifecycle()

05 — Feature Matrix

EVERY REQUIREMENT COVERED

All features from the original brief — mapped to Android's idiomatic solution.

🎤
Google STT / TTS
SpeechRecognizer for STT with partial results via UNSTABLE_TEXT bundle key. TextToSpeech with Google engine. Both wrapped in singleton managers, emitting to SharedFlow for ViewModel collection.
android.speech.SpeechRecognizer android.speech.tts.TextToSpeech
🔐
API encryption
AES-256-GCM via Android Keystore-backed CryptoManager. OkHttp Interceptor encrypts request body and decrypts response transparently. Zero crypto code in feature layer.
android.security.keystore javax.crypto (built-in)
Debounced search
Kotlin Flow operators: .debounce(350L).distinctUntilChanged().flatMapLatest { }. flatMapLatest cancels the previous coroutine the moment a new query arrives. Zero extra utilities needed.
kotlinx.coroutines.flow — built-in
📡
Cancellable API calls
Retrofit suspend functions cancel automatically when the coroutine scope is cancelled. viewModelScope cancels all in-flight requests on ViewModel clearance. flatMapLatest handles search supersession.
retrofit2 suspend support
💾
Offline-first cache
Write-through cache: remote success → Room @Upsert. Network fail → serve Room cache. NetworkBoundResource generic helper orchestrates the flow. Room emits Flow<T> for reactive UI updates.
room-runtime:2.7.x room-ktx:2.7.x
🔑
Secure storage
EncryptedSharedPreferences with MasterKey backed by Android Keystore AES-256-GCM. Stores tokens, user ID. Regular DataStore Preferences for non-sensitive flags. Both wrapped in singleton classes.
security-crypto:1.1.x datastore-preferences:1.1.x
UI animations
AnimatedVisibility with staggered LaunchedEffect delays for list entry. AnimatedContent for state-driven content swaps with cross-fade + slide. animate*AsState for value-driven transitions.
compose.animation — built-in
🎬
Motion / Lottie
Compose Lottie via rememberLottieComposition + animateLottieCompositionAsState. Declarative — no manual AnimationController. Used for empty states, success/error feedback, onboarding.
lottie-compose:6.x
🔀
Shared element transitions
Compose 1.7+ SharedTransitionLayout + sharedElement() modifier. Same key on source and destination — framework morphs the element automatically. Supports images, text, and complex composables.
compose.animation:1.7.x
Real-time communication
OkHttp WebSocketListener emits to MutableSharedFlow. Exponential backoff reconnect in onFailure/onClosed. ViewModel collects in viewModelScope — connection cancelled when VM clears.
okhttp:4.12.x WebSocket
🎨
Design system / theming
Material 3 with MaterialTheme. Custom ColorScheme, Typography, Shapes in AppTheme. Token objects (AppColors, AppTypography) — zero hardcoded values in feature composables. Light + dark ThemeData.
compose.material3 — built-in
🧩
Dependency injection
Hilt — Google's official DI for Android. @HiltAndroidApp, @AndroidEntryPoint, @HiltViewModel. @Module + @InstallIn scopes. @Binds for interface→impl. Compile-time verified, no reflection.
hilt-android:2.52 hilt-navigation-compose

06 — Gradle Dependencies

THE GRADLE REGISTRY

Stable, production-proven. Using Gradle version catalog (libs.versions.toml) format.

Version catalog: Add these to gradle/libs.versions.toml and reference as libs.hilt.android etc. — the modern Gradle approach.
ArtifactVersionPurposeLayer
androidx.compose.bom2025.05.xCompose BOM — pins all compose-* versions consistentlyui
compose.material3BOMMaterial 3 components, theming, ColorSchemeui
compose.animationBOM 1.7+AnimatedVisibility, AnimatedContent, SharedTransitionLayoutui
compose.uiBOMCore Compose runtime, layout, drawingui
navigation-compose2.8.xNavHost, composable routes, type-safe navigationui
hilt-navigation-compose1.2.xhiltViewModel() in composables, nav backstack scopingui
lottie-compose6.xLottie JSON animations with Compose APIsui
coil-compose2.7.xAsync image loading with Compose — AsyncImage composableui
lifecycle-viewmodel-compose2.8.xviewModel(), collectAsStateWithLifecycle()vm
lifecycle-runtime-compose2.8.xcollectAsStateWithLifecycle — lifecycle-aware collectionvm
kotlinx.coroutines.android1.9.xviewModelScope, Dispatchers, Flow, debounce, flatMapLatestvm
hilt-android2.52Hilt DI — @HiltAndroidApp, @HiltViewModel, @Moduledi
hilt-android-compiler2.52KSP annotation processor for Hilt code generationdi
retrofit22.11.xType-safe HTTP client with suspend function supportdata
retrofit2-converter-gson2.11.xJSON serialization — or swap for moshi-converterdata
okhttp34.12.xHTTP + WebSocket client, interceptor chaindata
okhttp3-logging-interceptor4.12.xHTTP body logging — debug builds onlydata
room-runtime2.7.xSQLite ORM — @Entity, @Dao, @Database, Flow queriesdata
room-ktx2.7.xCoroutines and Flow extensions for Roomdata
room-compiler2.7.xKSP processor for Room code generationdata
datastore-preferences1.1.xAsync, coroutine-based key-value storage — SharedPreferences replacementdata
security-crypto1.1.xEncryptedSharedPreferences + MasterKey — Keystore-backedcore
kotlinx.serialization.json1.7.xKotlin-native JSON — alternative to Gson, better performancedata
hilt-android-testing2.52Hilt test utilities, @HiltAndroidTesttest
kotlinx.coroutines.test1.9.xTestCoroutineDispatcher, runTest, turbine-compatibletest
turbine1.2.xFlow testing — awaitItem(), assertValues()test
mockk1.14.xKotlin-native mocking — coEvery, coVerify, relaxed mockstest

✦ — AI Scaffold

Generate with AI

Paste into Claude, ChatGPT, Gemini, or any AI agent to scaffold this exact architecture from scratch.

 Architecture Prompt
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 Result wrapper
6. Navigation graph with sealed class routes
7. Unit test for a ViewModel and Repository
8. Gradle version catalog (libs.versions.toml)
crafted with by Sam
 Copied to clipboard!