Build an offline sleep journal from an empty project
Type a working list first, wire events and Flow next, then replace memory with Room, Hilt, and a durable note queue. Stop at each checkpoint and make the suggested change yourself. The final reference is for comparison after your attempt.
Four sessions of roughly 45 to 75 minutes, then one 30-minute rebuild. These are planning estimates, not measured completion times.
Android Kotlin and Jetpack Compose only. Fake sleep data and a simulated backend. One app module, one ViewModel, no real health-device API. The simulated server receipts live in local Room storage, and the offline toggle resets on process restart.
Practice explaining each choice while you build. These are example answers for a small Android feature interview, not a confirmed Eight Sleep assessment or a script to memorize word for word.
A 30-minute feature interview
First five minutes: confirm the requirements, create simple models and show sample data. Use an existing working project if setup is already complete.
Next ten minutes: connect a memory repository, ViewModel and lifecycle-aware StateFlow collection. Handle Loading, Ready and Failure with simple Compose if/else checks.
Next ten minutes: finish the requested interaction, such as search, detail, favorite or editing. Run it before expanding scope.
Last five minutes: check the core behavior and an edge case. Run relevant existing checks when available and explain any verification still owed.
Keep the first version small
Default to MVVM with named functions for a small feature. Use the MVI path to practice explicit transitions when the prompt or team prefers it.
Use plain String IDs and one immutable UiState. UserInput holds the current query, filter, selected ID and draft. Actions describe changes to those values.
A repository interface is useful here because memory is replaced by Room. A single concrete repository is enough when no implementation swap is needed.
JournalEvent keeps the two workshop paths on one screen contract. A small interview screen can take direct onQueryChange and onSave lambdas instead.
LoadState is a simple Loading/Ready/Failure enum. JournalUiState holds the journal and error message, and Compose uses if/else checks. Repository interfaces describe replaceable data implementations; sealed events describe the input cases a callback can receive.
Keep the full workshop as a learning exercise. Add Room for durable storage, Hilt for dependency wiring and WorkManager for deferred retries only when the requirements justify them.
Explain or run the relevant existing unit and UI examples if asked. Do not spend the whole feature interview installing infrastructure before showing working behavior.
Choose your approach
Pick one path at WS04 and keep it through WS08. Both use Android ViewModel and unidirectional state, and both produce the same app. Direct callbacks versus a typed intent/reducer pipeline are teaching choices, not mutually exclusive industry definitions.
MVVM with direct ViewModel functions
Choose this for a small feature when named functions make state ownership easiest to explain.
Screen callback → route adapter → Intent → ViewModel → pure reducer → control state
ViewModel coroutine → Repository → Room → observed journal data
Save completion → typed result → pure reducer → StateFlow → Screen
WS04 uses intents/reducer with MemoryJournalRepository and viewModel().
WS08 keeps the same intent/reducer and replaces the ViewModel/route with injected final versions.
JournalIntent contains user actions; JournalResult represents save completion. The reducer is pure and exhaustive. The ViewModel owns effects and writes restoration inputs.
MVI can use Android ViewModel and does not require a store library. This lightweight example reduces input values/results while repository and operation flows retain their separate ownership.
The maps below are the actual source layout used by every snippet and both completed references. Package declarations match the folders. MainActivity and JournalApplication stay at the root; the screen and its ViewModel stay together in ui/journal.
UI state and intent/reducer types belong beside the screen. Domain models belong under model; Room entities and API DTOs stay in their own data sources. A remote API service belongs in data/remote, not a generic service bucket.
If you used the earlier flat tutorial, use Android Studio Refactor > Move to move declarations into these packages. Split domain and UI types, separate route from screen and Application from DI, then update imports and manifest references. Delete the old flat files so duplicate declarations cannot linger. The static WS03 stage has no route; remove any copied JournalRoute until WS04.
The default final reference uses MVI. The separate MVVM and MVI reference downloads are complete projects, not standalone overlays. Use them only after your own checkpoint attempt.
Switching displays the other implementation; it does not modify your project. Follow one path through its replacement checkpoints. Printing includes both paths for comparison.
Before starting
Use Android Studio with an Empty Activity Kotlin/Compose project, JDK 17, and SDK 35.
Use dev.dchung.sleepjournal as the base package, and create the subpackages shown in each file. The installed application ID is dev.dchung.sleepjournal.workshop. Directory paths and package declarations must agree.
Every code block is a full file replacement unless its note says otherwise. Early replacements deliberately differ from the final reference.
Keep your template gradle wrapper files. The bundled reference reuses the existing practice Gradle 8.13 wrapper.
Use the pinned versions in WS01. They are a reproducible baseline rather than the latest dependency recommendation.
Remove generated template test files so later expected counts refer only to this workshop.
What you will be able to demonstrate
WS03. A visible static list builds before ViewModel, Room, or Hilt.
WS04. In-memory interactions build and run. Rotation retains screen inputs.
WS08. Room, Hilt, and WorkManager are installed together. Favorites survive relaunch.
WS09 to WS10. Offline notes remain visible and retry with the same operation ID.
WS13. Slider experiment distinguishes composition, placement, and draw reads.
WS14 to WS15. Unit tests and UI checks verify separate contracts.
WS16. Build one independent feature in thirty minutes.
WS01Create an empty project and a reproducible baseline
Start with one app module that runs before you add architecture.
Piece
Why we add it
Keep it simple
One app module
This feature has one screen owner and no shared use-case logic yet.
Keep the generated project; add only dependencies needed for the next checkpoint.
Interviewer follow-up
Why not set up Room and Hilt immediately?
They are part of the complete learning app. For a short feature task, a working memory implementation gives us useful behavior sooner.
Do this
Create an Android Studio Empty Activity project named SleepJournalWorkshop. Choose Kotlin, package dev.dchung.sleepjournal, and minimum SDK 26.
Select JDK 17 for Gradle. Install Android SDK 35. Keep the Android Studio-generated gradlew scripts and wrapper jar.
Replace the listed Gradle files. This is a pinned learning baseline, not a claim about the newest library versions. Kotlin and Compose compiler versions match. Kapt processes both Room and Hilt here.
Delete the template ExampleUnitTest.kt and ExampleInstrumentedTest.kt. Delete the generated Greeting function and preview when you replace MainActivity in WS03.
Keep the template Activity for this step. Change its greeting to Sleep journal workshop and run the app.
Initial manifest. Hilt and custom WorkManager initialization arrive in WS08.
Explain it, then change it yourself
A chat lab is shorter for text entry but adds message ordering and transport decisions. A sleep journal gives us a bounded list, detail, filtering, editable notes, and an offline queue. We choose the journal and one app module. No use-case layer is needed for one ViewModel.
Make loading, readable data, and failure explicit before you write UI branches.
Piece
Why we add it
Keep it simple
LoadState
A simple enum tells Compose whether to show loading, an error or the journal.
Keep the data and error on JournalUiState and use if/else. No separate content class is needed.
Operation
A save can fail while loaded data stays visible. Its status should not replace the loaded journal.
Leave it out until the screen has a write or refresh whose status is separate from the initial read.
UserInput
It groups query, favorites filter, selected ID and draft because we update and restore those inputs together.
Keep this as a plain data class. UserInput holds current values; actions describe what happened.
JournalUiState
One immutable snapshot gives the screen everything it needs without reading the database itself.
Use this data class directly. loadState chooses the branch; journal and errorMessage supply its values.
Interviewer follow-up
Why use both models and UiState?
A night is stored data. Loading, search text and a selected row are screen concerns. Keeping them separate avoids putting screen-specific fields in our data model.
Do this
Create the model and ui/journal packages under app/src/main/java/dev/dchung/sleepjournal.
Create every file below. Domain values live in model; LoadState, Operation, UserInput and screen callbacks live in ui/journal.
JournalPresentation.kt filters domain data using UI input values and retains the small reduce function for existing presentation test examples. Neither ViewModel uses that legacy reducer.
Keep sample dates fixed so checks do not depend on today.
package dev.dchung.sleepjournal.model
// This is stored sleep data, not loading flags or text the user is editing.
// Keep IDs as plain strings here so the first feature stays quick to build.
data class SleepNight(val id: String, val date: String, val minutes: Int, val favorite: Boolean = false)
Create or fully replace this file. Type the code before comparing with the final reference.
package dev.dchung.sleepjournal.model
enum class SyncStatus { Pending, Acknowledged }
// The note ID also identifies its upload. Create it once, then reuse it on retry.
data class Note(val id: String, val nightId: String, val text: String, val status: SyncStatus)
Create or fully replace this file. Type the code before comparing with the final reference.
package dev.dchung.sleepjournal.model
// Group the data the screen reads. UI-only query and selection live in UiState.
data class Journal(val nights: List<SleepNight>, val notes: List<Note>)
Create or fully replace this file. Type the code before comparing with the final reference.
package dev.dchung.sleepjournal.model
// Fixed samples let us build and check the screen before wiring an API.
val sampleNights = listOf(
SleepNight("n1", "2026-10-01", 430),
SleepNight("n2", "2026-10-02", 470),
SleepNight("n3", "2026-10-03", 390)
)
Create or fully replace this file. Type the code before comparing with the final reference.
package dev.dchung.sleepjournal.ui.journal
import dev.dchung.sleepjournal.model.Journal
// One value chooses which branch the screen displays.
enum class LoadState { Loading, Ready, Failure }
// A failed save should leave the already loaded journal visible.
sealed interface Operation {
data object Idle : Operation
data object Busy : Operation
data class Failed(val message: String) : Operation
}
// These are current input values. Events describe what the user just did.
data class UserInput(
val query: String = "",
val favoritesOnly: Boolean = false,
val selected: String? = null,
val draft: String = ""
)
// Keep load status, data and error together so each read emits a complete state.
// Ready with an empty journal means a successful read with no results.
data class JournalUiState(
val loadState: LoadState = LoadState.Loading,
val journal: Journal = Journal(emptyList(), emptyList()),
val errorMessage: String? = null,
val userInput: UserInput = UserInput(),
val operation: Operation = Operation.Idle,
val offline: Boolean = false
)
Create or fully replace this file. Type the code before comparing with the final reference.
package dev.dchung.sleepjournal.ui.journal
// This shared callback lets both teaching paths reuse the same screen.
// It does not make the app MVI. Direct onQueryChange/onSave lambdas are simpler
// when building only one interview path.
sealed interface JournalEvent {
data class Query(val value: String) : JournalEvent
data class Favorites(val value: Boolean) : JournalEvent
data class Select(val id: String?) : JournalEvent
data class Draft(val value: String) : JournalEvent
data class Favorite(val id: String) : JournalEvent
data object SaveNote : JournalEvent
data object Sync : JournalEvent
data class Offline(val value: Boolean) : JournalEvent
}
Create or fully replace this file. Type the code before comparing with the final reference.
package dev.dchung.sleepjournal.ui.journal
import dev.dchung.sleepjournal.model.Journal
import dev.dchung.sleepjournal.model.SleepNight
// Local filtering is a plain calculation. No coroutine or extra use-case class
// is needed because the data is already in memory.
fun filteredNights(journal: Journal, userInput: UserInput): List<SleepNight> =
journal.nights.filter { night ->
(!userInput.favoritesOnly || night.favorite) &&
night.date.contains(userInput.query.trim(), ignoreCase = true)
}
fun reduce(userInput: UserInput, event: JournalEvent): UserInput = when (event) {
is JournalEvent.Query -> userInput.copy(query = event.value)
is JournalEvent.Favorites -> userInput.copy(favoritesOnly = event.value)
is JournalEvent.Select -> userInput.copy(selected = event.id, draft = "")
is JournalEvent.Draft -> userInput.copy(draft = event.value)
is JournalEvent.Favorite, JournalEvent.SaveNote, JournalEvent.Sync, is JournalEvent.Offline -> userInput
}
Create or fully replace this file. Type the code before comparing with the final reference.
Explain it, then change it yourself
Models describe stored data; UiState describes the screen right now. LoadState chooses Loading, Ready or Failure with simple Compose if/else branches. JournalUiState also holds journal data and errorMessage. Operation tracks a save or sync without hiding existing data. UserInput holds the current query, filter, selected ID and draft; it is not an action list. Plain string IDs keep setup simple; each note receives one UUID string reused across retries. Repository writes stay in the ViewModel and data layer, outside pure reducers.
See the first useful screen and keep it independently renderable.
Piece
Why we add it
Keep it simple
Stateless JournalScreen
Rendering can be checked without creating Room, Hilt or a ViewModel.
Use direct onQueryChange and onSave callbacks when building one path. JournalEvent exists here to share the teaching screen.
Stable list keys
A row keeps its identity when filtering changes its position.
Use the existing string ID; no wrapper type is needed.
Interviewer follow-up
Is a typed event callback automatically MVI?
No. MVVM can use typed callbacks too. Our MVVM route maps them to named functions; our MVI route maps them to intents and explicit reduced transitions.
Do this
Fully replace root MainActivity.kt and create ui/journal/JournalScreen.kt using these static-stage files.
Do not create JournalRoute.kt yet. If it exists from a prior attempt, remove it for WS03. This Activity passes fixed values and empty callbacks, so no ViewModel is referenced.
Run the app. The list sorts exactly as the supplied list for this static stage. Save and search are intentionally unwired until WS04.
Stateless screen. JournalRoute is a separate file introduced in WS04.
Explain it, then change it yourself
The screen accepts values and callbacks. This split lets us test UI behavior without database or network setup. Stable LazyColumn keys keep row identity when filtering.
WS04Choose MVVM or MVI and build retained in-memory state
Make search, favorites, details, and notes work in memory.
Piece
Why we add it
Keep it simple
JournalRepository interface
The same ViewModel contract will use memory now and Room later.
Use a concrete repository if there is only one source. An interface is not required for every class.
ViewModel and SavedStateHandle
The ViewModel retains inputs through rotation; SavedStateHandle restores small inputs after eligible recreation.
Keep durable data in storage. Do not claim saved state is a database or guarantees force-stop recovery.
MVVM or MVI
Both keep state ownership clear. MVI additionally names inputs and reduces input transitions.
Choose one path. Named ViewModel functions need no intent or reducer layer.
Interviewer follow-up
Why split Route from Screen?
The route wires the ViewModel and lifecycle-aware collection. The screen gets plain values and callbacks, so rendering does not depend on Android storage or injection.
Do this
Choose MVVM or MVI in the architecture selector. Follow the same choice at WS08; every other data and UI lesson is shared.
Create data/JournalRepository.kt and data/MemoryJournalRepository.kt below. Replace MainActivity.kt with the initial route Activity.
Apply only your selected path files. Both paths use lifecycle-aware collection and the same stateless screen inputs.
Run the whole in-memory feature before installing Room or Hilt. The final reference constructor does not work at this initial checkpoint.
package dev.dchung.sleepjournal.data
import dev.dchung.sleepjournal.model.Journal
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
// This interface lets the same ViewModel use memory first and Room later.
// It defines the operations both memory and Room implementations provide.
// If the interview has only one data source, a concrete repository is enough.
interface JournalRepository {
fun observe(): Flow<Journal>
val offline: StateFlow<Boolean>
suspend fun seed()
suspend fun toggle(nightId: String)
suspend fun addNote(nightId: String, text: String)
suspend fun sync()
fun setOffline(value: Boolean)
}
Create or fully replace this file. Type the code before comparing with the final reference.
package dev.dchung.sleepjournal.ui.journal
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
// The route owns Android-specific wiring; the screen only renders values.
// Lifecycle-aware collection pauses when this UI is not active.
@Composable
fun JournalRoute(modifier: Modifier = Modifier, viewModel: JournalViewModel = viewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
// Map the common teaching callback to this path's ViewModel functions or intents.
JournalScreen(state = state, onEvent = { event ->
when (event) {
is JournalEvent.Query -> viewModel.onQueryChanged(event.value)
is JournalEvent.Favorites -> viewModel.onFavoritesChanged(event.value)
is JournalEvent.Select -> viewModel.onNightSelected(event.id)
is JournalEvent.Draft -> viewModel.onDraftChanged(event.value)
is JournalEvent.Favorite -> viewModel.onFavoriteClicked(event.id)
JournalEvent.SaveNote -> viewModel.onSaveClicked()
JournalEvent.Sync -> viewModel.onSyncClicked()
is JournalEvent.Offline -> viewModel.onOfflineChanged(event.value)
}
}, onReload = viewModel::reload, modifier = modifier)
}
Selected architecture checkpoint. Replace route and ViewModel together; never install both variants.
package dev.dchung.sleepjournal.ui.journal
import dev.dchung.sleepjournal.model.*
import dev.dchung.sleepjournal.data.JournalRepository
import dev.dchung.sleepjournal.data.MemoryJournalRepository
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
@OptIn(ExperimentalCoroutinesApi::class)
class JournalViewModel(private val saved: SavedStateHandle) : ViewModel() {
private val repository: JournalRepository = MemoryJournalRepository()
// The ViewModel retains screen inputs through rotation. SavedStateHandle
// restores small inputs after eligible recreation; it is not a database.
private val userInput = MutableStateFlow(UserInput(
query = saved["query"] ?: "", favoritesOnly = saved["favorites"] ?: false,
selected = saved.get<String>("selected"), draft = saved["draft"] ?: ""))
private val operation = MutableStateFlow<Operation>(Operation.Idle)
private val retry = MutableStateFlow(0)
// Retry starts a fresh read subscription after a terminal read failure.
// Rethrow cancellation so leaving the owner can stop upstream work.
private val readFlow: Flow<JournalUiState> = retry.flatMapLatest {
repository.observe().map { JournalUiState(loadState = LoadState.Ready, journal = it) }
.onStart { emit(JournalUiState()) }
.catch { failure ->
if (failure is CancellationException) throw failure
emit(JournalUiState(loadState = LoadState.Failure,
errorMessage = failure.message ?: "Cannot read journal"))
}
}
// Combine the latest data and inputs into one screen snapshot.
// stateIn exposes a hot StateFlow with a current value; this upstream shares
// one subscription while observed and stops after five seconds without collectors.
val uiState: StateFlow<JournalUiState> = combine(readFlow, userInput, operation, repository.offline) { readState, input, op, offline ->
readState.copy(userInput = input, operation = op, offline = offline)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), JournalUiState())
init { runOperation { repository.seed() } }
fun reload() { retry.update { it + 1 } }
// MVVM uses named functions and copy to update immutable inputs directly.
fun onQueryChanged(value: String) = updateUserInput(userInput.value.copy(query = value))
fun onFavoritesChanged(value: Boolean) = updateUserInput(userInput.value.copy(favoritesOnly = value))
fun onNightSelected(id: String?) = updateUserInput(userInput.value.copy(selected = id, draft = ""))
fun onDraftChanged(value: String) = updateUserInput(userInput.value.copy(draft = value))
fun onFavoriteClicked(id: String) = runOperation { repository.toggle(id) }
fun onOfflineChanged(value: Boolean) = repository.setOffline(value)
fun onSyncClicked() = runOperation { repository.sync() }
fun onSaveClicked() {
val form = userInput.value
val id = form.selected ?: return
runOperation {
repository.addNote(id, form.draft)
// Do not clear a newer draft typed while this save was running.
if (userInput.value.selected == id && userInput.value.draft == form.draft) {
updateUserInput(userInput.value.copy(draft = ""))
}
}
}
private fun updateUserInput(value: UserInput) {
userInput.value = value
saved["query"] = value.query
saved["favorites"] = value.favoritesOnly
saved["selected"] = value.selected
saved["draft"] = value.draft
}
private fun runOperation(block: suspend () -> Unit) {
if (operation.value == Operation.Busy) return
operation.value = Operation.Busy
// The ViewModel owns this work; clearing it cancels the coroutine.
// This stage uses memory; Room and a worker are later extensions.
viewModelScope.launch {
try { block(); operation.value = Operation.Idle }
catch (cancelled: CancellationException) { operation.value = Operation.Idle; throw cancelled }
catch (failure: Exception) { operation.value = Operation.Failed(failure.message ?: "Operation failed") }
}
}
}
Selected architecture checkpoint. Replace route and ViewModel together; never install both variants.
This MVVM example exposes named ViewModel functions and updates immutable state directly. The screen uses a shared event callback for reusable rendering and tests; the route maps that UI contract to direct calls. Typed callbacks are compatible with MVVM, so event syntax alone does not identify MVI.
MVI in-memory checkpoint
Choose one architecture path and fully replace only the files shown for that path. Do not compile both JournalViewModel classes in one package.
Keep the SavedStateHandle-only constructor and viewModel() at WS04. The repository is MemoryJournalRepository. Hilt arrives at WS08.
Follow the route adapter from a JournalEvent to JournalIntent. onIntent first applies the pure reducer, then owns any coroutine or repository work.
Keep JournalIntent.kt and JournalReducer.kt beside the ViewModel. JournalResult.NoteSaved re-enters the pure reducer after a save; repository journal data and operation state still come from their existing flows.
package dev.dchung.sleepjournal.ui.journal
import dev.dchung.sleepjournal.model.*
// MVI names every user action so transitions have explicit inputs.
// Use named ViewModel functions instead if they are easier to finish and explain.
sealed interface JournalIntent {
data class QueryChanged(val value: String) : JournalIntent
data class FavoritesChanged(val value: Boolean) : JournalIntent
data class NightSelected(val id: String?) : JournalIntent
data class DraftChanged(val value: String) : JournalIntent
data class FavoriteClicked(val id: String) : JournalIntent
data object SaveClicked : JournalIntent
data object SyncClicked : JournalIntent
data class OfflineChanged(val value: Boolean) : JournalIntent
data object RetryRead : JournalIntent
}
// A save finishes later. Carry the submitted values back so completion
// can avoid clearing a newer draft or a different selected night.
sealed interface JournalResult {
data class NoteSaved(val nightId: String, val submittedDraft: String) : JournalResult
}
Selected architecture checkpoint. Replace route and ViewModel together; never install both variants.
package dev.dchung.sleepjournal.ui.journal
import dev.dchung.sleepjournal.model.*
// A reducer returns the next UserInput value. Same inputs give the same output.
// It does not launch work or write storage; the ViewModel owns those effects.
fun reduceJournal(userInput: UserInput, intent: JournalIntent): UserInput = when (intent) {
is JournalIntent.QueryChanged -> userInput.copy(query = intent.value)
is JournalIntent.FavoritesChanged -> userInput.copy(favoritesOnly = intent.value)
is JournalIntent.NightSelected -> userInput.copy(selected = intent.id, draft = "")
is JournalIntent.DraftChanged -> userInput.copy(draft = intent.value)
is JournalIntent.FavoriteClicked, JournalIntent.SaveClicked, JournalIntent.SyncClicked,
is JournalIntent.OfflineChanged, JournalIntent.RetryRead -> userInput
}
// Only clear the draft that actually finished saving.
// This reducer owns userInput/results, not every JournalUiState transition.
fun reduceJournal(userInput: UserInput, result: JournalResult): UserInput = when (result) {
is JournalResult.NoteSaved ->
if (userInput.selected == result.nightId && userInput.draft == result.submittedDraft)
userInput.copy(draft = "") else userInput
}
Selected architecture checkpoint. Replace route and ViewModel together; never install both variants.
package dev.dchung.sleepjournal.ui.journal
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
// The route owns Android-specific wiring; the screen only renders values.
// Lifecycle-aware collection pauses when this UI is not active.
@Composable
fun JournalRoute(modifier: Modifier = Modifier, viewModel: JournalViewModel = viewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
// Map the common teaching callback to this path's ViewModel functions or intents.
JournalScreen(state = state, onEvent = { event ->
val intent = when (event) {
is JournalEvent.Query -> JournalIntent.QueryChanged(event.value)
is JournalEvent.Favorites -> JournalIntent.FavoritesChanged(event.value)
is JournalEvent.Select -> JournalIntent.NightSelected(event.id)
is JournalEvent.Draft -> JournalIntent.DraftChanged(event.value)
is JournalEvent.Favorite -> JournalIntent.FavoriteClicked(event.id)
JournalEvent.SaveNote -> JournalIntent.SaveClicked
JournalEvent.Sync -> JournalIntent.SyncClicked
is JournalEvent.Offline -> JournalIntent.OfflineChanged(event.value)
}
viewModel.onIntent(intent)
}, onReload = { viewModel.onIntent(JournalIntent.RetryRead) }, modifier = modifier)
}
Selected architecture checkpoint. Replace route and ViewModel together; never install both variants.
package dev.dchung.sleepjournal.ui.journal
import dev.dchung.sleepjournal.model.*
import dev.dchung.sleepjournal.data.JournalRepository
import dev.dchung.sleepjournal.data.MemoryJournalRepository
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
@OptIn(ExperimentalCoroutinesApi::class)
class JournalViewModel(private val saved: SavedStateHandle) : ViewModel() {
private val repository: JournalRepository = MemoryJournalRepository()
// The ViewModel retains screen inputs through rotation. SavedStateHandle
// restores small inputs after eligible recreation; it is not a database.
private val userInput = MutableStateFlow(UserInput(
query = saved["query"] ?: "", favoritesOnly = saved["favorites"] ?: false,
selected = saved.get<String>("selected"), draft = saved["draft"] ?: ""))
private val operation = MutableStateFlow<Operation>(Operation.Idle)
private val retry = MutableStateFlow(0)
// Retry starts a fresh read subscription after a terminal read failure.
// Rethrow cancellation so leaving the owner can stop upstream work.
private val readFlow: Flow<JournalUiState> = retry.flatMapLatest {
repository.observe().map { JournalUiState(loadState = LoadState.Ready, journal = it) }
.onStart { emit(JournalUiState()) }
.catch { failure ->
if (failure is CancellationException) throw failure
emit(JournalUiState(loadState = LoadState.Failure,
errorMessage = failure.message ?: "Cannot read journal"))
}
}
// Combine the latest data and inputs into one screen snapshot.
// stateIn exposes a hot StateFlow with a current value; this upstream shares
// one subscription while observed and stops after five seconds without collectors.
val uiState: StateFlow<JournalUiState> = combine(readFlow, userInput, operation, repository.offline) { readState, input, op, offline ->
readState.copy(userInput = input, operation = op, offline = offline)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), JournalUiState())
init { runOperation { repository.seed() } }
// The reducer changes input state; this ViewModel performs save/sync effects.
fun onIntent(intent: JournalIntent) {
updateUserInput(reduceJournal(userInput.value, intent))
when (intent) {
is JournalIntent.FavoriteClicked -> runOperation { repository.toggle(intent.id) }
JournalIntent.SaveClicked -> {
val form = userInput.value
val id = form.selected ?: return
runOperation {
repository.addNote(id, form.draft)
updateUserInput(reduceJournal(userInput.value, JournalResult.NoteSaved(id, form.draft)))
}
}
JournalIntent.SyncClicked -> runOperation { repository.sync() }
is JournalIntent.OfflineChanged -> repository.setOffline(intent.value)
JournalIntent.RetryRead -> retry.update { it + 1 }
is JournalIntent.QueryChanged, is JournalIntent.FavoritesChanged,
is JournalIntent.NightSelected, is JournalIntent.DraftChanged -> Unit
}
}
private fun updateUserInput(value: UserInput) {
userInput.value = value
saved["query"] = value.query
saved["favorites"] = value.favoritesOnly
saved["selected"] = value.selected
saved["draft"] = value.draft
}
private fun runOperation(block: suspend () -> Unit) {
if (operation.value == Operation.Busy) return
operation.value = Operation.Busy
// The ViewModel owns this work; clearing it cancels the coroutine.
// This stage uses memory; Room and a worker are later extensions.
viewModelScope.launch {
try { block(); operation.value = Operation.Idle }
catch (cancelled: CancellationException) { operation.value = Operation.Idle; throw cancelled }
catch (failure: Exception) { operation.value = Operation.Failed(failure.message ?: "Operation failed") }
}
}
}
Selected architecture checkpoint. Replace route and ViewModel together; never install both variants.
This MVI example adds explicit typed intents, pure input/result transitions, and ViewModel-owned effects. A reducer cannot call Room, launch a coroutine, or write SavedStateHandle. MVVM and MVI overlap: Android ViewModel is the lifecycle owner in both, and neither approach requires a new framework.
Explain it, then change it yourself
Both paths use the same layers, source of truth, StateFlow, SavedStateHandle and UI. Choose direct named ViewModel functions (MVVM) or typed intent dispatch plus a pure reducer (MVI). SavedStateHandle restores small inputs after eligible system recreation; it is not durable storage and does not guarantee force-stop or task-dismissal recovery.
package dev.dchung.sleepjournal.ui.journal
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.test.*
import org.junit.Assert.*
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class FlowOwnershipTest {
@Test fun coldCollectorsStartSeparateProducers() = runTest {
var starts = 0
val cold = flow { starts++; emit("night") }
cold.first(); cold.first()
assertEquals(2, starts)
}
@Test fun stateInSharesThenStopsItsUpstreamAfterTimeout() = runTest {
var starts = 0
var stops = 0
val cold = flow {
starts++
try { emit(7); awaitCancellation() } finally { stops++ }
}
val hot = cold.stateIn(backgroundScope, SharingStarted.WhileSubscribed(5_000), 0)
val first = backgroundScope.launch { hot.collect {} }
val second = backgroundScope.launch { hot.collect {} }
runCurrent()
assertEquals(1, starts)
assertEquals(7, hot.value)
first.cancel(); second.cancel(); runCurrent()
advanceTimeBy(4_999); runCurrent()
assertEquals(0, stops)
advanceTimeBy(1); runCurrent()
assertEquals(1, stops)
assertEquals(7, hot.value)
}
@Test fun flatMapLatestCancelsOldSearchBeforeItCanPublish() = runTest {
val query = MutableSharedFlow<String>()
val results = mutableListOf<String>()
val job = backgroundScope.launch {
query.flatMapLatest { term -> flow { delay(if (term == "old") 1_000 else 100); emit(term) } }
.collect { results += it }
}
runCurrent()
query.emit("old")
advanceTimeBy(10); runCurrent()
query.emit("new")
advanceTimeBy(2_000); runCurrent()
assertEquals(listOf("new"), results)
job.cancel()
}
}
Create or fully replace this file. Type the code before comparing with the final reference.
Explain it, then change it yourself
Flow is an interface and does not promise coldness. flow { } is cold; MutableStateFlow is hot. stateIn shares an upstream subscription in its supplied scope. WhileSubscribed stops upstream work, but the StateFlow object and its last value still exist.
WS06Cancel old search work and choose operators deliberately
Keep an older response from replacing the current query.
Piece
Why we add it
Keep it simple
flatMapLatest for remote search
A newer query replaces the older inner producer so a cooperating stale request cannot publish.
Do not add debounce or asynchronous search machinery to this small local list.
Interviewer follow-up
Would you use collectLatest to save notes?
Not for independent saves that must all persist. Latest operators cancel previous work; that fits replaceable searches, not an append-only queue.
Do this
Run flatMapLatestCancelsOldSearchBeforeItCanPublish in the same test file.
Change flatMapLatest to flatMapConcat. Run the test and inspect its failure, then restore flatMapLatest.
For the journal, read filteredNights. It computes local results synchronously from the latest input values and journal, so there is no asynchronous query response race.
For a network search adaptation, place debounce and distinctUntilChanged before flatMapLatest. Keep the request query in the result model and cancel cooperating requests.
Explain it, then change it yourself
flatMapLatest cancels an inner producer. collectLatest cancels processing of a previous emission. buffer overlaps producer and consumer; conflate may discard intermediate values. StateFlow already conflates equal values. Search can drop stale results; a durable outbox cannot drop independent operations.
package dev.dchung.sleepjournal.data.local
import androidx.room.*
import dev.dchung.sleepjournal.model.*
@Entity(tableName = "nights")
// Room annotations and table shapes stay in data/local.
// Mappers below turn them into the simple models returned to the screen.
data class NightEntity(@PrimaryKey val id: String, val date: String, val minutes: Int, val favorite: Boolean)
@Entity(tableName = "notes", foreignKeys = [ForeignKey(entity = NightEntity::class,
parentColumns = ["id"], childColumns = ["nightId"])], indices = [Index("nightId")])
data class NoteEntity(@PrimaryKey val id: String, val nightId: String, val text: String, val acknowledged: Boolean)
@Entity(tableName = "demo_receipts")
data class DemoReceipt(@PrimaryKey val operationId: String, val nightId: String, val text: String)
fun NightEntity.toDomain() = SleepNight(id, date, minutes, favorite)
fun NoteEntity.toDomain() = Note(id, nightId, text,
if (acknowledged) SyncStatus.Acknowledged else SyncStatus.Pending)
Create or fully replace this file. Type the code before comparing with the final reference.
package dev.dchung.sleepjournal.data.local
import androidx.room.*
import kotlinx.coroutines.flow.Flow
// Room generates an implementation of this annotated DAO contract.
// An interface is simple here; Room also supports an abstract class.
@Dao
interface JournalDao {
@Query("SELECT * FROM nights ORDER BY date DESC, id") fun nights(): Flow<List<NightEntity>>
@Query("SELECT * FROM notes ORDER BY id") fun notes(): Flow<List<NoteEntity>>
@Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun seed(nights: List<NightEntity>)
@Query("UPDATE nights SET favorite = NOT favorite WHERE id = :id") suspend fun toggle(id: String)
@Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insert(note: NoteEntity)
// Pending rows are the durable outbox. WorkManager schedules attempts;
// it does not contain the notes themselves.
@Query("SELECT * FROM notes WHERE acknowledged = 0 ORDER BY id") suspend fun pending(): List<NoteEntity>
@Query("UPDATE notes SET acknowledged = 1 WHERE id = :id") suspend fun acknowledge(id: String)
@Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun receipt(receipt: DemoReceipt)
@Query("SELECT * FROM demo_receipts WHERE operationId = :id") suspend fun receiptById(id: String): DemoReceipt?
}
Create or fully replace this file. Type the code before comparing with the final reference.
package dev.dchung.sleepjournal.data.local
import androidx.room.Database
import androidx.room.RoomDatabase
// Room stores rows across process death. Schema export is off only for this lab;
// production data needs exported schemas and deliberate migrations.
@Database(entities = [NightEntity::class, NoteEntity::class, DemoReceipt::class], version = 1, exportSchema = false)
abstract class JournalDatabase : RoomDatabase() { abstract fun journalDao(): JournalDao }
Create or fully replace this file. Type the code before comparing with the final reference.
Explain it, then change it yourself
Room Flow queries react to invalidation and can rerun even if a relevant row did not change. The mapper keeps entities inside the data layer. This toy database has exportSchema=false to keep setup short. Production projects export schemas, version migrations, and test upgrades; never ship a destructive migration for valuable user notes.
WS08Replace memory with Room and wire Hilt as one compile checkpoint
Persist notes and favorites without making Compose own storage.
Piece
Why we add it
Keep it simple
Hilt
The complete app shares database, backend and repository dependencies across ViewModel and worker.
Direct construction is enough for the initial memory feature. Add injection when the task or existing project calls for it.
DTO
The upload payload is separate from the Room row, so transport does not depend on storage annotations.
Add a separate DTO when an API boundary needs it; do not duplicate every model by default.
Interviewer follow-up
Does calling a suspend function automatically make blocking work safe?
No. A suspend function can still block its caller. Room suspend methods manage database execution; other blocking boundaries must switch dispatchers inside the data layer.
Do this
Back up the WS04 files. Delete data/MemoryJournalRepository.kt after replacing its use in the ViewModel. Add or replace the listed files as one compile checkpoint.
Root JournalApplication.kt configures Hilt and WorkManager. di/JournalModule.kt supplies the database and repository; di/IoDispatcher.kt owns the qualifier.
sync/SyncQueue.kt and sync/JournalSyncWorker.kt supply the scheduling and worker types. data/remote owns the simulated backend and its upload DTO. We exercise the queue in WS10.
Replace the shared repository, Application/DI, worker, manifest and Activity below; then apply the WS08 files for the same path you chose at WS04. Replace route and ViewModel together. Do not mix constructors.
Run the app. Room now emits the sample nights in descending date order. Open Database Inspector and inspect nights and notes.
package dev.dchung.sleepjournal.data
import dev.dchung.sleepjournal.model.*
import dev.dchung.sleepjournal.data.local.*
import dev.dchung.sleepjournal.data.remote.DemoBackend
import dev.dchung.sleepjournal.data.remote.NoteUploadDto
import dev.dchung.sleepjournal.di.IoDispatcher
import dev.dchung.sleepjournal.sync.SyncQueue
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
// Add Room only when persistence is part of the requirement.
// The repository exposes domain data, so the UI does not depend on table shapes.
@Singleton
class RoomJournalRepository @Inject constructor(
private val dao: JournalDao,
private val backend: DemoBackend,
private val queue: SyncQueue,
@IoDispatcher private val io: CoroutineDispatcher
) : JournalRepository {
// Serialize manual and worker drains in this process.
// A real server still needs idempotency across retries and devices.
private val syncMutex = Mutex()
override val offline = backend.offline
// Room is the source of truth. Saves and sync update Room; its Flow updates UI.
override fun observe(): Flow<Journal> = combine(dao.nights(), dao.notes()) { nights, notes ->
Journal(nights.map { it.toDomain() }, notes.map { it.toDomain() })
}
override suspend fun seed() = withContext(io) {
dao.seed(sampleNights.map { NightEntity(it.id, it.date, it.minutes, it.favorite) })
}
override suspend fun toggle(nightId: String) = withContext(io) { dao.toggle(nightId) }
override suspend fun addNote(nightId: String, text: String) = withContext(io) {
val clean = text.trim()
require(clean.isNotEmpty() && clean.length <= 280) { "Write a note of 1 to 280 characters" }
// Persist first so the note is visible offline. Its UUID is the retry key.
dao.insert(NoteEntity(UUID.randomUUID().toString(), nightId, clean, false))
// Schedule an attempt after the durable write. These are separate transactions;
// startup recovery covers a crash here on the next app launch.
queue.enqueue()
Unit
}
override suspend fun sync() = withContext(io) {
syncMutex.withLock {
for (note in dao.pending()) {
backend.accept(NoteUploadDto(note.id, note.nightId, note.text))
dao.acknowledge(note.id)
}
}
}
override fun setOffline(value: Boolean) {
backend.setOffline(value)
if (!value) queue.enqueue()
}
}
Create or fully replace this file. Type the code before comparing with the final reference.
package dev.dchung.sleepjournal.data.remote
// This is the payload we send, separate from a Room entity.
// Add a DTO when the API shape differs; do not add one to every simple class.
data class NoteUploadDto(val id: String, val nightId: String, val text: String)
Create or fully replace this file. Type the code before comparing with the final reference.
package dev.dchung.sleepjournal.data.remote
import dev.dchung.sleepjournal.data.local.DemoReceipt
import dev.dchung.sleepjournal.data.local.JournalDao
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
// This simulates a remote API using local receipt rows. It makes retry identity
// visible, but cannot prove a real server accepted or retained anything.
@Singleton
class DemoBackend @Inject constructor(private val dao: JournalDao) {
private val disconnected = MutableStateFlow(false)
val offline: StateFlow<Boolean> = disconnected.asStateFlow()
fun setOffline(value: Boolean) { disconnected.value = value }
suspend fun accept(note: NoteUploadDto) {
delay(600)
if (offline.value) throw IOException("Demo backend is offline")
val receipt = DemoReceipt(note.id, note.nightId, note.text)
dao.receipt(receipt)
check(dao.receiptById(note.id) == receipt) { "Operation ID reused with different data" }
}
}
Create or fully replace this file. Type the code before comparing with the final reference.
package dev.dchung.sleepjournal.di
import javax.inject.Qualifier
// The qualifier selects this CoroutineDispatcher binding.
// It is useful for replacing dispatchers, not a new app layer.
@Qualifier @Retention(AnnotationRetention.BINARY) annotation class IoDispatcher
Create or fully replace this file. Type the code before comparing with the final reference.
package dev.dchung.sleepjournal.sync
import android.content.Context
import androidx.work.*
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class SyncQueue @Inject constructor(@ApplicationContext private val context: Context) {
fun enqueue() {
val work = OneTimeWorkRequestBuilder<JournalSyncWorker>()
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.SECONDS).build()
// Append another drain if a new note arrives during an existing one.
// WorkManager can delay execution; it is not an exact timer.
WorkManager.getInstance(context).enqueueUniqueWork("journal-outbox", ExistingWorkPolicy.APPEND_OR_REPLACE, work)
}
}
Create or fully replace this file. Type the code before comparing with the final reference.
Create or fully replace this file. Type the code before comparing with the final reference.
MVVM injected final checkpoint
Choose one architecture path and fully replace only the files shown for that path. Do not compile both JournalViewModel classes in one package.
Replace the WS04 ViewModel and route together. The constructor now requires JournalRepository and SavedStateHandle, and the route uses hiltViewModel().
Follow the route adapter from a JournalEvent to a named ViewModel function. Query, filter, selection and draft use direct immutable UserInput copies.
Do not use the shared reduce function in this ViewModel. It stays in ui/journal/JournalPresentation.kt for the existing presentation test examples.
package dev.dchung.sleepjournal.ui.journal
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.hilt.navigation.compose.hiltViewModel
// The route owns Android-specific wiring; the screen only renders values.
// Lifecycle-aware collection pauses when this UI is not active.
@Composable
fun JournalRoute(modifier: Modifier = Modifier, viewModel: JournalViewModel = hiltViewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
// Map the common teaching callback to this path's ViewModel functions or intents.
JournalScreen(state = state, onEvent = { event ->
when (event) {
is JournalEvent.Query -> viewModel.onQueryChanged(event.value)
is JournalEvent.Favorites -> viewModel.onFavoritesChanged(event.value)
is JournalEvent.Select -> viewModel.onNightSelected(event.id)
is JournalEvent.Draft -> viewModel.onDraftChanged(event.value)
is JournalEvent.Favorite -> viewModel.onFavoriteClicked(event.id)
JournalEvent.SaveNote -> viewModel.onSaveClicked()
JournalEvent.Sync -> viewModel.onSyncClicked()
is JournalEvent.Offline -> viewModel.onOfflineChanged(event.value)
}
}, onReload = viewModel::reload, modifier = modifier)
}
Selected architecture checkpoint. Replace route and ViewModel together; never install both variants.
package dev.dchung.sleepjournal.ui.journal
import dev.dchung.sleepjournal.model.*
import dev.dchung.sleepjournal.data.JournalRepository
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class JournalViewModel @Inject constructor(
private val repository: JournalRepository,
private val saved: SavedStateHandle
) : ViewModel() {
// The ViewModel retains screen inputs through rotation. SavedStateHandle
// restores small inputs after eligible recreation; it is not a database.
private val userInput = MutableStateFlow(UserInput(
query = saved["query"] ?: "", favoritesOnly = saved["favorites"] ?: false,
selected = saved.get<String>("selected"), draft = saved["draft"] ?: ""))
private val operation = MutableStateFlow<Operation>(Operation.Idle)
private val retry = MutableStateFlow(0)
// Retry starts a fresh read subscription after a terminal read failure.
// Rethrow cancellation so leaving the owner can stop upstream work.
private val readFlow: Flow<JournalUiState> = retry.flatMapLatest {
repository.observe().map { JournalUiState(loadState = LoadState.Ready, journal = it) }
.onStart { emit(JournalUiState()) }
.catch { failure ->
if (failure is CancellationException) throw failure
emit(JournalUiState(loadState = LoadState.Failure,
errorMessage = failure.message ?: "Cannot read journal"))
}
}
// Combine the latest data and inputs into one screen snapshot.
// stateIn exposes a hot StateFlow with a current value; this upstream shares
// one subscription while observed and stops after five seconds without collectors.
val uiState: StateFlow<JournalUiState> = combine(readFlow, userInput, operation, repository.offline) { readState, input, op, offline ->
readState.copy(userInput = input, operation = op, offline = offline)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), JournalUiState())
init { runOperation { repository.seed() } }
fun reload() { retry.update { it + 1 } }
// MVVM uses named functions and copy to update immutable inputs directly.
fun onQueryChanged(value: String) = updateUserInput(userInput.value.copy(query = value))
fun onFavoritesChanged(value: Boolean) = updateUserInput(userInput.value.copy(favoritesOnly = value))
fun onNightSelected(id: String?) = updateUserInput(userInput.value.copy(selected = id, draft = ""))
fun onDraftChanged(value: String) = updateUserInput(userInput.value.copy(draft = value))
fun onFavoriteClicked(id: String) = runOperation { repository.toggle(id) }
fun onOfflineChanged(value: Boolean) = repository.setOffline(value)
fun onSyncClicked() = runOperation { repository.sync() }
fun onSaveClicked() {
val form = userInput.value
val id = form.selected ?: return
runOperation {
repository.addNote(id, form.draft)
// Do not clear a newer draft typed while this save was running.
if (userInput.value.selected == id && userInput.value.draft == form.draft) {
updateUserInput(userInput.value.copy(draft = ""))
}
}
}
private fun updateUserInput(value: UserInput) {
userInput.value = value
saved["query"] = value.query
saved["favorites"] = value.favoritesOnly
saved["selected"] = value.selected
saved["draft"] = value.draft
}
private fun runOperation(block: suspend () -> Unit) {
if (operation.value == Operation.Busy) return
operation.value = Operation.Busy
// The ViewModel owns this work; clearing it cancels the coroutine.
// A durable note lives in Room and can be retried by a worker later.
viewModelScope.launch {
try { block(); operation.value = Operation.Idle }
catch (cancelled: CancellationException) { operation.value = Operation.Idle; throw cancelled }
catch (failure: Exception) { operation.value = Operation.Failed(failure.message ?: "Operation failed") }
}
}
}
Selected architecture checkpoint. Replace route and ViewModel together; never install both variants.
This MVVM example exposes named ViewModel functions and updates immutable state directly. The screen uses a shared event callback for reusable rendering and tests; the route maps that UI contract to direct calls. Typed callbacks are compatible with MVVM, so event syntax alone does not identify MVI.
MVI injected final checkpoint
Choose one architecture path and fully replace only the files shown for that path. Do not compile both JournalViewModel classes in one package.
Replace the WS04 ViewModel and route together. The constructor now requires JournalRepository and SavedStateHandle, and the route uses hiltViewModel().
Follow the route adapter from a JournalEvent to JournalIntent. onIntent first applies the pure reducer, then owns any coroutine or repository work.
Keep JournalIntent.kt and JournalReducer.kt beside the ViewModel. JournalResult.NoteSaved re-enters the pure reducer after a save; repository journal data and operation state still come from their existing flows.
package dev.dchung.sleepjournal.ui.journal
import dev.dchung.sleepjournal.model.*
// MVI names every user action so transitions have explicit inputs.
// Use named ViewModel functions instead if they are easier to finish and explain.
sealed interface JournalIntent {
data class QueryChanged(val value: String) : JournalIntent
data class FavoritesChanged(val value: Boolean) : JournalIntent
data class NightSelected(val id: String?) : JournalIntent
data class DraftChanged(val value: String) : JournalIntent
data class FavoriteClicked(val id: String) : JournalIntent
data object SaveClicked : JournalIntent
data object SyncClicked : JournalIntent
data class OfflineChanged(val value: Boolean) : JournalIntent
data object RetryRead : JournalIntent
}
// A save finishes later. Carry the submitted values back so completion
// can avoid clearing a newer draft or a different selected night.
sealed interface JournalResult {
data class NoteSaved(val nightId: String, val submittedDraft: String) : JournalResult
}
Selected architecture checkpoint. Replace route and ViewModel together; never install both variants.
package dev.dchung.sleepjournal.ui.journal
import dev.dchung.sleepjournal.model.*
// A reducer returns the next UserInput value. Same inputs give the same output.
// It does not launch work or write storage; the ViewModel owns those effects.
fun reduceJournal(userInput: UserInput, intent: JournalIntent): UserInput = when (intent) {
is JournalIntent.QueryChanged -> userInput.copy(query = intent.value)
is JournalIntent.FavoritesChanged -> userInput.copy(favoritesOnly = intent.value)
is JournalIntent.NightSelected -> userInput.copy(selected = intent.id, draft = "")
is JournalIntent.DraftChanged -> userInput.copy(draft = intent.value)
is JournalIntent.FavoriteClicked, JournalIntent.SaveClicked, JournalIntent.SyncClicked,
is JournalIntent.OfflineChanged, JournalIntent.RetryRead -> userInput
}
// Only clear the draft that actually finished saving.
// This reducer owns userInput/results, not every JournalUiState transition.
fun reduceJournal(userInput: UserInput, result: JournalResult): UserInput = when (result) {
is JournalResult.NoteSaved ->
if (userInput.selected == result.nightId && userInput.draft == result.submittedDraft)
userInput.copy(draft = "") else userInput
}
Selected architecture checkpoint. Replace route and ViewModel together; never install both variants.
package dev.dchung.sleepjournal.ui.journal
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.hilt.navigation.compose.hiltViewModel
// The route owns Android-specific wiring; the screen only renders values.
// Lifecycle-aware collection pauses when this UI is not active.
@Composable
fun JournalRoute(modifier: Modifier = Modifier, viewModel: JournalViewModel = hiltViewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
// Map the common teaching callback to this path's ViewModel functions or intents.
JournalScreen(state = state, onEvent = { event ->
val intent = when (event) {
is JournalEvent.Query -> JournalIntent.QueryChanged(event.value)
is JournalEvent.Favorites -> JournalIntent.FavoritesChanged(event.value)
is JournalEvent.Select -> JournalIntent.NightSelected(event.id)
is JournalEvent.Draft -> JournalIntent.DraftChanged(event.value)
is JournalEvent.Favorite -> JournalIntent.FavoriteClicked(event.id)
JournalEvent.SaveNote -> JournalIntent.SaveClicked
JournalEvent.Sync -> JournalIntent.SyncClicked
is JournalEvent.Offline -> JournalIntent.OfflineChanged(event.value)
}
viewModel.onIntent(intent)
}, onReload = { viewModel.onIntent(JournalIntent.RetryRead) }, modifier = modifier)
}
Selected architecture checkpoint. Replace route and ViewModel together; never install both variants.
package dev.dchung.sleepjournal.ui.journal
import dev.dchung.sleepjournal.model.*
import dev.dchung.sleepjournal.data.JournalRepository
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class JournalViewModel @Inject constructor(
private val repository: JournalRepository,
private val saved: SavedStateHandle
) : ViewModel() {
// The ViewModel retains screen inputs through rotation. SavedStateHandle
// restores small inputs after eligible recreation; it is not a database.
private val userInput = MutableStateFlow(UserInput(
query = saved["query"] ?: "", favoritesOnly = saved["favorites"] ?: false,
selected = saved.get<String>("selected"), draft = saved["draft"] ?: ""))
private val operation = MutableStateFlow<Operation>(Operation.Idle)
private val retry = MutableStateFlow(0)
// Retry starts a fresh read subscription after a terminal read failure.
// Rethrow cancellation so leaving the owner can stop upstream work.
private val readFlow: Flow<JournalUiState> = retry.flatMapLatest {
repository.observe().map { JournalUiState(loadState = LoadState.Ready, journal = it) }
.onStart { emit(JournalUiState()) }
.catch { failure ->
if (failure is CancellationException) throw failure
emit(JournalUiState(loadState = LoadState.Failure,
errorMessage = failure.message ?: "Cannot read journal"))
}
}
// Combine the latest data and inputs into one screen snapshot.
// stateIn exposes a hot StateFlow with a current value; this upstream shares
// one subscription while observed and stops after five seconds without collectors.
val uiState: StateFlow<JournalUiState> = combine(readFlow, userInput, operation, repository.offline) { readState, input, op, offline ->
readState.copy(userInput = input, operation = op, offline = offline)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), JournalUiState())
init { runOperation { repository.seed() } }
// The reducer changes input state; this ViewModel performs save/sync effects.
fun onIntent(intent: JournalIntent) {
updateUserInput(reduceJournal(userInput.value, intent))
when (intent) {
is JournalIntent.FavoriteClicked -> runOperation { repository.toggle(intent.id) }
JournalIntent.SaveClicked -> {
val form = userInput.value
val id = form.selected ?: return
runOperation {
repository.addNote(id, form.draft)
updateUserInput(reduceJournal(userInput.value, JournalResult.NoteSaved(id, form.draft)))
}
}
JournalIntent.SyncClicked -> runOperation { repository.sync() }
is JournalIntent.OfflineChanged -> repository.setOffline(intent.value)
JournalIntent.RetryRead -> retry.update { it + 1 }
is JournalIntent.QueryChanged, is JournalIntent.FavoritesChanged,
is JournalIntent.NightSelected, is JournalIntent.DraftChanged -> Unit
}
}
private fun updateUserInput(value: UserInput) {
userInput.value = value
saved["query"] = value.query
saved["favorites"] = value.favoritesOnly
saved["selected"] = value.selected
saved["draft"] = value.draft
}
private fun runOperation(block: suspend () -> Unit) {
if (operation.value == Operation.Busy) return
operation.value = Operation.Busy
// The ViewModel owns this work; clearing it cancels the coroutine.
// A durable note lives in Room and can be retried by a worker later.
viewModelScope.launch {
try { block(); operation.value = Operation.Idle }
catch (cancelled: CancellationException) { operation.value = Operation.Idle; throw cancelled }
catch (failure: Exception) { operation.value = Operation.Failed(failure.message ?: "Operation failed") }
}
}
}
Selected architecture checkpoint. Replace route and ViewModel together; never install both variants.
This MVI example adds explicit typed intents, pure input/result transitions, and ViewModel-owned effects. A reducer cannot call Room, launch a coroutine, or write SavedStateHandle. MVVM and MVI overlap: Android ViewModel is the lifecycle owner in both, and neither approach requires a new framework.
Explain it, then change it yourself
Hilt constructs repositories and workers. Singleton scopes share the backend and sync mutex. Repository functions switch to IO themselves; callers remain main-safe. Room suspend queries already handle their execution. This app uses one dispatcher qualifier to make any future blocking boundary explicit.
WS09Save offline and distinguish acceptance from acknowledgement
Make a note useful before synchronization succeeds.
Piece
Why we add it
Keep it simple
Local source of truth
The UI observes Room, so offline saves and later acknowledgements use the same read path.
For an online-only prompt, handle its loading and save result without adding offline synchronization.
Interviewer follow-up
Does this prove exactly-once delivery?
No. The stable ID supports deduplication, but a real server must retain and enforce idempotency. This backend simulates receipts locally.
Do this
Turn on Demo offline before saving a new note.
Save Window was cold. Inspect the Pending note row and its UUID in Database Inspector.
Press Sync now while offline. Keep the note and show an operation error. Turn offline off and press Sync now again.
Inspect demo_receipts. The receipt operation ID matches the note ID.
Explain it, then change it yourself
The UI reads Room, not the simulated backend return value. Pending means stored locally, not confirmed remotely. The note UUID is created once at insertion and reused on each attempt. The simulated server receipt is also persisted locally for this exercise, so it survives this app process; it does not model a real independent server.
WS10Retry the durable queue across lifecycle changes
Separate immediate user work from deferred durable work.
Piece
Why we add it
Keep it simple
WorkManager
It schedules retryable work independently of a visible screen.
Use viewModelScope when completing the action only while its owner exists is acceptable.
Startup reconciliation
A crash can happen between inserting a note and enqueueing its worker. Startup schedules another drain.
Explain that this recovery happens on the next launch; the two transactions are not atomic.
Interviewer follow-up
Will WorkManager run exactly when the backoff expires?
No. The OS can delay it, and force-stop pauses scheduling until launch. The pending row remains durable while waiting.
Do this
Read JournalSyncWorker.doWork. IOException retries with backoff; cancellation propagates.
Save offline, return to the home screen, then reopen the app. Turn offline off and wait for acknowledgement.
Compare the manual Sync now call in viewModelScope with the worker. Both use the same repository mutex and stable operation IDs.
Read Application.onCreate. It schedules reconciliation even after a crash between note insertion and enqueue.
Explain it, then change it yourself
Room insertion and WorkManager enqueue are separate transactions. Startup scheduling recovers their crash gap on the next launch, not atomically while the app remains unopened. Workers can be delayed by the OS, force-stop pauses scheduling until launch, and backoff is not an exact timer. A real API needs server-side idempotency retention, conflict policy, authentication, connectivity constraints, and observability.
WS11Keep failures recoverable and scopes deliberate
Explain who owns a coroutine and what survives its cancellation.
Piece
Why we add it
Keep it simple
viewModelScope
It connects foreground work to the ViewModel lifetime instead of a global coroutine.
No custom scope is needed. Durable retries belong to the worker only if the requirement needs them.
Interviewer follow-up
Does going to the background cancel viewModelScope?
No. It is cancelled when the ViewModel is cleared. Lifecycle-aware UI collection can stop in the background, and WhileSubscribed can then stop its upstream separately.
Do this
Open your selected JournalViewModel.runOperation. Both paths use the same coroutine owner and catch branches; name the owner and read each branch.
Try a note longer than 280 characters. Confirm the validation error and that no new note enters Room.
Read readFlow.catch and the retry counter. In MVVM the screen calls reload(); in MVI it dispatches RetryRead. Both start a new observable stream after a terminal read error.
Turn the app background for longer than five seconds. Room collection stops if there are no other collectors. Worker ownership does not depend on that collection.
Explain it, then change it yourself
viewModelScope follows the ViewModel lifetime, not a single recomposition. A regular structured child failure can cancel siblings; supervisor ownership isolates appropriate independent work. This app catches expected operation failures at the UI boundary and preserves cancellation. Room holds durable data; SavedStateHandle holds small restoration inputs.
WS12Trace your chosen path and optionally compare the other
Follow one user event through the owner that changes its state.
Piece
Why we add it
Keep it simple
Pure MVI reducer
The next UserInput value depends only on its current value and input. Effects stay in the ViewModel.
Use copy in named MVVM functions when that is clearer. Neither path requires a store framework.
Save completion ownership
An old save must not clear a newer draft or the draft for another selected night.
Compare the captured selection and draft before clearing. No new abstraction is needed.
Interviewer follow-up
Does this MVI reducer own the entire UiState?
No. It owns UserInput and save-completion transitions. Repository data and operation status remain in their existing flows; the ViewModel combines them.
Do this
MVVM: trace a query callback through the route adapter, onQueryChanged, UserInput.copy, combine, uiState and lifecycle-aware screen collection.
MVI: trace QueryChanged through onIntent, reduceJournal, userInput, combine, uiState and lifecycle-aware screen collection.
Trace save separately: both ViewModels capture selected ID/draft, write through the repository, and let Room observation update journal data. MVVM checks ownership before clearing; MVI feeds NoteSaved into its pure reducer.
Open another night or type a new draft during a save. Completion must not clear another selection’s draft.
Optional comparison after WS08: back up the working project, replace only ui/journal/JournalViewModel.kt and ui/journal/JournalRoute.kt with the other final path, and add/remove JournalIntent.kt and JournalReducer.kt as appropriate. Never add a second class with the same package/name. Shared data, DI, manifest, Activity and tests stay the same.
Do not switch during an interview unless the prompt requires it. Pick the simplest approach you can explain and finish.
Explain it, then change it yourself
MVVM describes presentation layers, while MVI emphasizes typed inputs and predictable state transitions. An Android ViewModel can implement either. The shared screen emits JournalEvent for testability; MVVM routes these to functions and MVI routes them to intents. This small MVI reducer owns screen inputs/results, while repository data and operation flows retain their existing owners. A single reducer for every JournalUiState transition is an optional store-style extension; it is not implemented or needed here. No store library, additional module or use-case wrapper is required.
WS13Observe composition, layout, and drawing separately
Move a state read to the phase that needs it and observe the difference.
Piece
Why we add it
Keep it simple
Optional phase lab
It demonstrates that a draw-only state read can invalidate drawing without requiring that composable to recompose.
Normal screen state is fine. Do not add phase tricks without a measured problem.
Interviewer follow-up
Does every redraw imply recomposition?
No. Composition, layout and drawing are distinct phases. Which work is invalidated depends on where the state is read.
Do this
Create ui/journal/PhaseLab.kt. In MainActivity temporarily replace JournalRoute() with PhaseLab() and replace its import with dev.dchung.sleepjournal.ui.journal.PhaseLab.
Run and move the slider. Filter Logcat by PhaseLab, then inspect recomposition counts in Layout Inspector.
Compare CompositionMeter, DrawOnlyMeter, and LayoutOnlyDot. Each reads the same state in a different phase.
Restore JournalRoute() and its import after the experiment. Keep PhaseLab as an optional exercise file.
package dev.dchung.sleepjournal.ui.journal
import android.util.Log
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import kotlin.math.roundToInt
// This optional lab isolates where state is read. Optimize phases after
// measuring a frequent update, not before the first working screen.
@Composable
fun PhaseLab(modifier: Modifier = Modifier) {
val progress = remember { mutableFloatStateOf(0.5f) }
SideEffect { Log.d("PhaseLab", "Parent composed") }
Column(modifier.padding(16.dp)) {
PhaseControls(progress)
CompositionMeter({ progress.floatValue })
DrawOnlyMeter({ progress.floatValue })
LayoutOnlyDot({ progress.floatValue })
}
}
@Composable
private fun PhaseControls(progress: MutableFloatState) {
Slider(progress.floatValue, { progress.floatValue = it }, Modifier.testTag("phase-slider"))
}
@Composable
private fun CompositionMeter(progress: () -> Float) {
val fraction = progress()
val color = MaterialTheme.colorScheme.primary
SideEffect { Log.d("PhaseLab", "Composition meter composed") }
Canvas(Modifier.fillMaxWidth().height(24.dp).testTag("composition-meter")) {
drawRect(color, size = size.copy(width = size.width * fraction))
}
}
@Composable
private fun DrawOnlyMeter(progress: () -> Float) {
val color = MaterialTheme.colorScheme.secondary
SideEffect { Log.d("PhaseLab", "Draw-only meter composed") }
Canvas(Modifier.fillMaxWidth().height(24.dp).testTag("draw-meter")) {
drawRect(color, size = size.copy(width = size.width * progress()))
Log.d("PhaseLab", "Draw-only meter drew")
}
}
@Composable
private fun LayoutOnlyDot(progress: () -> Float) {
val color = MaterialTheme.colorScheme.tertiary
SideEffect { Log.d("PhaseLab", "Layout-only dot composed") }
Box(Modifier.width(100.dp).height(24.dp)) {
Box(Modifier.offset {
Log.d("PhaseLab", "Layout-only dot placed")
IntOffset((84.dp.toPx() * progress()).roundToInt(), 0)
}.size(16.dp).background(color).testTag("layout-dot"))
}
}
Create or fully replace this file. Type the code before comparing with the final reference.
Explain it, then change it yourself
A state read registers an invalidation dependency in the phase where it occurs. Recomposition, layout, and redraw are different work; Android can redraw without recomposing. Phase deferral is useful for measured frequent visual changes, not a reason to avoid simple screen state. This debug log experiment is not a performance benchmark.
WS14Check pure behavior and coroutine contracts with unit tests
Turn the observed behavior into deterministic checks.
Piece
Why we add it
Keep it simple
Existing unit checks
Pure filtering and Flow ownership can be checked without an emulator.
In a short interview, check the requested behavior first and run relevant existing checks when available.
Interviewer follow-up
Do these passing tests prove Room and Hilt work?
No. They check presentation helpers and Flow contracts. Generated database/injection code, path-specific ViewModels and device behavior need their own verification.
Do this
Create JournalModelsTest.kt. Run it and the FlowOwnershipTest added in WS05.
These existing domain and Flow tests are shared by both paths. JournalModelsTest exercises the shared reduce example; it does not prove the MVVM direct functions or the path-specific MVI reducer/ViewModel. Keep that coverage boundary explicit.
Read each asserted output before running. No Android device is needed for these domain and coroutine tests.
Run the tests using Gradle on your own machine. The author separately compiled these pure files and ran their six tests with the Kotlin CLI.
Create or fully replace this file. Type the code before comparing with the final reference.
Explain it, then change it yourself
The tests check filtering results, draft ownership, producer starts, subscription timeout, and stale search cancellation. They do not prove Room migrations, WorkerFactory wiring, or Compose device behavior. A complete production suite would add repository, WorkManager, persistence, and failure integration checks. Both architecture paths retain these test symbols. Their six tests are shared examples, not architecture-variant ViewModel or reducer integration coverage.
Exercise visible behavior through the screen contract.
Piece
Why we add it
Keep it simple
Stable semantics tags
Checks can identify a control reliably without relying on its screen position.
Use the existing screen contract. No Hilt or Room is needed to render fixed state.
Interviewer follow-up
What would you verify after a stateless screen check?
Run the real feature, rotate, relaunch, and check persistence or offline behavior if required. A screen-only check does not cover the route or repository.
Do this
Create JournalScreenTest.kt under app/src/androidTest/java/dev/dchung/sleepjournal/ui/journal. Its package matches the screen package.
Both paths keep the same JournalScreen(state, onEvent, onReload) contract and JournalEvent type, so this test file needs no architecture-specific rewrite. The route adapters and ViewModels are outside these stateless screen tests.
Start an emulator from Android Studio and run connectedDebugAndroidTest.
The tests render a stateless screen with observable fake UI state. They do not instantiate Hilt, Room, or a network client.
Run the full app manually after the screen tests to cover persistence and offline synchronization.
package dev.dchung.sleepjournal.ui.journal
import dev.dchung.sleepjournal.model.*
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createComposeRule
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
class JournalScreenTest {
@get:Rule val compose = createComposeRule()
@Test fun filteringAndOpeningDetailUseTheVisibleControls() {
val state = mutableStateOf(JournalUiState(loadState = LoadState.Ready, journal = Journal(sampleNights, emptyList())))
compose.setContent {
MaterialTheme {
JournalScreen(state.value, { event -> state.value = state.value.copy(userInput = reduce(state.value.userInput, event)) }, {})
}
}
compose.onNodeWithTag("sleep-query").performTextInput("10-02")
compose.onNodeWithTag("night-n1").assertDoesNotExist()
compose.onNodeWithTag("night-n2").performClick()
compose.onNodeWithTag("note-input").assertIsDisplayed()
compose.onNodeWithTag("back-detail").performClick()
compose.onNodeWithTag("sleep-query").assertTextContains("10-02")
}
@Test fun savingEmitsAnEventAndPendingNotesStayVisible() {
var saved = 0
val journal = Journal(sampleNights, listOf(Note("op1", "n1", "Window was cold", SyncStatus.Pending)))
val state = JournalUiState(loadState = LoadState.Ready, journal = journal,
userInput = UserInput(selected = "n1", draft = "New note"), offline = true)
compose.setContent { MaterialTheme { JournalScreen(state, { if (it == JournalEvent.SaveNote) saved++ }, {}) } }
compose.onNodeWithTag("note-op1").assertTextContains("Pending")
compose.onNodeWithTag("note-save").performClick()
compose.runOnIdle { assertEquals(1, saved) }
compose.onNodeWithTag("sync-status").assertTextEquals("Offline")
}
}
Create or fully replace this file. Type the code before comparing with the final reference.
Explain it, then change it yourself
Compose synchronizes test actions with its UI work. Virtual coroutine time and Compose animation time are different clocks. Stable tags select controls; assertions must verify results. Stateless screen tests are a first layer and do not replace an end-to-end device run through the Activity and storage.
WS16Rebuild the core in thirty minutes and transfer it
Practice choosing the smallest feature scope under interview timing.
Piece
Why we add it
Keep it simple
MVP first
A runnable feature demonstrates the behavior and leaves time to check it.
Use memory, MVVM functions, plain strings and a small UiState. Add the rest only when the prompt needs it.
Interviewer follow-up
What would you cut first if time was running out?
Optional setup and architecture layers, not the required user interaction. I would tell the interviewer what remains and show the behavior I have actually run.
Do this
Create a second empty project. Do not copy the final reference into it.
Spend five minutes on models and a fake repository, ten on ViewModel and lifecycle-aware list collection, ten on one interaction, and five on error or empty state and a check.
Choose EP01 chat or EP08 sleep history. Map night IDs to message or row IDs and implement the requested feature.
Add Room, Hilt, and durable work only when the prompt asks for persistence or synchronization. Explain the extension rather than spending the entire short interview on setup.
Compare your result with the completed journal only after your independent attempt.
Explain it, then change it yourself
The transferable skill is choosing state ownership and a useful observable checkpoint before adding infrastructure. This workshop covers common patterns in the practice bank; company prompts can add API, image, paging, build, or media requirements that need their own work.
After completing this tutorial, close the reference. Pick a practice problem and implement it from its brief. Explain which workshop pattern you are reusing and which domain rule is different.
Reuse operation state and cancellation handling, but define device command ordering and observed state separately. A real hardware control cannot treat a local receipt as physical confirmation.
Use cancellable searches for changing location, stable venue IDs for favorites, and an offline local source. Add location lifecycle and permissions only when required.
Each download is a complete project with the shared storage and sync code plus its selected screen/ViewModel implementation.
On October 10 after simplifying screen state, both actual WS04 ViewModels compiled with Kotlin CLI, cached lifecycle 2.11.0 and coroutines 1.9.0. Both final logic variants compiled after removing Hilt annotations only in temporary copies. All four runs exited 0 without diagnostics. The shared shape is a plain JournalUiState data class containing a Loading/Ready/Failure LoadState enum, non-null Journal data, errorMessage, UserInput, separate Operation and offline status. Compose uses simple if/else checks. This verifies Kotlin logic, not pinned lifecycle 2.9.0, Compose, injection or generated Room/Hilt code. IDs remain plain strings and note UUIDs persist as retry keys. Current tests could not rerun because JUnit, Hamcrest and coroutines-test jars are missing. Earlier successful tests are historical. The existing Gradle gate fails before compilation because FileLockContentionHandler cannot create a socket: Operation not permitted. Full APK and device paths remain unverified. No new tests were added.