David Chung · Eight Sleep · 30-minute Android interview

Pick a problem.
Build the feature.

11 independent Android practice problems to build from an empty project, with requirements, sample data, and follow-ups. Study the 18 fundamentals lessons with extra depth on Flow and coroutines, Compose recomposition and drawing, architecture, offline sync, Hilt, and unit/UI tests. Use the 20 recall cards afterward.

Updated October 12, 2026 · Recovered exercises and adaptations are labeled. Exact Eight Sleep technical questions were not verified.

Fundamentals for the practice problems

Learn the fundamentals, then rebuild them in your own empty project. Flow and coroutines get the most depth. Compose lessons cover state, recomposition, layout, drawing, stability and performance diagnosis. The examples share one small chat contract so the ViewModel and tests fit together.

Original educational examples synthesized from primary Android and Kotlin documentation checked October 9, 2026. Framework snippets need the listed Android dependencies and your package names. The build-along workshop has separate MVVM and MVI reference projects with the folder structure shown below.

18 lessons
AF01Start an empty project and build one vertical slice

Fundamentals lesson · original examples

Start with Android Studio Empty Activity, Kotlin, Compose and Material 3. Keep one app module for the timed attempt. Create model/, data/ and ui/ packages, then implement sample data, repository, ViewModel, and screen. Make loading, empty, content and failure observable before adding persistence.

Folders and ownership

app/src/main/java/dev/dchung/sleepjournal/
  MainActivity.kt
  JournalApplication.kt
  model/
    SleepNight.kt, Note.kt, Journal.kt, SampleNights.kt
  ui/journal/
    JournalRoute.kt, JournalScreen.kt, JournalViewModel.kt
    JournalUiState.kt, JournalEvent.kt, JournalPresentation.kt
    JournalIntent.kt, JournalReducer.kt   // MVI path only
    PhaseLab.kt                           // optional WS13 experiment
  data/
    JournalRepository.kt, RoomJournalRepository.kt
    local/JournalDatabase.kt, JournalDao.kt, JournalEntities.kt
    remote/DemoBackend.kt, JournalDtos.kt
  sync/
    JournalSyncWorker.kt, SyncQueue.kt
  di/
    JournalModule.kt, IoDispatcher.kt

The workshop uses these package boundaries in its actual snippets and reference downloads. Use plain String IDs for nights and notes. Keep ViewModels and UI state beside their screen; database entities and network DTOs stay in data.

NeedDependency or setup
Screen + ViewModelUse the template Compose setup. Add lifecycle-viewmodel-ktx, lifecycle-runtime-compose, and lifecycle-viewmodel-compose.
Asynchronous workkotlinx-coroutines-android. Pick a Kotlin-compatible stable version.
HTTP + JSONChoose Retrofit with a converter or Ktor with serialization. Add INTERNET permission. Map DTOs to domain models.
Local dataRoom runtime + room-ktx and its KSP compiler. Use DataStore for preferences.
DIHilt Android, its compiler through KSP, and the Hilt Gradle plugin. Use matching Hilt versions.
Unit testsJUnit 4 + kotlinx-coroutines-test. Match the coroutines-core and test versions.
UI testsCompose ui-test-junit4 plus androidx.test.ext:junit and test:runner in androidTestImplementation. Use ui-test-manifest in debugImplementation and AndroidJUnitRunner.

Use the current Compose BOM for Compose artifacts. Lifecycle, Room, Hilt and coroutines are versioned separately. Use KSP compatible with the project Kotlin/compiler setup. Sync and run an empty screen first; dependency repair should happen before your timed rehearsal.

30-minute order

  • First 3 minutes: restate the behavior, identify the state owner, and show the sample model.
  • Next 15 minutes: finish one usable path through repository, ViewModel and Compose.
  • Next 7 minutes: handle failure/retry and one meaningful unit test.
  • Last 5 minutes: run the feature, explain lifecycle/cancellation, and show a UI test or describe the remaining coverage.
  • For the longer follow-up, add Room, WorkManager and Hilt. Ask what libraries the interview project already provides.

Primary references: Android build configuration

AF02MVVM, MVI and state ownership

Fundamentals lesson · original examples

MVVM assigns screen decisions to a ViewModel and persistence/network behavior to a repository. MVI usually makes intents, state transitions and side effects explicit. The terms overlap: a ViewModel can expose immutable state and process intents with a pure reducer. Neither name requires a framework or a new module.

Use the same folder structure for MVVM and MVI. Put screen state and ViewModel code in ui/journal. MVI adds its intent and reducer beside them. Domain models do not import UI controls; repositories, Room entities and API adapters stay in data. The workshop provides separate implementation paths with the same storage and user-visible behavior.

For the workshop, JournalUiState holds everything Compose renders. UserInput groups the query, favorite filter, selected night and draft. LoadState is an enum with Loading, Ready and Failure; the journal data and error message are fields in UiState. Compose uses if checks to show the matching UI. The JournalRepository interface lets us replace memory storage with Room; a single concrete repository is enough when you do not need that swap.

The data path

UI action -> ViewModel or intent handler -> repository write
Room or in-memory state -> Flow -> ViewModel UI state -> Compose
Reducer: previous state + action -> next state
Effect runner: send request -> dispatch result action

Use one screen state with explicit content and operation status. Keep transient input separate from persisted entities. A domain use case is useful when rules are reused; a pass-through use case adds little to a small interview feature.

Choose the state lifetime

  • remember: composition-local state. rememberSaveable: small saveable UI values across recreation.
  • ViewModel: survives configuration changes while its owner remains; it does not survive process death.
  • SavedStateHandle: small draft/query or ID needed to reconstruct a screen after eligible state restoration.
  • Room: durable records, favorites, pending operations. DataStore: small preferences.
  • Represent important user feedback as state with an acknowledgement ID. A SharedFlow with no replay can lose it while the screen is stopped.

A small, pure MVI transition

sealed interface InputAction {
    data class Changed(val text: String) : InputAction
    data object Cleared : InputAction
}
data class InputState(val text: String = "")
fun reduce(state: InputState, action: InputAction): InputState =
    when (action) {
        is InputAction.Changed -> state.copy(text = action.text)
        InputAction.Cleared -> state.copy(text = "")
    }

Keep network calls outside the reducer. Serialize result actions if they can race.

Primary references: Android architecture recommendations

AF03Coroutines, suspension and structured concurrency

Fundamentals lesson · original examples

A coroutine is a cancellable computation, not a thread. A suspending call can release its thread while waiting. Marking a function suspend does not move blocking work off Main. Dispatchers.Main is for UI, IO for blocking I/O, and Default for CPU work. A suspend network library may already be main-safe.

launch returns a Job for work without a result. async returns Deferred for a result obtained with await. withContext changes the context for a block and returns its result. coroutineScope waits for children; a child failure normally cancels its siblings. Keep the parent-child relationship and avoid GlobalScope.

Parallel reads with a shared lifetime

suspend fun loadOverview(api: OverviewApi): Overview = coroutineScope {
    val profile = async { api.profile() }
    val history = async { api.history() }
    Overview(profile.await(), history.await())
}

Design sketch: OverviewApi and Overview are your domain contracts. If the reads depend on each other, call them sequentially.

viewModelScope is appropriate for screen-owned work. For work that must survive leaving the screen, select an explicitly owned longer scope or persistent work. A Job is cancellation/lifetime; a dispatcher is execution context. Dispatchers.IO alone is not a durable lifetime.

Senior distinctions

  • Use withContext on an injected dispatcher around blocking file/database adapters or expensive parsing. Room suspend/Flow adapters already manage their work.
  • A suspend call does not guarantee cancellation of a blocking SDK call. Use a cancellable adapter or runInterruptible when the API supports thread interruption.
  • Do not use runBlocking or Thread.sleep on Main. Prefer delay for asynchronous waits.
  • Never launch a fresh disconnected scope inside a suspending repository method merely to return early.

Primary references: Coroutine context and dispatchers · Android coroutine best practices

AF04Cancellation, failures and request races

Fundamentals lesson · original examples

Cancellation is cooperative. Suspending library calls check it; long CPU loops should periodically call ensureActive or yield. Clean up resources in finally. Catch CancellationException only to rethrow it. A broad runCatching also catches cancellation, so inspect the failure before turning it into a user error.

coroutineScope is appropriate when all child results are required. supervisorScope isolates child failures, but each child still needs explicit handling. A SupervisorJob does not make exceptions disappear. CoroutineExceptionHandler handles uncaught failures for applicable launch jobs; it is not a substitute for recovery around an awaited operation.

Handle an expected failure and preserve cancellation

try {
    val completed = withTimeoutOrNull(5_000) {
        repository.refresh()
        true
    }
    if (completed == null) showRefreshTimeout()
} catch (cancelled: CancellationException) {
    throw cancelled
} catch (failed: IOException) {
    showRefreshFailure()
}

Illustrative calls. withTimeoutOrNull returns null for the timeout it owns; cancellation from an outer scope still propagates. A UI timeout does not undo a server-side operation.

For replaceable reads, cancel the previous Job or use flatMapLatest. A cancelled client request may still finish on a server. Protect writes with an idempotency key, and protect displayed responses with a request identity when the adapter cannot cancel reliably. A request ID and a server/device revision describe different orderings.

Rehearse races

  • Search for "s" then "sleep": a late "s" result must not overwrite "sleep".
  • Tap Send twice before the first coroutine starts: claim the in-flight state synchronously.
  • Clear a chat while a delayed reply is pending: cancel its owner or reject the reply using a conversation generation.
  • Device slider: latest requested temperature can differ from acknowledged temperature; preserve both.

Primary references: Cancellation and timeouts · Coroutine exception handling

AF05Flow fundamentals: cold, hot, StateFlow and SharedFlow

Fundamentals lesson · original examples

TypeWhat collection meansUse and limitation
flow { ... }Cold. The builder executes separately for each collector.A repeatable read/stream; two collectors may create two requests or listeners.
StateFlowHot state holder. A new collector gets the current value.Has an initial value; equality-conflated and slow collectors may skip intermediate states.
SharedFlowHot broadcast with configurable replay and buffering.Each active subscriber receives emissions subject to buffering. No durable storage guarantee.
ChannelQueue with receive semantics, not a Flow state holder.Multiple receivers compete for elements. Buffering is not an exactly-once or process-death guarantee.

Flow is an interface, so seeing Flow<T> alone does not prove a source is cold. MutableStateFlow can change with no collector. A cold flow does no builder work until collection. A shared stream has one producer lifetime, which its owning scope and sharing policy control.

StateFlow compares values with equals. Publish immutable snapshots with copy; mutating a list inside the existing state can leave observers unaware. MutableStateFlow.update is atomic, but its lambda can run more than once. Keep network calls, counters with external effects, and ID generation outside that lambda.

With MutableSharedFlow(replay = 0), values emitted without subscribers are lost. extraBufferCapacity supports slow active subscribers; it does not store an offline backlog for future subscribers. replay = 1 re-delivers the latest item to new subscribers, which can repeat navigation or a snackbar.

Cold versus hot, predict the counts

var starts = 0
val cold = flow { emit(++starts) }
println(cold.first())
println(cold.first())
val hot = MutableStateFlow(7)
println(hot.first())
println(hot.first())

Run in a coroutine with kotlinx.coroutines.flow imports.

Primary references: Kotlin Flow guide · StateFlow API · SharedFlow API

AF06Choose Flow operators by ordering and loss policy

Fundamentals lesson · original examples

NeedOperatorSenior caveat
Transform each valuemapSequential by default; expensive CPU mapping needs an appropriate dispatcher.
Compose current valuescombineEmits after each source has emitted; uses the latest value from each.
Pair corresponding valueszipPairs one-for-one and ends when pairing can no longer continue.
Ignore unchanged valuesdistinctUntilChangedUseful after normalization. StateFlow already suppresses equal state values.
Wait until typing pausesdebounceLimits search starts, but old work may continue during the debounce window.
Replace the previous inner streamflatMapLatestCancels the previous inner collection; suitable for queries or selected IDs.
Replace collector workcollectLatestCancels the previous collector block, not arbitrary external side effects.
Serialize inner streamsflatMapConcatPreserves order; an infinite inner stream prevents later ones from starting.
Concurrent inner streamsflatMapMergeInterleaves results; bound concurrency and explain why ordering is unnecessary.
Allow producer/consumer overlapbufferA bounded queue absorbs bursts; it does not fix sustained consumer overload.
Keep latest pending valueconflateDrops older values awaiting collection. Fine for progress, unsafe for transaction records.
Move upstream contextflowOnChanges operators above it, not the final collector or everything in the pipeline.
Recover upstream errorscatch / retryWhenPlacement determines what is caught or restarted. Do not retry permanent failures forever.

Cancel a search immediately, then debounce the next start

@OptIn(ExperimentalCoroutinesApi::class)
fun searchResults(query: Flow<String>, api: SearchApi): Flow<SearchUiState> =
    query.map { it.trim() }.distinctUntilChanged().flatMapLatest { text ->
        flow<SearchUiState> {
            if (text.isBlank()) {
                emit(SearchUiState.Empty)
            } else {
                emit(SearchUiState.Loading(text))
                delay(300)
                emit(SearchUiState.Content(text, api.search(text)))
            }
        }.catch { error ->
            if (error is IOException) emit(SearchUiState.Error(text))
            else throw error
        }
    }

Design sketch: define SearchApi and the sealed SearchUiState. The delay inside the new inner flow cancels old work before the wait. Loading includes the query so the UI can associate results with their input.

catch handles failures upstream of its position, not exceptions in the terminal collector. Emitting a fallback ends that failing upstream collection; it does not automatically reconnect. Catching inside flatMapLatest lets future queries create new streams. Use bounded retryWhen for recoverable transport failures.

Primary references: Flow API and operator contracts · Kotlin Flow guide

AF07Share once and collect with the Android lifecycle

Fundamentals lesson · original examples

stateIn creates shared state; shareIn creates a shared broadcast. Choose the scope first, then the start policy and replay. Eagerly starts immediately. Lazily starts on the first subscriber and continues until the owning scope ends. WhileSubscribed follows subscribers with configurable stop and replay timeouts.

One state stream for a screen

val uiState: StateFlow<HistoryUiState> = repository.observeHistory()
    .map<List<Night>, HistoryUiState> { HistoryUiState.Content(it) }
    .catch { error ->
        if (error is IOException) emit(HistoryUiState.Error)
        else throw error
    }
    .stateIn(
        viewModelScope,
        SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000),
        HistoryUiState.Loading
    )

Illustrative domain types. Put stateIn in a stable ViewModel property, not in a composable or a getter that creates it on every access.

The 5-second stop timeout delays stopping upstream after the last subscriber leaves. It is not polling, a network timeout, or disk caching. The replay cache is retained by default; replayExpirationMillis = 0 resets stateIn to its initial value after stopping. Collection can restart upstream when a subscriber returns.

Compose and Views collection

// Compose route
val state by viewModel.uiState.collectAsStateWithLifecycle()

// Fragment, use the view lifetime
viewLifecycleOwner.lifecycleScope.launch {
    viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
        launch { viewModel.uiState.collect { render(it) } }
        launch { viewModel.connection.collect { renderConnection(it) } }
    }
}

Two infinite flows need two child collectors. Sequential collect calls never reach the second stream.

An upstream failure in stateIn/shareIn terminates the sharing coroutine and goes to its scope, rather than becoming an exception delivered to subscribers. Put expected recovery before sharing. If the resulting flow completes after catch, define how retry will start new upstream work.

Primary references: StateFlow and SharedFlow on Android · WhileSubscribed API · Testing Kotlin flows

AF08A coherent MVVM example you can rebuild

Fundamentals lesson · original examples

This small chat example connects immutable messages, a repository contract, draft input and send status. It is a teaching slice, not a completed answer to EP01. Add the ping/pong delay, deletion, image handling and clear-history rules yourself. The in-memory fake is not offline storage.

Chat contract and ViewModel

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import java.io.IOException
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch

data class ChatMessage(val id: String, val text: String)
interface ChatRepository {
    fun observeMessages(): Flow<List<ChatMessage>>
    suspend fun send(text: String)
}
sealed interface SendState {
    data object Idle : SendState
    data object Sending : SendState
    data object Failed : SendState
}
data class ChatUiState(
    val messages: List<ChatMessage> = emptyList(),
    val draft: String = "",
    val send: SendState = SendState.Idle
)
class ChatViewModel(private val repository: ChatRepository) : ViewModel() {
    private val draft = MutableStateFlow("")
    private val sendState = MutableStateFlow<SendState>(SendState.Idle)
    val uiState: StateFlow<ChatUiState> = combine(
        repository.observeMessages(), draft, sendState
    ) { messages, text, status -> ChatUiState(messages, text, status) }
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ChatUiState())

    fun onDraftChanged(text: String) { draft.value = text }

    fun onSend() {
        val original = draft.value
        val text = original.trim()
        if (text.isEmpty() || sendState.value == SendState.Sending) return
        sendState.value = SendState.Sending
        viewModelScope.launch {
            try {
                repository.send(text)
                draft.compareAndSet(original, "")
                sendState.value = SendState.Idle
            } catch (cancelled: CancellationException) {
                throw cancelled
            } catch (failed: IOException) {
                sendState.value = SendState.Failed
            } finally {
                if (sendState.value == SendState.Sending) {
                    sendState.value = SendState.Idle
                }
            }
        }
    }
}

Calls to onDraftChanged/onSend come from Main. The repository contract assumes an observable local store that keeps producing; handle storage failures separately. compareAndSet prevents a completed send from clearing a newer draft.

The in-flight guard changes synchronously before launch. This covers two taps even if uiState has not emitted its new value yet. UI disabling is useful feedback, but the state owner still enforces the rule. The coroutine test in AF13 uses this exact contract and ViewModel.

Primary references: Android architecture recommendations · Android coroutine best practices

AF09Compose state, navigation and rendering

Fundamentals lesson · original examples

Keep the route responsible for obtaining the ViewModel and collecting it. Pass plain state and callbacks into a stateless screen. Hoist shared state to its common owner. Use stable item IDs, avoid network calls during composition, and model loading/empty/error explicitly.

ChatScreen.kt, compatible with AF08 and AF14

import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag

@Composable
fun ChatScreen(
    state: ChatUiState,
    onDraftChanged: (String) -> Unit,
    onSend: () -> Unit,
    modifier: Modifier = Modifier
) {
    Column(modifier) {
        LazyColumn(Modifier.weight(1f).fillMaxWidth()) {
            items(state.messages, key = { it.id }) { message ->
                Text(message.text, Modifier.testTag("message-${message.id}"))
            }
        }
        if (state.send == SendState.Failed) {
            Text("Send failed. Try again.", Modifier.testTag("send-error"))
        }
        OutlinedTextField(
            value = state.draft,
            onValueChange = onDraftChanged,
            label = { Text("Message") },
            modifier = Modifier.fillMaxWidth().testTag("chat-input")
        )
        Button(
            onClick = onSend,
            enabled = state.draft.isNotBlank() && state.send != SendState.Sending,
            modifier = Modifier.testTag("chat-send")
        ) { Text("Send") }
    }
}

Call this in a size-constrained Scaffold content area and apply its padding. The route imports lifecycle-compose collectAsStateWithLifecycle and delegates state/events.

LaunchedEffect keys control restarting a composition-owned effect. DisposableEffect is for cleanup of a registration/resource. rememberCoroutineScope supports a UI interaction tied to the composition. Do not create a player or start collection on every recomposition.

Small-feature details

  • Navigate with an ID rather than a large DTO; reconstruct detail data through the repository. Keep NavController in UI.
  • Use rememberSaveable for a simple local toggle or SavedStateHandle for a ViewModel-owned query. They are not replacements for a database.
  • For image selection, use the photo picker and a URI. Do not assume it is a filesystem path; plan URI access/copying for persistence.
  • Make content descriptions useful and keep touch targets accessible. Assert semantic behavior in UI tests.

Continue with AF17 for recomposition versus layout/redraw and AF18 for remember, derivedStateOf, snapshotFlow, stability and profiling.

Primary references: State in Compose · Navigation with Compose · Photo picker

AF10Room storage and offline reads

Fundamentals lesson · original examples

For an offline-first feature, the UI observes persisted local data and refresh writes network results into that store. Keep DTOs and database entities inside the data layer. Room fits messages/history/favorites; DataStore fits preferences. A StateFlow cache disappears with process death.

Small persisted chat table and DAO

import androidx.room.*
import kotlinx.coroutines.flow.Flow

@Entity(tableName = "messages")
data class MessageEntity(
    @PrimaryKey val id: String,
    val text: String,
    val createdAtMillis: Long,
    val pending: Boolean
)
@Dao
interface MessageDao {
    @Query("SELECT * FROM messages ORDER BY createdAtMillis, id")
    fun observeAll(): Flow<List<MessageEntity>>

    @Upsert
    suspend fun upsert(messages: List<MessageEntity>)

    @Query("SELECT * FROM messages WHERE pending = 1 ORDER BY createdAtMillis, id")
    suspend fun pending(): List<MessageEntity>
}
@Database(entities = [MessageEntity::class], version = 1, exportSchema = true)
abstract class ChatDatabase : RoomDatabase() {
    abstract fun messages(): MessageDao
}

Teaching schema, not a complete sync implementation. Persist migrations and exported schemas in a production project. Include an account/conversation key before supporting multiple users or conversations.

Offline read exercise

  • EP05 or EP11: store favorites by stable destination/venue ID.
  • Persist the favorite, restart the app with network disabled, and confirm it stays selected.
  • Refresh venue/catalog data while preserving local favorites; keep server data separate from local preference fields.
  • Distinguish first-load empty from cached empty, and refresh failure from failure to read the database.
  • Use a transaction for related writes that must become visible together. Avoid destructive migration as a default.

Primary references: Room storage · Offline-first data layer

AF11Offline writes and sync correctness

Fundamentals lesson · original examples

Choose write behavior per operation. A favorite or chat draft can update locally first. A payment or device command may require online acknowledgement. Do not label a queued operation as completed. Pending, acknowledged and failed are meaningful states for EP01 and EP09.

Durable outbox design

  • Create a stable operation ID once. In one database transaction, write the optimistic local record and an outbox row with payload, account, status and attempt metadata.
  • Enqueue persistent sync with WorkManager using appropriate constraints and bounded/backoff retry. It is deferred work, not an immediate real-time channel.
  • Worker selects or atomically claims pending work. Send the same idempotency key on each retry; the backend must support deduplication.
  • On acknowledgement, transactionally mark the operation complete and merge canonical server IDs/revisions. If the process dies after server success but before local acknowledgement, retry remains safe.
  • A unique worker name reduces overlapping work; it cannot by itself prevent duplicate server effects. Recover abandoned claims after crashes.
  • Retry transient connectivity/5xx failures. Re-authenticate before retrying authorization failures. Mark permanent validation failures for user correction.

For conflicts, define which fields the client owns and which the server owns. Use server revisions or explicit conflict responses rather than trusting device wall-clock ordering. Use tombstones or versioned deletes so a refresh cannot resurrect deleted records. Persist the incremental cursor only after successfully committing its batch.

The failure window to explain

1. Client persists operation op-123 and a pending message.
2. Server accepts op-123 and creates message server-456.
3. App dies before marking op-123 complete.
4. Worker retries op-123 after restart.
5. Server returns the original result for the same key.
6. Client merges server-456 and clears pending atomically.

Server idempotency and client reconciliation are required. This is an original design exercise.

Connectivity is a scheduling hint, not proof a request will succeed. If UI observes Room, a worker update naturally reaches the screen through Flow. For a device, queued desired state and confirmed hardware state must remain distinct.

Primary references: Offline-first data layer · WorkManager

AF12Hilt and dependency ownership

Fundamentals lesson · original examples

Constructor injection exposes dependencies and makes replacement with fakes straightforward. Hilt generates the Android object graph. Bind an interface to an implementation; provide third-party objects such as a database. Singleton, ActivityRetained and ViewModel scopes describe lifetimes, not where work executes.

Minimal Hilt wiring for the AF08 contract

import android.app.Application
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.HiltAndroidApp
import dagger.hilt.components.SingletonComponent
import javax.inject.Inject
import javax.inject.Qualifier
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*

@HiltAndroidApp
class PracticeApplication : Application()

@Singleton
class InMemoryChatRepository @Inject constructor() : ChatRepository {
    private val messages = MutableStateFlow<List<ChatMessage>>(emptyList())
    override fun observeMessages(): Flow<List<ChatMessage>> = messages.asStateFlow()
    override suspend fun send(text: String) {
        val item = ChatMessage(java.util.UUID.randomUUID().toString(), text)
        messages.update { it + item }
    }
}
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class IoDispatcher

@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
    @Binds
    abstract fun chat(impl: InMemoryChatRepository): ChatRepository
}
@Module
@InstallIn(SingletonComponent::class)
object DispatcherModule {
    @Provides @IoDispatcher
    fun io(): CoroutineDispatcher = Dispatchers.IO
}

Set android:name to .PracticeApplication in the manifest. Add @AndroidEntryPoint to the Activity. Change the AF08 ViewModel to @HiltViewModel class ChatViewModel @Inject constructor(...), with the matching imports. Use the current Android Hilt ViewModel Compose integration in the route. The in-memory implementation is a warm-up; replace the binding with a Room-backed repository for offline practice.

A main-safe data source can inject @IoDispatcher CoroutineDispatcher to move blocking work with withContext. A local unit test can instantiate ChatViewModel(fake) directly. Use Hilt test replacement only when testing the actual DI integration; AF14 tests the stateless screen without the whole graph.

Primary references: Hilt on Android

AF13Unit tests for coroutines, Flow and ViewModels

Fundamentals lesson · original examples

Use runTest with one shared TestCoroutineScheduler. StandardTestDispatcher queues work until the test yields or runs the scheduler. advanceTimeBy moves virtual time; runCurrent executes tasks at the new boundary. advanceUntilIdle drains finite queued work. Delays on real IO/Default dispatchers are not controlled by virtual time, so inject dispatchers.

Replace Main before constructing a ViewModel and reset it afterward. stateIn with WhileSubscribed needs an active collector. Put never-ending collectors in backgroundScope so runTest cancels them. Assert final StateFlow values when testing state; use an explicitly collected event list or Turbine when emission order itself is the contract.

src/test/.../ChatViewModelTest.kt, uses AF08

import java.io.IOException
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestWatcher
import org.junit.runner.Description
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.*

@OptIn(ExperimentalCoroutinesApi::class)
class MainDispatcherRule(
    val dispatcher: TestDispatcher = StandardTestDispatcher()
) : TestWatcher() {
    override fun starting(description: Description) { Dispatchers.setMain(dispatcher) }
    override fun finished(description: Description) { Dispatchers.resetMain() }
}
class FakeChatRepository : ChatRepository {
    val messages = MutableStateFlow<List<ChatMessage>>(emptyList())
    var calls = 0
    var fail = false
    override fun observeMessages(): Flow<List<ChatMessage>> = messages
    override suspend fun send(text: String) {
        calls++
        delay(1_000)
        if (fail) throw IOException("Offline")
        val item = ChatMessage("m$calls", text)
        messages.update { it + item }
    }
}
@OptIn(ExperimentalCoroutinesApi::class)
class ChatViewModelTest {
    @get:Rule val main = MainDispatcherRule()

    @Test fun blankInputDoesNotSend() = runTest {
        val repo = FakeChatRepository()
        val vm = ChatViewModel(repo)
        vm.onDraftChanged("   ")
        vm.onSend()
        runCurrent()
        assertEquals(0, repo.calls)
    }

    @Test fun duplicateTapSendsOnceAndPublishesResult() = runTest {
        val repo = FakeChatRepository()
        val vm = ChatViewModel(repo)
        backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
            vm.uiState.collect {}
        }
        vm.onDraftChanged("  ping  ")
        vm.onSend()
        vm.onSend()
        runCurrent()
        assertEquals(1, repo.calls)
        assertEquals(SendState.Sending, vm.uiState.value.send)
        advanceTimeBy(1_000)
        runCurrent()
        assertEquals(listOf(ChatMessage("m1", "ping")), vm.uiState.value.messages)
        assertEquals("", vm.uiState.value.draft)
        assertEquals(SendState.Idle, vm.uiState.value.send)
    }

    @Test fun failedSendPreservesDraftAndCanBeRetried() = runTest {
        val repo = FakeChatRepository().apply { fail = true }
        val vm = ChatViewModel(repo)
        backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
            vm.uiState.collect {}
        }
        vm.onDraftChanged("hello")
        vm.onSend()
        runCurrent()
        advanceTimeBy(1_000)
        runCurrent()
        assertEquals("hello", vm.uiState.value.draft)
        assertEquals(SendState.Failed, vm.uiState.value.send)
        assertTrue(vm.uiState.value.messages.isEmpty())
        repo.fail = false
        vm.onSend()
        runCurrent()
        advanceTimeBy(1_000)
        runCurrent()
        assertEquals(listOf(ChatMessage("m2", "hello")), vm.uiState.value.messages)
        assertEquals(SendState.Idle, vm.uiState.value.send)
    }

    @Test fun completedSendDoesNotEraseNewDraft() = runTest {
        val repo = FakeChatRepository()
        val vm = ChatViewModel(repo)
        backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
            vm.uiState.collect {}
        }
        vm.onDraftChanged("first")
        vm.onSend()
        runCurrent()
        vm.onDraftChanged("second")
        advanceTimeBy(1_000)
        runCurrent()
        assertEquals("second", vm.uiState.value.draft)
        assertEquals(listOf(ChatMessage("m1", "first")), vm.uiState.value.messages)
    }
}

These are original teaching tests for the AF08 slice. Run them in your Android project; they are not tests added to the existing downloadable apps.

src/test/.../FlowFundamentalsTest.kt

import org.junit.Assert.*
import org.junit.Test
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.test.*

@OptIn(ExperimentalCoroutinesApi::class)
class FlowFundamentalsTest {
    @Test fun coldFlowRunsPerCollector() = runTest {
        var starts = 0
        val stream = flow { emit(++starts) }
        assertEquals(1, stream.first())
        assertEquals(2, stream.first())
        assertEquals(2, starts)
    }

    @Test fun hotStateUpdatesWithoutCollectors() = runTest {
        val state = MutableStateFlow(1)
        state.value = 2
        assertEquals(2, state.first())
        assertEquals(2, state.first())
    }

    @Test fun broadcastWithoutReplayLosesUnobservedValue() = runTest {
        val events = MutableSharedFlow<String>(replay = 0)
        events.emit("before")
        val seen = mutableListOf<String>()
        backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
            events.collect { seen += it }
        }
        events.emit("after")
        assertEquals(listOf("after"), seen)
    }

    @Test fun sharedStateStartsOneUpstreamForTwoCollectors() = runTest {
        var starts = 0
        val state = flow {
            starts++
            emit(42)
            awaitCancellation()
        }.stateIn(backgroundScope, SharingStarted.WhileSubscribed(5_000), 0)
        assertEquals(0, state.value)
        assertEquals(0, starts)
        repeat(2) {
            backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
                state.collect {}
            }
        }
        runCurrent()
        assertEquals(42, state.value)
        assertEquals(1, starts)
    }

    @Test fun cancellationRunsCleanupAndPreventsDelayedWork() = runTest {
        var cleanedUp = false
        var replied = false
        val job = launch {
            try {
                delay(1_000)
                replied = true
            } finally {
                cleanedUp = true
            }
        }
        runCurrent()
        job.cancelAndJoin()
        advanceTimeBy(1_000)
        runCurrent()
        assertTrue(cleanedUp)
        assertFalse(replied)
    }
}

Five outcome-based tests for the stream and cancellation contracts. No Android framework dependency is required.

Add the hard cases in your own attempt

  • Query race: arrange a slow first result and a faster second result; prove only the current query is shown.
  • Cancellation: cancel a screen-owned job and prove it does not emit a user-visible failure.
  • Sharing: two collectors must not start two upstream requests after stateIn/shareIn.
  • Offline sync: retry the same operation ID after an interrupted acknowledgement and prove one logical server effect.
  • Algorithm: test empty input, boundaries, repeated visits and simultaneous times for EP07.

Verified here: all nine published ChatViewModelTest and FlowFundamentalsTest methods compiled and passed on the JVM using the cached AndroidX ViewModel 2.8.7, coroutines 1.9.0 and JUnit 4.13.2 libraries. This is not an APK, device or UI test run.

Primary references: Testing Kotlin coroutines · Testing Kotlin flows

AF14UI tests with Compose semantics

Fundamentals lesson · original examples

Test what a user sees and can do. Render a stateless screen with explicit state and fake callbacks for component coverage. Add a separate route/integration test with a fake repository to prove ViewModel state reaches the UI. Use semantic text, roles and stable tags; avoid coordinate taps and wall-clock sleeps.

src/androidTest/.../ChatScreenTest.kt, uses AF08 and AF09

import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class ChatScreenTest {
    @get:Rule val compose = createComposeRule()

    @Test fun displaysMessagesAndForwardsInput() {
        var typed = ""
        compose.setContent {
            MaterialTheme {
                ChatScreen(
                    ChatUiState(messages = listOf(ChatMessage("1", "ping"))),
                    onDraftChanged = { typed = it },
                    onSend = {}
                )
            }
        }
        compose.onNodeWithTag("message-1").assertTextEquals("ping")
        compose.onNodeWithTag("chat-send").assertIsNotEnabled()
        compose.onNodeWithTag("chat-input").performTextInput("hello")
        compose.runOnIdle { assertEquals("hello", typed) }
    }

    @Test fun enabledSendForwardsOneClick() {
        var sends = 0
        compose.setContent {
            MaterialTheme {
                ChatScreen(ChatUiState(draft = "hello"), {}, { sends++ })
            }
        }
        compose.onNodeWithTag("chat-send").assertIsEnabled().performClick()
        compose.runOnIdle { assertEquals(1, sends) }
    }

    @Test fun pendingSendIsDisabled() {
        compose.setContent {
            MaterialTheme {
                ChatScreen(ChatUiState(draft = "hello", send = SendState.Sending), {}, {})
            }
        }
        compose.onNodeWithTag("chat-send").assertIsNotEnabled()
    }

    @Test fun errorIsVisibleAndRetryIsAvailable() {
        compose.setContent {
            MaterialTheme {
                ChatScreen(ChatUiState(draft = "hello", send = SendState.Failed), {}, {})
            }
        }
        compose.onNodeWithTag("send-error").assertTextEquals("Send failed. Try again.")
        compose.onNodeWithTag("chat-send").assertIsEnabled()
    }
}

These tests intentionally supply fixed state; callback assertions do not prove the whole app updates. Add a route-level test with a fake repository for input -> send -> displayed reply.

Deterministic UI checks

  • Compose synchronizes its own work. For external asynchronous work use a controlled fake and waitUntil for an observable condition.
  • mainClock controls Compose frames/animations. It is separate from the kotlinx-coroutines-test scheduler. Do not advance one and assume the other advances.
  • Verify loading, empty, retry, selection/detail/back, disabled actions, and accessibility semantics.
  • Use an in-memory Room database for repository integration tests and persistent storage/relaunch coverage for offline requirements.
  • Run ./gradlew :app:testDebugUnitTest, then ./gradlew :app:connectedDebugAndroidTest with an emulator/device. Adapt the task prefix for build flavors.

The four Compose UI tests are supplied for your practice project. They have not been executed on an emulator in this environment.

Primary references: Compose testing · Compose test APIs and semantics

AF15Adapters for real-time data, location and media

Fundamentals lesson · original examples

Clean up callback registrations

fun positions(source: PositionSource): Flow<Position> = callbackFlow {
    val listener = object : PositionListener {
        override fun onPosition(value: Position) {
            // Position is replaceable snapshot data.
            trySend(value)
        }
        override fun onFailure(error: Throwable) { close(error) }
    }
    source.register(listener)
    awaitClose { source.unregister(listener) }
}.buffer(Channel.CONFLATED)

Design sketch with domain-specific PositionSource, PositionListener and Position. Import kotlinx.coroutines.channels.awaitClose and Channel plus flow APIs. Registration/unregistration must obey the SDK thread and concurrency contract. Conflation is deliberate for positions; use an explicit overflow/recovery policy for non-droppable messages.

callbackFlow is cold, so each collector registers its own listener unless you share the stream. awaitClose ties cleanup to cancellation. Handle trySend failure according to the data contract. For reliable messages, persist stable IDs and detect gaps; reconnect with a cursor and bounded backoff instead of assuming the socket delivered everything.

Problem-specific adapters

  • EP10 video: one active player policy, release/stop at the owner lifecycle, track visible item by stable ID, pause offscreen media. Media3 is a useful implementation option.
  • EP11 venues: request only the needed location permission, handle denied/approximate location, adapt the location source to Flow, cancel old venue queries. Do not prompt from recomposition.
  • EP06 files: use URI/storage access contracts, map loading and permission failure, keep debug/release or flavor configuration explicit.
  • EP01 images: use the picker contract, keep URI ownership clear, and store accessible image data when the feature must survive restart.
  • EP09 hardware: separate desired, sent and acknowledged values. Sequence commands and reject stale replies according to the device revision contract.

Primary references: callbackFlow API · Media3 ExoPlayer · Location permissions

AF16Senior review and coverage checks

Fundamentals lesson · original examples

The goal is to finish a small feature and explain its failure behavior. Use this guide for the shared building blocks, then read the specific brief for algorithm, media, location or device rules. These lessons are a preparation plan, not a guarantee of the actual interview content.

Before calling an attempt complete

  • State: one clear owner, immutable snapshots, explicit loading/empty/error/pending behavior.
  • Concurrency: child lifetime, injected dispatchers where needed, cooperative cancellation, latest-read policy, write idempotency.
  • Flow: cold/hot reasoning, sharing scope, replay/loss policy, lifecycle collection, upstream failure recovery.
  • Storage: offline reads work after restart; pending writes are durable when required; local/server IDs reconcile.
  • UI: stable IDs, useful semantics, back/navigation, no work launched from ordinary composition.
  • Tests: one outcome-changing unit test and one visible UI behavior; extend to race/offline tests in the longer attempt.
  • Delivery: run the feature and explain what is intentionally unfinished under the timebox.

Senior questions to answer aloud

  • Why Flow instead of a suspend function here? When would you use each?
  • What changes if two screens collect the same stream?
  • What happens if the screen stops halfway through a send?
  • How does stateIn handle a thrown exception and a later retry?
  • Why does a StateFlow update fail to render when you mutated its list?
  • Which values are safe to conflate? What cannot be dropped?
  • How do you keep an old search result from replacing a new one?
  • What survives process death, and how do you resume a pending write?
  • How do you test WhileSubscribed state without a real delay?
  • Where are Hilt dependencies scoped, and what changes in a fake-based test?
  • Can a redraw happen without recomposition? Where did the state read occur?
  • When does derivedStateOf help, and when is remember with input keys enough?
  • What does strong skipping change about unstable parameters?

Primary references: Android architecture recommendations

AF17Compose recomposition, layout and redraws

Fundamentals lesson · original examples

Recomposition reruns affected composable code to update the UI description. Layout measures and places nodes. Drawing renders their content. A state change can invalidate just one phase. Recomposition does not mean the whole screen was measured or redrawn, and a redraw can happen without recomposition.

Where state is readWhat can be invalidatedExample
CompositionThe reading restart scope, with layout/drawing if its output changes.Text(text = draft.value)
MeasurementThe affected measurement work and dependent later work.A custom layout reads a size preference while measuring.
PlacementAffected placement work; measurement can sometimes remain unchanged.Modifier.offset { IntOffset(position.value, 0) }
DrawingAffected drawing work, without requiring composition or layout for that read.Modifier.drawBehind { drawRect(tint.value) }

The point where value is read matters. Passing a State<T> or a provider lambda to a child can defer the read. Evaluating value in the parent first still subscribes that parent scope. A lambda only helps when the API invokes it in the later phase you intend.

Two phase-specific state reads

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import kotlin.math.roundToInt

@Composable
fun MovingMarker(positionPx: State<Float>, modifier: Modifier = Modifier) {
    Box(modifier.offset {
        IntOffset(positionPx.value.roundToInt(), 0)
    }.size(24.dp).background(Color.Green))
}

@Composable
fun TintMarker(tint: State<Color>, modifier: Modifier = Modifier) {
    Box(modifier.size(40.dp).drawBehind {
        drawRect(tint.value)
    })
}

positionPx is pixels. These examples deliberately avoid reading either value in composition. Supply remembered snapshot state and put the marker in a visible, size-constrained parent. They illustrate phase invalidation; device rendering has not been measured here.

Primary references: Compose rendering phases · Compose performance best practices

AF18Compose state tools, stability and performance diagnosis

Fundamentals lesson · original examples

ToolPurposeAvoid this mistake
remember(keys)Keep a value while that composition identity and keys remain.remember { items.sorted() } can become stale when items change.
rememberSaveableRestore supported small UI values after eligible recreation.It does not persist a message database or an arbitrary large model.
derivedStateOfExpose a derived result that changes less often than its snapshot inputs.Wrapping every string concatenation adds overhead without reducing updates.
snapshotFlowObserve snapshot reads inside its block as a cold Flow of changes.It is not the app database, and collection still needs a lifetime.
rememberUpdatedStateRead the latest callback/value inside a long-lived effect.Adding every callback to effect keys can restart a timer unnecessarily.
LaunchedEffect / DisposableEffectOwn composition-scoped work and cleanup using meaningful keys.Requests, listener registration or player creation in ordinary composition can repeat.

Remember a calculation with its actual inputs

val visible = remember(nights, query) {
    nights.filter { it.date.contains(query, ignoreCase = true) }
}

This caches a pure calculation for these inputs. For a large list, do filtering in the ViewModel on the appropriate dispatcher. It does not move work to another thread.

Use a derived threshold rather than every scroll position

val listState = rememberLazyListState()
val showJumpButton by remember(listState) {
    derivedStateOf { listState.firstVisibleItemIndex >= 3 }
}

Requires Compose runtime getValue/remember/derivedStateOf and foundation lazy rememberLazyListState imports. Use listState on the actual LazyColumn. The boolean changes less often than the underlying position.

Observe a UI threshold with a cancellable effect

val latestOnPastIntro by rememberUpdatedState(onPastIntro)
LaunchedEffect(listState) {
    snapshotFlow { listState.firstVisibleItemIndex >= 3 }
        .filter { it }
        .collect { latestOnPastIntro() }
}

Imports Compose runtime snapshotFlow, LaunchedEffect and rememberUpdatedState plus Flow filter/collect. onPastIntro is your callback. This reports each transition into the threshold while the effect is alive; it is not durable analytics. Use take(1) for a once-per-effect rule.

Stability affects whether unchanged parameters can allow skipping. Strong skipping is enabled by default with Kotlin 2.0.20 and later. Restartable composables can then be skipped even with unstable parameters. Stable parameters use equals; unstable ones use identity comparison. State invalidation can still require a rerun.

A val List<T> is read-only through that reference, not necessarily an immutable object. In-place mutation may be invisible to state observation. Publish new snapshots or use observable snapshot collections appropriately. @Stable and @Immutable promise contracts to the compiler; adding them does not repair mutable data. Verify your compiler configuration instead of applying old rules about lambdas or unstable inputs blindly.

Lazy lists and effect identity

  • Use a stable unique key so remembered item state follows its item when rows move. contentType can help lazy-layout reuse for different row shapes; it serves a different purpose from identity.
  • Store derived values with the right keys. A remembered sort with no items key can retain old data.
  • Use LaunchedEffect(itemId) when selecting a new item should restart its work. Use DisposableEffect for listener or player cleanup.
  • Keep expensive work and state writes out of ordinary composition. Composition can be repeated or abandoned; a println counter in the body is not a reliable frame metric.

Diagnose before optimizing

  • Use Layout Inspector recomposition/skip counters to locate active areas. It shows composition activity, not a complete count of drawing or GPU work.
  • Inspect Compose compiler stability/skippability reports when parameter behavior is unclear.
  • Reproduce the actual interaction and use system tracing or profiler tools to identify main-thread, layout, drawing, allocation or media work.
  • Assess performance on a representative device with a release or appropriate benchmark configuration; debug overhead can mislead.
  • Change one suspected cause, then repeat the same interaction. A lower recomposition count alone does not prove a smoother app.

Hands-on Compose experiment

  • Add a marker beside your sleep list. Use snapshot state to change its text, position and tint separately.
  • First read the position in the composable body, then move the read into Modifier.offset { ... }. Observe which composition scopes restart.
  • Move the tint read into drawBehind and compare the observed phase work.
  • Type in search while inspecting list rows. Confirm stable item keys preserve per-row state during filtering/reordering.
  • Explain the result using a state-read location and actual trace evidence, without promising a fixed recomposition count.

Primary references: Compose side effects and state tools · Strong skipping and parameter equality · Fix stability issues · Debug Compose with Layout Inspector · Diagnose stability with compiler reports · Compose performance measurement

Which fundamentals does each problem use?
ProblemStudy these lessonsApply them
EP01: Build a chat screenAF02: MVVM, MVI and state ownership · AF03: Coroutines, suspension and structured concurrency · AF04: Cancellation, failures and request races · AF05: Flow fundamentals: cold, hot, StateFlow and SharedFlow · AF07: Share once and collect with the Android lifecycle · AF08: A coherent MVVM example you can rebuild · AF09: Compose state, navigation and rendering · AF13: Unit tests for coroutines, Flow and ViewModels · AF14: UI tests with Compose semantics · AF17: Compose recomposition, layout and redraws · AF18: Compose state tools, stability and performance diagnosisFor offline chat, add AF10/AF11. Keep delayed reply work attached to a conversation lifetime; test clear/delete races.
EP02: Build a catalog with a detail screenAF01: Start an empty project and build one vertical slice · AF02: MVVM, MVI and state ownership · AF06: Choose Flow operators by ordering and loss policy · AF07: Share once and collect with the Android lifecycle · AF09: Compose state, navigation and rendering · AF13: Unit tests for coroutines, Flow and ViewModels · AF14: UI tests with Compose semantics · AF17: Compose recomposition, layout and redraws · AF18: Compose state tools, stability and performance diagnosisDTO mapping, catalog loading/error, stable IDs and detail navigation. Add cached reads in AF10 afterward.
EP03: Build a ride request boardAF02: MVVM, MVI and state ownership · AF04: Cancellation, failures and request races · AF05: Flow fundamentals: cold, hot, StateFlow and SharedFlow · AF08: A coherent MVVM example you can rebuild · AF13: Unit tests for coroutines, Flow and ViewModels · AF14: UI tests with Compose semantics · AF09: Compose state, navigation and rendering · AF17: Compose recomposition, layout and redraws · AF18: Compose state tools, stability and performance diagnosisTreat accept/decline as state transitions. Claim actions before launching and test duplicate taps.
EP04: Build ride search, filtering, and sortingAF03: Coroutines, suspension and structured concurrency · AF04: Cancellation, failures and request races · AF05: Flow fundamentals: cold, hot, StateFlow and SharedFlow · AF06: Choose Flow operators by ordering and loss policy · AF07: Share once and collect with the Android lifecycle · AF13: Unit tests for coroutines, Flow and ViewModels · AF14: UI tests with Compose semantics · AF09: Compose state, navigation and rendering · AF17: Compose recomposition, layout and redraws · AF18: Compose state tools, stability and performance diagnosisCombine filters with data, cancel obsolete reads, preserve sort order and test slow/fast query races.
EP05: Build saved destinationsAF02: MVVM, MVI and state ownership · AF05: Flow fundamentals: cold, hot, StateFlow and SharedFlow · AF09: Compose state, navigation and rendering · AF10: Room storage and offline reads · AF11: Offline writes and sync correctness · AF13: Unit tests for coroutines, Flow and ViewModels · AF14: UI tests with Compose semantics · AF17: Compose recomposition, layout and redraws · AF18: Compose state tools, stability and performance diagnosisPersist saved IDs with Room; test restart/offline access and deletion during refresh.
EP06: Build a file browser and configure build variantsAF01: Start an empty project and build one vertical slice · AF02: MVVM, MVI and state ownership · AF09: Compose state, navigation and rendering · AF12: Hilt and dependency ownership · AF14: UI tests with Compose semantics · AF15: Adapters for real-time data, location and media · AF17: Compose recomposition, layout and redraws · AF18: Compose state tools, stability and performance diagnosisConfigure build variants and permission/URI handling. Flavor-specific tests use corresponding Gradle task names.
EP07: Build the infection simulatorAF02: MVVM, MVI and state ownership · AF03: Coroutines, suspension and structured concurrency · AF13: Unit tests for coroutines, Flow and ViewModelsAlgorithm-first: define interval and ordering rules, keep calculation pure, use Default if work is expensive. UI and DI are secondary.
EP08: Build sleep history from an empty projectAF02: MVVM, MVI and state ownership · AF05: Flow fundamentals: cold, hot, StateFlow and SharedFlow · AF07: Share once and collect with the Android lifecycle · AF09: Compose state, navigation and rendering · AF10: Room storage and offline reads · AF13: Unit tests for coroutines, Flow and ViewModels · AF14: UI tests with Compose semantics · AF17: Compose recomposition, layout and redraws · AF18: Compose state tools, stability and performance diagnosisObserve cached sleep history, distinguish empty from loading, and retain useful data when refresh fails.
EP09: Build device temperature control from an empty projectAF03: Coroutines, suspension and structured concurrency · AF04: Cancellation, failures and request races · AF06: Choose Flow operators by ordering and loss policy · AF11: Offline writes and sync correctness · AF13: Unit tests for coroutines, Flow and ViewModels · AF14: UI tests with Compose semantics · AF15: Adapters for real-time data, location and media · AF09: Compose state, navigation and rendering · AF17: Compose recomposition, layout and redraws · AF18: Compose state tools, stability and performance diagnosisDesired versus confirmed device state, command identity/revision, timeout, retry and stale acknowledgement tests.
EP10: Build a vertical video feed and profileAF02: MVVM, MVI and state ownership · AF07: Share once and collect with the Android lifecycle · AF09: Compose state, navigation and rendering · AF14: UI tests with Compose semantics · AF15: Adapters for real-time data, location and media · AF17: Compose recomposition, layout and redraws · AF18: Compose state tools, stability and performance diagnosisVideo player ownership, offscreen pause/release, feed item IDs and profile navigation require Media3-specific practice.
EP11: Build a nearby venue finderAF04: Cancellation, failures and request races · AF06: Choose Flow operators by ordering and loss policy · AF07: Share once and collect with the Android lifecycle · AF10: Room storage and offline reads · AF13: Unit tests for coroutines, Flow and ViewModels · AF14: UI tests with Compose semantics · AF15: Adapters for real-time data, location and media · AF09: Compose state, navigation and rendering · AF17: Compose recomposition, layout and redraws · AF18: Compose state tools, stability and performance diagnosisLocation callback cleanup, denied/approximate location, latest venue query and durable favorites.

Build each problem from an empty project

Pick one problem and create a fresh Android Studio Empty Activity project with Kotlin and Compose. Implement your own models, state holder, repository, and UI from the brief. Use the supplied data or linked data source.

Saved prompts, reconstructed code, and guide-based adaptations are labeled separately. These briefs are not claimed to be Eight Sleep questions. No original private documents or repository files are published here.

Choose one of 11 problems
EP01Build a chat screen

Quora · 30-minute core; attachments and menus are extensions

Reconstructed from saved code. Saved Quora Android implementation. The original written prompt was not recovered.

Build from an empty project

Create a chat app in a new Kotlin and Compose project. Add text input, a Send button, message history, and a delayed local bot reply.

Core requirements

  1. Show user messages on the right and bot messages on the left.
  2. Ignore blank input. After sending, append the user message and clear the input.
  3. For the message ping, append pong after one second. Use Hello! for other messages. Replies are local in this exercise.
  4. Add a Clear all action. Keep the input and history usable after several sends.

Conversation rules

Start with an empty conversation.
User: ping
After one second, bot: pong
User: hi
Bot: Hello!

Run the feature

  • Send ping and see the delayed response.
  • Send another message and see both rows in the correct order.
  • Clear the conversation and confirm that the list is empty.

Follow-ups after the core

  • Delete one message through a long-press menu.
  • Add image selection and render an attached image.
  • Send ping twice consecutively. Explain whether each send creates a separate reply.
  • Clear while a reply is pending. Define whether that reply can appear afterward.
Hints, open after your attempt
  • Give each message an ID. Treat sending as an action rather than using message text as an effect key.
  • Keep list state in a state holder. Explain what survives rotation.

Recall concepts: A02 · A03 · A08

EP02Build a catalog with a detail screen

Robinhood · 30-minute core

Adapted from interview guides. The saved 2024 Robinhood guides require UI, networking, parsing, and navigation. They do not specify this catalog.

Retrofit version of the template: 30-minute template, Part 2

Follow-up practice: Add and edit products

Build from an empty project

Create a two-screen product catalog. Fetch the first 20 products, show their titles and prices, and open a detail screen when a row is tapped.

Core requirements

  1. Fetch GET https://dummyjson.com/products?limit=20 and parse the products array.
  2. Show loading, an error with Retry, and an empty state.
  3. Show each product title and price in a scrollable list.
  4. Open the selected product with its title, description, and price. Back returns to the list.

API contract and offline fallback

List: GET https://dummyjson.com/products?limit=20
Detail, if needed: GET https://dummyjson.com/products/{id}
Required fields: id, title, description, price.

If the service is unavailable, parse this sample locally and continue:
{
  "products": [
    {
      "id": 101,
      "title": "Demo lamp",
      "description": "A sample desk lamp.",
      "price": 24.5
    },
    {
      "id": 102,
      "title": "Demo blanket",
      "description": "A sample blanket.",
      "price": 39.0
    }
  ]
}

Run the feature

  • Open the app and see the fetched list.
  • Open a row, confirm the matching detail, and navigate back.
  • Disconnect and load again. Confirm that Retry is available.

Follow-ups after the core

  • Retain the selected product and list position after rotation.
  • Add search without letting stale responses overwrite a newer query.

Original public resource: DummyJSON product API

Hints, open after your attempt
  • Add Internet permission for HTTP requests. Keep request work off the main thread.
  • Pass the selected product ID or model to the detail destination.

Recall concepts: A01 · A04 · A06 · A10

EP03Build a ride request board

Lyft · 30-minute core; longer app practice can take 60 to 90 minutes

Adapted from the Android guide. The saved 2022 Lyft guide describes a mini app followed by feature additions. This ride board is an original practice specification.

Build from an empty project

Create a ride request app. Parse the sample data, show the available rides, and let the user accept a requested ride locally.

Core requirements

  1. Show each passenger, pickup, destination, ETA, and status.
  2. Show Accept only for requested rides.
  3. Tapping Accept changes that ride to accepted without changing other rows.
  4. Use stable ride IDs. Make the list scrollable and show a message when it is empty.

Sample ride data

{
  "rides": [
    {
      "id": "R1",
      "passenger": "Alex",
      "pickup": "Union Station",
      "destination": "North Park",
      "etaMinutes": 4,
      "status": "requested"
    },
    {
      "id": "R2",
      "passenger": "Jamie",
      "pickup": "City Hall",
      "destination": "East Market",
      "etaMinutes": 2,
      "status": "requested"
    },
    {
      "id": "R3",
      "passenger": "Sam",
      "pickup": "West Library",
      "destination": "Union Station",
      "etaMinutes": 7,
      "status": "accepted"
    }
  ]
}

Run the feature

  • See the three sample rides.
  • Accept R1. Confirm that R1 changes while R2 remains requested.
  • Confirm that an accepted ride cannot be accepted again.

Follow-ups after the core

  • Add search and sorting as EP04.
  • Explain which client state would be provisional if a real server handled acceptance.
Hints, open after your attempt
  • Use immutable row models and replace the changed row by ID.
  • Keep rendering separate from the acceptance action.

Recall concepts: A01 · A02 · A13

EP04Build ride search, filtering, and sorting

Lyft · 30-minute core

Adapted feature-addition practice. Original extension based on the feature-addition format in the saved Lyft guide.

Build from an empty project

Start another empty project. Build a list from the ride data, then add a search field, a status filter, and ETA sorting.

Core requirements

  1. Search passenger, pickup, and destination without case sensitivity.
  2. Combine search with a status filter: All, Requested, or Accepted.
  3. Sort the visible results by ascending ETA.
  4. Show a no-results message and a Clear filters action.
  5. Keep the query and filter through rotation.

Sample ride data

{
  "rides": [
    {
      "id": "R1",
      "passenger": "Alex",
      "pickup": "Union Station",
      "destination": "North Park",
      "etaMinutes": 4,
      "status": "requested"
    },
    {
      "id": "R2",
      "passenger": "Jamie",
      "pickup": "City Hall",
      "destination": "East Market",
      "etaMinutes": 2,
      "status": "requested"
    },
    {
      "id": "R3",
      "passenger": "Sam",
      "pickup": "West Library",
      "destination": "Union Station",
      "etaMinutes": 7,
      "status": "accepted"
    }
  ]
}

Run the feature

  • Search union and see R1 and R3.
  • Select Requested while that query is active and see only R1.
  • Clear the filters and see ETA order R2, R1, R3.

Follow-ups after the core

  • Debounce a remote search and prevent stale completions from replacing the latest results.
  • Discuss local filtering versus backend filtering when the list becomes large.
Hints, open after your attempt
  • Keep the original ride collection separate from the derived visible list.
  • Store user input rather than several independent copies of filtered data.

Recall concepts: A02 · A07 · A11

EP05Build saved destinations

Lyft · 30-minute core

Adapted feature-addition practice. Original persistence exercise based on the saved Lyft guide. This is not a recovered original task.

Build from an empty project

Start an empty project. Show a destination list, let users favorite destinations, and persist their choices when the app closes.

Core requirements

  1. Show a destination name and address with a favorite action.
  2. Toggle favorites by destination ID.
  3. Add a Favorites only filter and a useful empty state.
  4. Persist favorite IDs across app restart using storage you choose.
  5. Loading saved choices must not briefly overwrite them with empty defaults.

Sample destination data

{
  "destinations": [
    {
      "id": "D1",
      "name": "Home",
      "address": "10 Sample Road"
    },
    {
      "id": "D2",
      "name": "Work",
      "address": "20 Sample Street"
    },
    {
      "id": "D3",
      "name": "Gym",
      "address": "30 Sample Avenue"
    }
  ]
}

Run the feature

  • Favorite Work, enable Favorites only, and see Work.
  • Restart the app and confirm that Work remains a favorite.
  • Remove its favorite flag and see the empty message.

Follow-ups after the core

  • Add and delete custom destinations.
  • Explain how local choices would merge with a remote account.
Hints, open after your attempt
  • Separate persisted favorite IDs from the destination records.
  • Explain the source of truth while storage is loading.

Recall concepts: A01 · A07 · A16

EP06Build a file browser and configure build variants

Dropbox · 30-minute app; 60-minute app and build-variants round

Adapted from screening questions. The saved Dropbox document has eight build-tool screening questions. Its debugging preparation project omits the actual task source.

Build from an empty project

Create a file-list app with a detail screen. Then use it to practice the Gradle and build-variant topics from the Dropbox questions.

Core requirements

  1. Show file name, type, and size from the sample data.
  2. Open a file detail and provide Back navigation.
  3. Build and run the debug app.
  4. For the longer round, add demo and production flavors with distinct application IDs. Show the active flavor in the app.

Sample file data

{
  "files": [
    {
      "id": "F1",
      "name": "Sleep notes.txt",
      "type": "text",
      "sizeBytes": 2048
    },
    {
      "id": "F2",
      "name": "Room photo.jpg",
      "type": "image",
      "sizeBytes": 512000
    }
  ]
}

Run the feature

  • Open both rows and confirm the matching metadata.
  • Build debug successfully.
  • In the longer round, build both flavors and confirm that both can be installed.

Follow-ups after the core

  • Add a build-only CI workflow that assembles the app and runs lint.
  • Explain how you would upgrade Gradle and plugins without interrupting other developers.
  • Explain which build logic you would share across modules.
Hints, open after your attempt
  • Finish the visible feature before changing the build configuration.
  • Use the generated Android Studio build files as the baseline.

Recall concepts: A01 · A10

EP07Build the infection simulator

Company unconfirmed · 30-minute algorithm and minimal UI

Recovered algorithm prompt. Saved infection-simulator question handout. The company association is unconfirmed. Add the Android UI yourself.

Build from an empty project

Create an app that runs the saved visit-based infection simulation. Show the input, add a Simulate button, and display the total infected users.

Core requirements

  1. A visit has a user ID, location ID, start time, and end time.
  2. Initially infected users are contagious at time zero. New infections propagate immediately in this fictional simulation.
  3. Visits overlap only before the exclusive end time. A departure at time 8 does not overlap an arrival at time 8.
  4. A user occupies at most one location at a time. The baseline has no recovery.
  5. Return the total infected users, including the initially infected users.

Input and expected result

{
  "visits": [
    [
      0,
      0,
      1,
      3
    ],
    [
      0,
      1,
      4,
      5
    ],
    [
      0,
      2,
      8,
      9
    ],
    [
      1,
      1,
      4,
      6
    ],
    [
      2,
      2,
      7,
      9
    ],
    [
      3,
      2,
      6,
      8
    ]
  ],
  "initiallyInfected": [
    1
  ],
  "expectedTotal": 3
}
User 1 infects user 0. User 0 later infects user 2. User 3 leaves before contact.

Run the feature

  • Run the sample and see total 3.
  • Explain why user 3 is not infected.
  • Run again and confirm that the result does not depend on old UI state.

Follow-ups after the core

  • Add an incubation period of K time units.
  • Add recovery after X time units.
  • Handle visit events as a stream instead of receiving the entire list.
Hints, open after your attempt
  • Keep the simulation function separate from the UI.
  • Track time, not only a boolean infected flag, if your algorithm revisits earlier propagation.

Recall concepts: A01 · A04

EP08Build sleep history from an empty project

Eight Sleep preparation · 30-minute core

Original role-focused practice. Original exercise for this interview preparation. The actual Eight Sleep task is unknown.

Short version to drill: 30-minute template, Part 1

Build from an empty project

Create the full sleep-history feature yourself. Add your own models, repository, ViewModel, and Compose screen in a new project.

Core requirements

  1. Load recent nights with a simulated 600-millisecond delay.
  2. Show loading, a date and duration list, an empty state, and an error with Retry.
  3. Format durations as hours and minutes.
  4. Make Retry clear the previous error and load again. Avoid duplicate active loads.
  5. Use stable night IDs and lifecycle-aware state collection.

Sample nights and response modes

{
  "nights": [
    {
      "id": "N1",
      "date": "2026-10-07",
      "durationMinutes": 435
    },
    {
      "id": "N2",
      "date": "2026-10-06",
      "durationMinutes": 400
    },
    {
      "id": "N3",
      "date": "2026-10-05",
      "durationMinutes": 480
    }
  ]
}
Support local response modes DATA, ERROR, and EMPTY. After an ERROR result, the next load returns DATA.

Run the feature

  • See loading followed by durations 7 h 15 min, 6 h 40 min, and 8 h 0 min.
  • Choose ERROR, then Retry, and see the data.
  • Choose EMPTY and see an empty message. Rotate during a load and explain the behavior.

Follow-ups after the core

  • Add refresh while retaining old content.
  • Define which result wins if overlapping refreshes are allowed.
Hints, open after your attempt
  • Keep request state in the ViewModel.
  • Use the reference implementation only after your own attempt.

Recall concepts: A02 · A03 · A06 · A10

EP09Build device temperature control from an empty project

Eight Sleep preparation · 30-minute basic flow; 60-minute full race and reconnect behavior

Original role-focused practice. Original simulator exercise. Levels and revisions are teaching contracts, not Pod specifications.

Build from an empty project

Create a device-control screen with a level slider and an Apply button. Implement the state model and simulated repository yourself.

Core requirements

  1. Levels range from 0 to 10. Keep desired, pending, and confirmed values distinct.
  2. Selecting a level changes only the desired value. Apply starts a request.
  3. Confirm a new device value only when its revision is newer. Clear pending only for the matching request ID.
  4. Preserve the desired and confirmed values on failure and allow retry.
  5. Offline Apply sends no command. Reconnect refreshes device status and does not automatically apply the draft.

Simulator contract

Initial: desired=5, confirmed=5, revision=0, connected=true.
A request has id and level. A completion has id, level, and deviceRevision.
You control the demo delays, connection, and failure mode.
An old request can report newer device truth. Request ID and device revision have different roles.

Run the feature

  • Select 8 and see desired 8 while confirmed remains 5.
  • Apply and show pending before confirmation.
  • Complete two requests out of order. Preserve newer intent and use revisions for confirmed truth.
  • Disconnect and confirm that Apply sends nothing.

Follow-ups after the core

  • Explain cancellation when a physical command may already have been applied.
  • Save only the draft through process recreation and reconcile device truth after reconnect.
Hints, open after your attempt
  • Model actions and state before wiring the slider.
  • Compare device revision separately from the identity of the latest UI request.

Recall concepts: A05 · A13 · A14 · A16

EP10Build a vertical video feed and profile

Streamlabs · Published assignment suggests 2 to 3 hours; use 30 minutes for a first slice

Published hiring exercise. Published Streamlabs assignment. Start an empty project and use only the supplied data and assets.

Build from an empty project

Build a vertical video feed and a user profile using the company repository data.

Core requirements

  1. Parse videos.json and display a full-screen video from its URL.
  2. Navigate the feed vertically with swipe gestures.
  3. Parse user.json and show the user profile.
  4. Finish both screens and explain your decisions.

Data location

Get videos.json, user.json, and the image assets from the linked Streamlabs repository. Import the data into your own project.

Run the feature

  • Open a video and move to another feed item.
  • Open the profile and see the supplied user information.
  • Return to the feed.

Follow-ups after the core

  • For a 30-minute warm-up, finish one playable video and a basic profile first.
  • Explain when inactive video playback stops and player resources are released.

Original public resource: Streamlabs original assignment and data

Hints, open after your attempt
  • Keep media lifecycle ownership explicit.

Recall concepts: A06 · A08 · A09

EP11Build a nearby venue finder

Wolt · 30-minute first slice; complete the full task in a longer practice session

Published hiring exercise. Published Wolt 2025 Android internship assignment. Its historical service availability is unconfirmed.

Build from an empty project

Build a venue list that updates as a simulated user location changes.

Core requirements

  1. Show up to 15 nearby venues.
  2. Advance through the supplied coordinates every ten seconds and loop.
  3. Refresh the list with a smooth transition.
  4. Persist favorites by venue ID, including when a venue reappears.

Data and fallback

Use the API, coordinates, response fields, and assets in the linked Wolt README. If the historical API is unavailable, create two local venue responses in your own project and alternate them. Keep the same IDs for shared venues.

Run the feature

  • See the venue list change when the coordinate advances.
  • Favorite a venue and confirm that its state persists when it reappears and after restart.

Follow-ups after the core

  • Use 30 minutes to finish the list and favorite action, then add timed updates.
  • Explain cancellation and stale results when location requests overlap.

Original public resource: Wolt original assignment, API, and coordinates

Hints, open after your attempt
  • Persist favorite IDs independently from each refreshed response.

Recall concepts: A05 · A06 · A16

Prepare for 30 minutes

You confirmed a 30-minute interview that involves creating a small app feature. The exact feature, starting project, and allowed libraries are unconfirmed. The schedule below is for rehearsal.

Six preparation priorities

  1. UI state and ownership

    Keep loading, data, and error state in the ViewModel. Describe the repository boundary before you start.

    A01: architecture · A02: state

  2. Load and retry with coroutines

    Use viewModelScope, expose read-only StateFlow, and keep cancellation separate from errors.

    A03: Flow · A04: scope · A05: cancellation

  3. Render a Compose list

    Use LazyColumn with stable item IDs. Render loading, data, empty, and error states, with a Retry button.

    Empty-project problems · Google: Compose lists

  4. Lifecycle and rotation

    Use the supplied lifecycle-aware state collection. Explain why recomposition does not trigger another fetch and what happens after process recreation.

    A06: lifecycle · A07: saved state · A08: effects

  5. Run failure and retry

    Inspect the sample DATA, ERROR, and EMPTY responses. Confirm Retry clears the error and fetches again. Avoid duplicate loads.

    Sleep-history problem

  6. Explain a decision concisely

    Have one explanation of state ownership and one example from Driver AI ready for follow-up questions.

    Driver AI · Speech tradeoff

Optional technical discussion rehearsal
TimeFocusRehearsal task
0–3 minIntroductionGive a short background and the Android work you want to discuss.
3–9 minOne featureExplain Driver AI, your scope, a technical decision, and one follow-up.
9–20 minCore conceptsPractice architecture/state, Flow/coroutines, lifecycle, and Compose effects. Keep the first answer short.
20–26 minFailure and validationExplain one race or production debugging example and how you would test it.
26–30 minQuestions and closingAsk one or two questions about Android ownership and app/device reliability.
30-minute rehearsal: build a small app feature
TimeFocusRehearsal task
0 to 3 minClarifyClarify the selected brief, required behavior, and scope for this attempt.
3 to 6 minModelDescribe your models, UI state, and repository boundary before coding.
6 to 21 minImplementCreate your models, state holder, and screen. Finish the core before follow-ups.
21 to 26 minRunRun the app and inspect the behavior listed in the brief. Explain unfinished requirements.
26 to 30 minExplainExplain state ownership, request handling, and one next improvement. Ask a question.

Timed practice problem

Choose Quora chat, the Robinhood catalog, or sleep history from the problem bank. Build the stated 30-minute core yourself, starting from an empty Kotlin and Compose project.

  1. Implement the visible feature described in your chosen brief.
  2. Handle user actions and the relevant loading or error states.
  3. Run the feature and confirm its stated behavior.
  4. Explain what you finished, one tradeoff, and what remains.

Choose a brief in the empty-project problem bank. Finish its core before adding follow-ups.

Have two questions ready

Earlier sleep-history example

Build EP08 from an empty project first. Use this earlier example to compare your approach afterward.

Open the earlier starter and reference projects

Original practice for a 30-minute app-feature interview. This is not Eight Sleep's actual assessment. The dates and sleep durations are sample data.

Complete SleepHistoryViewModel.kt and SleepHistoryScreen.kt in the new starter. Load recent nights through the supplied repository, show a dated list with durations, and handle loading, empty results, and error with retry.

The Activity, lifecycle collection, Gradle setup, models, and repository are supplied. Your work is the ViewModel loading function and the Compose screen.

Requirements

  1. Show a loading indicator, then rows with dates and formatted durations.
  2. Use stable night IDs in the list.
  3. Show an empty message when no nights return.
  4. On failure, show an error and Retry. Clear the error when retrying and avoid duplicate active loads.

Open and run the project

Unzip the starter, open it in Android Studio, and sync with JDK 17 and SDK 35. Run it on an API 26 or newer emulator or phone before timing your attempt. No account, real Pod, or API key is needed.

The repository delays each call for 600 milliseconds. In MainActivity.kt, choose SampleResponse.DATA, ERROR, or EMPTY and restart the app. ERROR and EMPTY affect the first load; the next call returns data.

./gradlew :app:assembleDebug

The model and sample repository compiled with the local Kotlin compiler and cached coroutines. Full APK build and emulator execution remain unverified because Gradle startup is blocked by this session. No new tests were added.

What to say while coding

F01Clarify before coding

Say this

Before I start, can I confirm what is supplied and what you want me to finish? Is the repository already available? I'm planning to cover the list, loading, empty state, and an error with retry. Is that the right scope?

F02Explain your plan

Say this

I'll keep the screen state in the ViewModel and expose it as StateFlow. The repository will provide the nights, and Compose will render the current state. I'll load once when the ViewModel starts and load again on retry, so recomposition won't start another request.

F03Describe the implementation order

Say this

I'll get the successful load and list working first. Then I'll add loading feedback, failure with retry, and the empty result. I'll use the night ID as the list key and keep duration formatting in the row.

F04Close with the tradeoff

Say this

The ViewModel owns the request and the UI state. The screen renders that state and sends the retry action back. I prevent another load while one is active. Rotation keeps the ViewModel, while process recreation reloads the data. If overlapping refreshes were required, I'd define which result should win and handle stale completions explicitly.

Questions to explain afterward

Why StateFlow here?

The screen needs the latest loading/data/error state when it starts collecting. StateFlow represents that ongoing state. SharedFlow is not needed for this state model.

Why not fetch directly in the composable body?

The body can recompose. Keep loading in the ViewModel and use a controlled entry or user action to start work.

What happens when Retry is tapped twice?

This reference allows one load at a time. A second call while the Job is active does nothing. Explain a different policy if the product needs overlapping refreshes.

What survives rotation or process death?

The ViewModel survives an Activity configuration change. A new process creates a new ViewModel and fetches again. Persistence would need a separate requirement.

Reference ViewModel, reveal after your attempt
package dev.dchung.sleepfeature

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch

class SleepHistoryViewModel(private val repository: SleepRepository) : ViewModel() {
    private val _state = MutableStateFlow(SleepHistoryState(isLoading = true))
    val state = _state.asStateFlow()
    private var loadJob: Job? = null

    init { load() }

    fun load() {
        if (loadJob?.isActive == true) return
        loadJob = viewModelScope.launch {
            _state.value = _state.value.copy(isLoading = true, error = null)
            try {
                _state.value = SleepHistoryState(nights = repository.loadNights())
            } catch (cancelled: CancellationException) {
                throw cancelled
            } catch (_: Exception) {
                _state.value = _state.value.copy(
                    isLoading = false,
                    error = "Could not load sleep history. Try again.",
                )
            }
        }
    }
}
Reference Compose screen, reveal after your attempt
package dev.dchung.sleepfeature

import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp

@Composable
fun SleepHistoryScreen(state: SleepHistoryState, onRetry: () -> Unit, modifier: Modifier = Modifier) {
    when {
        state.isLoading -> Column(modifier, verticalArrangement = Arrangement.spacedBy(12.dp)) {
            CircularProgressIndicator()
            Text("Loading sleep history")
        }
        state.error != null -> Column(modifier, verticalArrangement = Arrangement.spacedBy(12.dp)) {
            Text(state.error, color = MaterialTheme.colorScheme.error)
            Button(onClick = onRetry) { Text("Retry") }
        }
        state.nights.isEmpty() -> Text("No sleep history yet", modifier)
        else -> LazyColumn(modifier, verticalArrangement = Arrangement.spacedBy(12.dp)) {
            items(state.nights, key = { it.id }) { night ->
                Card(modifier = Modifier.fillMaxWidth()) {
                    Column(Modifier.padding(16.dp)) {
                        Text(night.dateLabel, style = MaterialTheme.typography.titleMedium)
                        Text("${night.durationMinutes / 60} h ${night.durationMinutes % 60} min")
                    }
                }
            }
        }
    }
}

What the listing emphasizes

Official Senior Android Engineer listing

Official employer listing indexed in search; its direct page is JavaScript-only in this environment. Requirements were checked from the official indexed posting on October 7. Interview questions below are original practice, not an asserted Eight Sleep question bank.

Role emphasisYour practice
Native Android and KotlinExplain lifecycle, state ownership, concurrency, and one shipped client feature.
Compose and custom UIBuild a state-driven screen; discuss effects, accessibility, and performance.
Coroutines, Flow, MVVM/MVIShow a ViewModel/repository boundary and deterministic state transitions.
Testable apps and qualityExercise delayed results, errors, retry, rotation, and stale state.
Collaboration with firmware/backend/MLExplain contracts and distinguish a UI request from authoritative device state.
Hardware/health experience is a bonusUse real hardware debugging as transferable experience and learn the actual device protocol.

Explain these without a script

Jump to a topic
A01How would you structure a Compose screen?

Architecture · original practice question

Explain aloud

I separate UI, state and decisions, and data access. A screen-level ViewModel exposes immutable UI state and accepts actions. A repository owns access to device or network data. MVVM describes those responsibilities; an MVI-style reducer can make transitions explicit. I add layers when they clarify ownership or reuse.

Remember: UI renders; ViewModel decides; repository accesses data.

Watch the distinction: Architecture is a set of responsibilities, not a required number of modules.

Connect to your work: L02 · L03

Primary references: Architecture

A02How do you model loading, data, and errors?

State · original practice question

Explain aloud

I model the states the user can actually encounter, using immutable data or sealed alternatives. A device control can have a desired value, confirmed target, connection status, pending request, and an error. I expose read-only StateFlow and update state deliberately so the UI can render a coherent snapshot.

Remember: One coherent state; explicit pending; immutable updates.

Watch the distinction: An optimistic desired value is not a confirmed device target.

Primary references: Android UI state production

A03StateFlow, SharedFlow, or a cold Flow?

Flow · original practice question

Explain aloud

StateFlow represents a current value and gives new collectors that value; equal updates are conflated. SharedFlow is a configurable broadcast stream, with replay and buffering choices. A cold flow runs its producer when collected. I choose based on state ownership and delivery needs, and model important UI outcomes as state rather than assuming a transient event will be received.

Remember: Current state; broadcast stream; producer per collection.

Watch the distinction: A SharedFlow configuration does not automatically guarantee delivery across stopped collectors.

Primary references: Flow

A04How do scopes and dispatchers fit together?

Coroutines · original practice question

Explain aloud

The scope defines the lifetime and parent-child relationship of work. viewModelScope is useful for screen business work that should end when the ViewModel is cleared. Dispatchers address execution: blocking I/O and CPU work have different needs. A suspend function is not automatically background work; I make blocking operations main-safe at the appropriate boundary.

Remember: Lifetime; parent cancellation; main-safe work.

Watch the distinction: Launching another coroutine is not permission to ignore its lifecycle.

Connect to your work: L01

Primary references: Coroutines

A05What does cancelling a request actually guarantee?

Coroutines · original practice question

Explain aloud

Cancellation is cooperative. I preserve CancellationException and clean up owned resources. Cancelling a local caller does not prove a remote side effect was cancelled. For a control flow, I keep request identity separate from authoritative device state and reconcile after uncertainty. In the exercise, request IDs manage pending UI work and device revisions determine current confirmed truth.

Remember: Cooperative cancellation; preserve it; reconcile remote truth.

Watch the distinction: Do not discard a newer authoritative device state solely because its request ID is old.

Primary references: Coroutines

A06How do you collect state safely in Compose?

Lifecycle · original practice question

Explain aloud

I use collectAsStateWithLifecycle for Android UI state. For lifecycle-aware collection outside Compose, repeatOnLifecycle creates and cancels the collection at the chosen lifecycle boundary. Upstream lifetime is a separate decision: a hot stream or ViewModel task can keep running after the UI stops collecting, so I inspect the sharing policy and the operation’s owner.

Remember: Lifecycle-aware collector; upstream policy; operation owner.

Watch the distinction: Stopping UI collection does not necessarily stop every upstream operation.

Connect to your work: L04

Primary references: Android StateFlow and lifecycle collection

A07remember, rememberSaveable, or SavedStateHandle?

Compose · original practice question

Explain aloud

remember retains a value across recomposition while its composition entry remains. rememberSaveable can restore supported small UI values through saved state. SavedStateHandle helps a ViewModel restore small input state after system recreation. Durable data belongs in persistent storage. I save a draft or identifier, then refresh authoritative device or server state.

Remember: Composition; saved UI state; ViewModel inputs; persistent data.

Watch the distinction: A ViewModel alone does not survive process death.

Primary references: State · Saved

A08How do you choose and key a side effect?

Compose · original practice question

Explain aloud

LaunchedEffect runs coroutine work tied to its composition lifetime and restarts when its keys change. DisposableEffect is useful for registrations that need cleanup. rememberUpdatedState supplies an updated value without restarting an effect solely for that value. I choose keys that match the operation’s identity and keep I/O out of the composable body.

Remember: Effect lifetime; meaningful keys; cleanup.

Watch the distinction: An unstable key can restart work unexpectedly; a missing key can retain stale behavior.

Primary references: Effects

A09How do you investigate excessive recomposition or jank?

Compose · original practice question

Explain aloud

I first reproduce the user-visible problem and measure it. Then I inspect changing state, expensive work, item identity, and the work performed during composition, layout, or drawing. Stable item keys and appropriate state reads can help. I use performance tooling to verify a change rather than assuming every recomposition is a defect.

Remember: Measure; inspect state and work; verify.

Watch the distinction: Do not claim a performance improvement from a recomposition counter alone.

Connect to your work: L03

Primary references: Performance

A10What should happen after rotation or process recreation?

Lifecycle · original practice question

Explain aloud

Rotation can preserve a ViewModel while recreating the UI. Process recreation requires saved inputs and persistent data to restore a useful screen. I restore the local draft, reconnect or reload, and make freshness visible. A cached device target remains last-known state until current authoritative information arrives.

Remember: Restore inputs; refresh truth; show freshness.

Watch the distinction: Never treat a restored UI value as proof that a device currently has that value.

Connect to your work: V19

Primary references: Saved

A11How would you prevent duplicate streamed chat messages?

Networking · original practice question

Explain aloud

In a proposed design, I use message identity for message upserts and event or sequence identity for incremental updates. I distinguish partial and final content and define ordering, replay, and reconnection behavior. Identical text is not enough to identify a duplicate. I would test replay, retries, out-of-order delivery, and finalization against the actual protocol.

Remember: Identity; ordering; partial versus final.

Watch the distinction: These are design options, not the recovered root cause of your P000 fix.

Connect to your work: L02

Primary references: Architecture

A12How would you explain client transcription versus streaming?

Networking · original practice question

Explain aloud

Your initial Driver AI flow was speech-to-text on the client, transcript to backend and LLM, then text returned to the client. For a proposed streaming design, I would clarify which stage streams, what incremental feedback users gain, and the resource, timing, cancellation, and reconnect tradeoffs. I would compare experience and correctness alongside implementation cost.

Remember: Name the stage; experience; resources and timing.

Watch the distinction: The exact streaming stage and the 40% usage measurement remain unconfirmed.

Connect to your work: L02

Primary references: Coroutines

A13How do desired and confirmed control state differ?

Device · original practice question

Explain aloud

Desired state represents what the user requested. Confirmed state represents what the system reports as applied; an acknowledged request can still be pending. An applied target is also different from a sensed physical temperature. I model those meanings separately, expose uncertainty and failure, and keep the UI aligned with authoritative state.

Remember: Desired; acknowledged; applied target; sensed value.

Watch the distinction: Transport success is not automatically a physical outcome.

Primary references: Backup

A14How would you design cloud and local device control?

Device · original practice question

Explain aloud

As a proposed design, I separate the user action and state model from transport and protocol details. I define how local and cloud state reconcile, which controls remain available offline, and how the UI explains limitations. Eight Sleep’s Backup Mode write-up describes local Bluetooth control during outages and deferred data processing; I would ask about today’s actual Android boundaries.

Remember: Common state model; transport boundary; reconciliation.

Watch the distinction: Do not present the historical rollout or a proposed transport abstraction as their current internal architecture.

Connect to your work: L04

Primary references: Backup

A15What Android Bluetooth permissions would you check?

Device · original practice question

Explain aloud

For Android 12 and above, I distinguish scanning, communication with paired devices, and advertising, and request the relevant Nearby devices runtime permissions. Older versions have different permission and location rules. I handle denial and unavailable Bluetooth in the UI and request only the capabilities required by the actual flow.

Remember: Version; capability; denial state.

Watch the distinction: Bluetooth access and device authentication are different concerns.

Primary references: Bluetooth

A16What should be cached or queued when offline?

Offline · original practice question

Explain aloud

I define reads and writes separately. Local persistent data can support offline reads with explicit freshness. Durable synchronization may fit WorkManager, but a live device control has different timing and authorization constraints. For a temperature draft, I would avoid blindly replaying an old action on reconnect and first establish current state and user intent.

Remember: Read cache; write policy; freshness.

Watch the distinction: Offline-first does not mean every command should be replayed later.

Primary references: Offline

A17How do you test coroutine timing and races?

Testing · original practice question

Explain aloud

I use runTest with test dispatchers sharing a scheduler so I can control virtual time and execution order. I inject boundaries and assert state and effects across delayed success, error, cancellation, and overlap. The supplied reducer harness covers deterministic transitions; coroutine integration tests would separately verify scheduling and collection behavior.

Remember: Shared scheduler; controlled order; boundary assertions.

Watch the distinction: The 13 pure Kotlin checks are not Compose or coroutine integration tests.

Primary references: Coroutine Tests

A18What would you test in the Compose UI?

Testing · original practice question

Explain aloud

I test what the user can observe and do using semantics: desired versus confirmed labels, pending state, disabled actions offline, errors, and retry. I test interactions and resulting state with appropriate synchronization. Smaller state and repository tests cover logic, while device tests cover the hardware and OS boundary.

Remember: Semantics; observable states; layered tests.

Watch the distinction: Arbitrary sleeps make timing tests fragile.

Connect to your work: L04

Primary references: Ui Tests

A19How do you debug an ANR or slow interaction?

Performance · original practice question

Explain aloud

I reproduce with a representative device and build, then use traces and diagnostics to locate blocking, contention, CPU work, or an expensive render path. I keep blocking work off the main thread at the correct boundary and verify the user journey after the change. I distinguish a responsiveness problem, crash, and network latency before choosing the fix.

Remember: Reproduce; trace; isolate; verify.

Watch the distinction: Moving everything to an I/O dispatcher is not a diagnosis.

Connect to your work: L04

Primary references: Perfetto · Anr

A20How do you make a control screen accessible and trustworthy?

Product · original practice question

Explain aloud

I use understandable labels, semantic state, accessible control targets, and clear pending, error, and offline feedback. Non-text controls need meaningful descriptions; text needs to work with scaling and assistive navigation. For a device control, I make the difference between a draft and confirmed state visible so the user can understand what happened.

Remember: Meaningful semantics; visible state; usable controls.

Watch the distinction: A visually polished screen can still be ambiguous or inaccessible.

Primary references: Accessibility

Extended practice: reliable bed-temperature control

Original role-inspired exercise · simulated device · 45–60 minutes for the core + Android integration

Implement the reducer in the starter Android project. Keep the selected draft separate from the confirmed device target. Handle overlapping requests, old responses, current errors, disconnect, and explicit retry. A Compose UI, ViewModel, and fake repository are supplied.

Acceptance criteria

  1. Levels 0–10 and monotonic revisions are fictional exercise contracts, not Pod specifications.
  2. Select changes desired state; it does not change confirmed state or send a command.
  3. Submit records the latest pending request and emits a Send effect; offline submit emits no command.
  4. Authoritative results update confirmed state only when their device revision is newer.
  5. Request identity controls which pending operation completes or errors; it does not define authoritative device truth.
  6. A current error preserves desired and confirmed values and permits retry.
  7. Disconnect clears pending UI work; reconnect refreshes status and does not automatically replay a draft.
  8. An older request can still carry newer authoritative device truth. Cancellation of a caller does not prove a remote command was undone.

Score your attempt

AreaPointsWhat to demonstrate
State correctness30Draft, pending, and confirmed target have distinct meanings.
Races and failures30Old results/errors cannot erase newer intent; newer device truth is preserved.
Android integration20Lifecycle-aware state collection, scoped work, and appropriate persistence.
UI and explanation20Clear feedback, usable controls, and a concise explanation of tradeoffs.

Senior-level extensions

Read the state and action contract
package dev.dchung.bedcontrol.domain

// Fictional exercise protocol: levels 0..10, monotonic device revisions.
data class Pending(val id: Long, val level: Int)
data class ControlState(
    val desired: Int = 5,
    val confirmed: Int = 5,
    val connected: Boolean = true,
    val revision: Long = 0,
    val pending: Pending? = null,
    val error: String? = null,
)
sealed interface Action {
    data class Select(val level: Int) : Action
    data class Submit(val id: Long) : Action
    data class Applied(val id: Long, val level: Int, val revision: Long) : Action
    data class Failed(val id: Long, val reason: String) : Action
    data class Connection(val connected: Boolean) : Action
    data class Snapshot(val level: Int, val revision: Long) : Action
}
sealed interface Effect {
    data class Send(val id: Long, val level: Int) : Effect
}
data class Transition(val state: ControlState, val effects: List<Effect> = emptyList())
Reveal the reference reducer after your attempt
package dev.dchung.bedcontrol.domain

fun reduce(state: ControlState, action: Action): Transition = when (action) {
    is Action.Select -> if (action.level in 0..10) {
        Transition(state.copy(desired = action.level, error = null))
    } else {
        Transition(state.copy(error = "Choose a level from 0 to 10"))
    }
    is Action.Submit -> when {
        !state.connected -> Transition(state.copy(error = "Offline: reconnect before applying"))
        state.pending == null && state.desired == state.confirmed -> Transition(state)
        else -> Transition(
            state.copy(pending = Pending(action.id, state.desired), error = null),
            listOf(Effect.Send(action.id, state.desired)),
        )
    }
    is Action.Applied -> {
        // Request identity controls UI completion; device revision controls device truth.
        val actual = if (action.revision > state.revision) {
            state.copy(confirmed = action.level, revision = action.revision)
        } else state
        Transition(if (state.pending?.id == action.id) {
            actual.copy(pending = null, error = null)
        } else actual)
    }
    is Action.Failed -> Transition(if (state.pending?.id == action.id) {
        state.copy(pending = null, error = action.reason)
    } else state)
    is Action.Connection -> Transition(state.copy(
        connected = action.connected,
        pending = if (action.connected) state.pending else null,
        error = if (action.connected) null else "Disconnected: device status may be stale",
    ))
    is Action.Snapshot -> Transition(if (action.revision > state.revision) {
        state.copy(confirmed = action.level, revision = action.revision)
    } else state)
}

Compare request identity with device revision. An older request may still report newer device truth. This simulator’s revision contract is a teaching choice.

Run the earlier device example

For a fresh attempt, use Empty project setup. These steps apply to the earlier downloadable device project.

  1. Download and unzip the starter. Open its project folder in Android Studio.
  2. Use JDK 17 and install SDK Platform 35 / Build Tools 35.0.0. Gradle 8.13 and AGP 8.12.0 are pinned, with Kotlin/Compose compiler 2.1.20.
  3. Let Gradle sync in your normal development environment. Dependencies may need downloading.
  4. Run the app on an API 26+ phone or emulator. It uses a simulated repository and requires no account or API key.
  5. Implement domain/Reducer.kt. Run bash run-core-tests.sh with the Kotlin CLI compiler to check the core independently of Android.
  6. Try quick overlapping Apply actions, error/retry, disconnect, and rotation. Add process-restoration behavior as a stretch.

From the unzipped starter folder:

bash run-core-tests.sh
./gradlew :app:assembleDebug

The starter’s reducer intentionally contains a TODO. The supplied UI displays that status until you implement it.

Existing exercises from the internet

Published company tasks and Google’s codelab. These are practice resources; none is presented as Eight Sleep’s actual assessment.

Streamlabs mobile hiring assignment

A published company exercise: build a vertical video feed and profile screen from supplied JSON. Kotlin is preferred for Android; the README suggests 2–3 hours and asks for documented decisions.

Useful for lifecycle, state ownership, media, navigation, and explaining tradeoffs. This is Streamlabs’ exercise, not Eight Sleep’s.

Wolt Android internship assignment, 2025

A published Kotlin task: show nearby venues, change the simulated location every ten seconds, animate the list, and persist favorites.

Use the 30-minute list and favorites version first. Add timed refresh and stale-request handling in a longer session. Historical API availability is unconfirmed.

Sources and scope

Each recall card links to relevant primary documentation. The topic selection and proposed designs are a synthesis for this role. Your duplicate-message fix’s root cause is still unconfirmed; the design options in A11 are not claims about that fix.