A suspend function returns one value after waiting. But many things in an app produce values over time: a database that changes as the user adds rows, a countdown timer, a stream of GPS locations, a search box where the user keeps typing. For those you need something that can deliver a sequence of values, and in Kotlin that is a Flow.
Flows are the glue of modern Android architecture. Room returns them (lesson 34), DataStore returns them (lesson 35), and your ViewModel exposes UI state through them (lessons 24 and 25). This lesson makes sure you understand them properly instead of copying the pattern.
This lesson covers the two items of Pathway 2, Unit 6 in the official Android Basics with Compose course that build on flows: Kotlin Flows in Practice and Testing Flows. Most examples run in the free Kotlin Playground (https://play.kotlinlang.org), so you can experiment on any computer.
By the end you will be able to:
- Explain the difference between a suspend function, a
List, aSequenceand aFlow. - Build a flow with
flow { emit() }, and collect it. - Use operators:
map,filter,onEach,take,catch,onCompletion,combine,debounce,flatMapLatest. - Explain cold versus hot flows, and
StateFlowversusSharedFlow. - Turn a cold flow into a
StateFlowwithstateInandSharingStarted.WhileSubscribed(5000). - Collect flows safely in Compose with
collectAsStateWithLifecycle. - Write tests for flows with
runTest.
Big idea: a Flow is a suspend function that can return many values. It is cold: nothing happens until somebody collects it.
- Coroutines,
launch,suspend, dispatchers and structured concurrency (lesson 28). You cannot understand flows without them. StateFlowbasics in a ViewModel (lessons 24 and 25).- Lambdas (lesson 12). Operators take lambdas.
- Kotlin collections (lesson 14).
mapandfilteron lists work the same conceptually. - Dependencies. In the Playground,
import kotlinx.coroutines.flow.*works with no setup. In Android Studio, coroutines come with Compose and Lifecycle libraries, pluskotlinx-coroutines-testfor tests (lesson 25).
| Type | How many values | When are they produced? | Blocking? |
|---|---|---|---|
Int / suspend fun f(): Int |
One | Once, when called | Suspends |
List<Int> |
Many | All at once, already in memory | No |
Sequence<Int> |
Many | Lazily, one at a time, synchronously | Blocks the thread |
Flow<Int> |
Many | Over time, asynchronously | Suspends |
An analogy: a suspend function is ordering one meal. A list is a buffet already on the table. A flow is a conveyor-belt sushi bar: dishes arrive one after another, and you take them as they pass.
Three roles in any flow:
Producer -> [operators: map, filter, ...] -> Collector
(emits) (intermediate, transform) (terminal, receives)
- The producer emits values (a database, a sensor, a loop).
- Intermediate operators transform the stream without starting it.
- The collector is the terminal step that actually runs everything, and receives each value.
Open the Kotlin Playground and run:
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
fun countdown(from: Int): Flow<Int> = flow {
for (i in from downTo 1) {
delay(500)
emit(i)
}
}
fun main() = runBlocking {
println("Before collecting")
countdown(3).collect { value ->
println("Got $value")
}
println("Finished")
}
Before collecting
Got 3
Got 2
Got 1
Finished
What happened:
flow { ... }is a builder. The block is the producer;emit(value)sends a value downstream.countdown(3)just creates the flow. Nothing runs yet..collect { }is the terminal operator. It starts the producer, and runs the lambda for each emitted value. It is a suspend function, so the surrounding coroutine waits (without blocking the thread) until the flow completes.- The
delayinside the producer suspends, it does not block, so other coroutines can run.
Cold: each collector gets its own run
fun main() = runBlocking {
val flow = countdown(2)
println("First collector")
flow.collect { println("A got $it") }
println("Second collector")
flow.collect { println("B got $it") }
}
First collector
A got 2
A got 1
Second collector
B got 2
B got 1
The second collect starts the producer from scratch. This is what cold means: a cold flow is like a recipe, and every collector cooks it again. It does no work when nobody is collecting, and it stops when the collector stops (cancellation).
Other ways to create flows
val a = flowOf(1, 2, 3) // fixed values
val b = listOf("x", "y").asFlow() // from a collection
val c = (1..5).asFlow() // from a range
Use flowOf and asFlow for tests and previews; use flow { } for real producers.
Operators transform a flow. They are intermediate: they return a new flow and do nothing until collected.
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
fun main() = runBlocking {
(1..10).asFlow()
.filter { it % 2 == 0 } // keep evens: 2, 4, 6, 8, 10
.map { it * it } // square: 4, 16, 36, 64, 100
.onEach { println("About to use $it") }
.take(3) // only the first three: 4, 16, 36
.collect { println("Result: $it") }
}
About to use 4
Result: 4
About to use 16
Result: 16
About to use 36
Result: 36
Notice the interleaving: values travel through the whole pipeline one at a time, not stage by stage. After take(3) receives its third item, it cancels the upstream flow, so 8 and 10 are never produced.
The operators you will use most
| Operator | Purpose |
|---|---|
map { } |
Transform each value |
filter { } |
Keep matching values |
onEach { } |
Do a side effect (logging) without changing values |
take(n) |
Stop after n values |
distinctUntilChanged() |
Skip a value equal to the previous one |
catch { e -> } |
Handle an upstream exception |
onStart { } / onCompletion { } |
Run code at start or end |
flowOn(dispatcher) |
Change the dispatcher for the upstream part |
debounce(ms) |
Wait for a quiet period before emitting |
combine(a, b) { x, y -> } |
Merge the latest values of several flows |
flatMapLatest { } |
Switch to a new inner flow, cancelling the old one |
stateIn(scope, started, initial) |
Turn a flow into a StateFlow |
Handling errors with catch
fun main() = runBlocking {
flow {
emit(1)
emit(2)
throw IllegalStateException("Sensor failed")
}
.map { it * 10 }
.catch { e -> println("Caught: ${e.message}"); emit(-1) }
.collect { println("Value: $it") }
}
Value: 10
Value: 20
Caught: Sensor failed
Value: -1
catch handles exceptions from upstream (operators above it) only. It does not catch exceptions thrown inside collect { }, because that is downstream. Put a try/catch around the collection for those. And remember lesson 28's rule: do not swallow CancellationException; catch correctly ignores cancellation.
flowOn: choosing the thread
By default a flow runs in the collector's context. To do heavy upstream work on another dispatcher without changing the collector:
flow { emit(readHugeFile()) }
.flowOn(Dispatchers.IO) // affects everything ABOVE this line
.collect { show(it) } // runs where it was collected, e.g. Main
Never call withContext inside flow { } to emit from another dispatcher: Kotlin's context-preservation rule throws an exception. Use flowOn.
combine: join several streams
Suppose a search screen has two independent inputs, the query text and a "sort by" choice. Whenever either changes, you recompute the result:
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
fun main() = runBlocking {
val query = MutableStateFlow("b")
val ascending = MutableStateFlow(true)
val names = listOf("Bola", "Ada", "Banji", "Tunde")
val job = launch {
combine(query, ascending) { q, asc ->
val matches = names.filter { it.contains(q, ignoreCase = true) }
if (asc) matches.sorted() else matches.sortedDescending()
}.collect { println(it) }
}
delay(100); ascending.value = false // change sorting
delay(100); query.value = "a" // change search text
delay(100)
job.cancel()
}
[Banji, Bola]
[Bola, Banji]
[Bola, Banji, Ada]
Work through it by hand. With the query b, the names containing the letter b (ignoring case) are Bola and Banji, so ascending order gives [Banji, Bola]. Flipping ascending to false recomputes with the same query: [Bola, Banji]. Changing the query to a keeps the descending order and now matches Bola, Banji and Ada (Tunde has no a), giving [Bola, Banji, Ada]. Notice that combine recomputed every time either input changed, and that it emitted once at the start because both flows already had values. Predict output first, run it second: that habit catches mistakes in your own tutorials as well as in code.
debounce and flatMapLatest: the search-as-you-type pattern
A search box that calls the server on every keystroke wastes bandwidth. The standard recipe:
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
class SearchRepository {
fun search(query: String): Flow<List<String>> = flow {
delay(300) // pretend network
emit(listOf("$query result 1", "$query result 2"))
}
}
@OptIn(FlowPreview::class)
fun resultsFor(queries: Flow<String>, repo: SearchRepository): Flow<List<String>> =
queries
.debounce(400) // wait until the user pauses for 400 ms
.map { it.trim() }
.filter { it.length >= 2 } // ignore very short text
.distinctUntilChanged() // do not repeat the same search
.flatMapLatest { repo.search(it) } // cancel the old search if text changes
debounceis marked as a preview API in some versions, hence@OptIn(FlowPreview::class).flatMapLatestcancels an in-flight search when a newer query arrives, which also fixes the race condition from lesson 29 (Exercise 2).
A hot flow exists independently of collectors. It produces values whether or not anyone listens, and collectors just tune in.
StateFlow |
SharedFlow |
|
|---|---|---|
| Has a current value? | Yes, always (.value) |
No |
| New collector receives | The latest value immediately | By default nothing; optionally the last N (replay) |
| Equal consecutive values | Ignored (conflated by equality) | Delivered |
| Typical use | UI state | One-off events (but see below) |
val state = MutableStateFlow(0) // needs an initial value
state.value = 1 // set
println(state.value) // get
val events = MutableSharedFlow<String>() // no value stored
events.emit("Saved") // suspends until delivered to collectors
Events: a word of caution
Early tutorials used SharedFlow or Channel for one-off events such as "show a snackbar" or "navigate". Current Android guidance recommends modelling events as state where possible: put message: String? in the UI state, show it, and have the UI call onMessageShown() to clear it. That way an event is never lost during rotation, and never delivered twice. See lesson 25 on UI state and the official guide to UI events (https://developer.android.com/topic/architecture/ui-layer/events).
stateIn: from cold to hot, safely
Say a repository exposes a cold Flow<List<Item>>. A ViewModel should expose a StateFlow that the UI collects. stateIn makes the conversion:
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
class ItemsViewModel(repository: ItemsRepository) : ViewModel() {
val uiState: StateFlow<ItemsUiState> = repository.getItems() // Flow<List<Item>>
.map { items -> ItemsUiState(items) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = ItemsUiState()
)
}
The three parameters:
scope: where the sharing coroutine lives (viewModelScope, so it is cancelled with the ViewModel).started: when to run the upstream flow.WhileSubscribed(5_000)starts it when the first collector arrives and stops it 5 seconds after the last collector leaves. That timeout is the key detail: when the user rotates the phone, the UI stops collecting for a moment, but the upstream (say a database query) is kept alive for the 5 seconds, so it does not restart unnecessarily. When the user leaves the app for longer than 5 seconds, the work stops and saves battery.initialValue: what the UI sees before the first emission.
Alternatives: SharingStarted.Eagerly (start at once, never stop) and SharingStarted.Lazily (start at the first collector, never stop). WhileSubscribed(5_000) is the recommended default for UI state.
You have already used:
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
collectAsStateWithLifecycle() collects only while the lifecycle is at least STARTED (lesson 23), and cancels and restarts as the app goes to the background and back. The older collectAsState() keeps collecting in the background.
For a flow that is not a StateFlow, give an initial value:
val time by timeFlow.collectAsStateWithLifecycle(initialValue = "--:--")
To collect a flow for side effects (for example showing a snackbar for each event) use LaunchedEffect with the flow as a key, and repeatOnLifecycle when you must respect the lifecycle (lesson 55 covers effects):
LaunchedEffect(viewModel) {
viewModel.messages.collect { message ->
snackbarHostState.showSnackbar(message)
}
}
In classic Activity code, the safe pattern is:
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { render(it) }
}
}
A common beginner bug is calling collect directly in lifecycleScope.launch { } with no repeatOnLifecycle, which keeps collecting while the app is in the background.
The second item in this lesson is Testing Flows. You already know runTest from lesson 25. Flows add one idea: the flow produces values over time, so decide what you want to check.
1. Check the latest state
For a StateFlow, usually just read .value after advancing the scheduler:
@Test
fun query_updatesResults() = runTest {
val vm = SearchViewModel(FakeSearchRepository())
vm.onQueryChange("ada")
advanceUntilIdle()
assertEquals(listOf("Ada"), vm.uiState.value.results)
}
2. Check a whole sequence with toList
For a finite cold flow, collect everything:
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Test
class OperatorTest {
@Test
fun squares_areEmittedInOrder() = runTest {
val result = flowOf(1, 2, 3).map { it * it }.toList()
assertEquals(listOf(1, 4, 9), result)
}
}
toList() never returns for an endless flow such as a StateFlow (it never completes), so the test would hang. For those use first(), take(n).toList(), or collect in a background job.
3. Collect a hot flow in a background job
When you need to see every value of a StateFlow over time, start a collector before triggering the changes:
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun counter_emitsEachValue() = runTest {
val counter = MutableStateFlow(0)
val seen = mutableListOf<Int>()
val job = launch(UnconfinedTestDispatcher(testScheduler)) {
counter.toList(seen) // appends every value into `seen`
}
counter.value = 1
counter.value = 2
job.cancel()
assertEquals(listOf(0, 1, 2), seen)
}
UnconfinedTestDispatcher starts the collector eagerly, so it is already listening before the first change. Because StateFlow conflates, rapid changes in a standard dispatcher could skip values, so the unconfined dispatcher is what makes this deterministic. The toList(destination) overload appends into an existing list, so you can read it after cancelling.
4. Testing with a fake that exposes a flow
A fake repository can expose a MutableSharedFlow or MutableStateFlow that your test controls:
class FakeItemsRepository : ItemsRepository {
val items = MutableStateFlow<List<Item>>(emptyList())
override fun getItems(): Flow<List<Item>> = items
}
Then the test sets repo.items.value = listOf(Item(1, "Rice")) and asserts that the ViewModel's uiState changes. A third-party library named Turbine is popular for readable flow tests (flow.test { awaitItem() }); it is not part of the official course, so check its current documentation and release notes before adopting it. The built-in tools above are enough for everything in this course.
Testing stateIn with WhileSubscribed
A StateFlow made with WhileSubscribed does nothing until someone collects it. A test that just reads .value sees only the initial value. Start a collector first:
val vm = ItemsViewModel(repo)
val job = launch(UnconfinedTestDispatcher(testScheduler)) { vm.uiState.collect {} }
repo.items.value = listOf(Item(1, "Rice"))
assertEquals(1, vm.uiState.value.items.size)
job.cancel()
Forgetting this is the most common reason a stateIn test returns the initial state forever.
Exercise 1: Temperature stream
In the Playground, write a temperatures() flow that emits 22, 24, 27, 31, 35, 33 with a short delay. Using operators, print only the readings above 25, converted to Fahrenheit (c * 9 / 5 + 32, as an integer using Kotlin's Int arithmetic), stopping after the first three such readings. Predict the output before running.
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
fun temperatures(): Flow<Int> = flow {
for (t in listOf(22, 24, 27, 31, 35, 33)) {
delay(100)
emit(t)
}
}
fun main() = runBlocking {
temperatures()
.filter { it > 25 }
.map { it * 9 / 5 + 32 }
.take(3)
.collect { println("$it F") }
}
Hand calculation: readings above 25 are 27, 31, 35, 33. Fahrenheit with integer arithmetic: 27 x 9 = 243, 243 / 5 = 48 (integer division), 48 + 32 = 80. 31 x 9 = 279, 279 / 5 = 55, 55 + 32 = 87. 35 x 9 = 315, 315 / 5 = 63, 63 + 32 = 95. take(3) stops before 33.
80 F
87 F
95 F
(Integer division drops the decimals; the exact values are 80.6, 87.8 and 95.)
Exercise 2: Search-as-you-type ViewModel
Build a SearchViewModel with query: StateFlow<String> and results: StateFlow<List<String>>. Results come from a repository search function returning a Flow. Use debounce, distinctUntilChanged, flatMapLatest and stateIn. Then write a test that types a, ad, ada quickly and checks that only one search ran, for ada.
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.*
interface SearchRepository {
fun search(query: String): Flow<List<String>>
}
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
class SearchViewModel(private val repo: SearchRepository) : ViewModel() {
private val _query = MutableStateFlow("")
val query: StateFlow<String> = _query.asStateFlow()
val results: StateFlow<List<String>> = _query
.debounce(300)
.map { it.trim() }
.distinctUntilChanged()
.flatMapLatest { q -> if (q.length < 2) flowOf(emptyList()) else repo.search(q) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
fun onQueryChange(text: String) { _query.value = text }
}
class FakeSearchRepository : SearchRepository {
val searches = mutableListOf<String>()
override fun search(query: String): Flow<List<String>> = flow {
searches += query
emit(listOf(query.uppercase()))
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun typingQuickly_runsOnlyOneSearch() = runTest {
val repo = FakeSearchRepository()
val vm = SearchViewModel(repo)
val job = launch(UnconfinedTestDispatcher(testScheduler)) { vm.results.collect {} }
vm.onQueryChange("a")
advanceTimeBy(100)
vm.onQueryChange("ad")
advanceTimeBy(100)
vm.onQueryChange("ada")
advanceTimeBy(400) // let the debounce window pass
runCurrent()
assertEquals(listOf("ada"), repo.searches)
assertEquals(listOf("ADA"), vm.results.value)
job.cancel()
}
The three quick keystrokes arrive within the 300 ms window, so debounce keeps only the last. The single-letter a would have been filtered anyway by the length rule. advanceTimeBy and runCurrent come from kotlinx.coroutines.test; they let you move virtual time precisely. Run the test in your project to confirm; timing tests are the most sensitive to library versions.
Exercise 3: Why does this show an empty screen?
val uiState: StateFlow<UiState> = repository.items
.map { UiState(it) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), UiState())
// in a test:
val vm = ViewModel(fakeRepo)
fakeRepo.items.value = listOf(Item(1))
assertEquals(1, vm.uiState.value.items.size) // fails!
WhileSubscribed only runs the upstream flow while something collects uiState. In the test nothing collects it, so .value stays at the initial UiState(). Fix: start a collector (launch(UnconfinedTestDispatcher(testScheduler)) { vm.uiState.collect {} }) before changing the repository, and cancel it at the end.
Run the countdown flow from Build 1 with two collectors launched at the same time using launch, and observe that each one gets its own full countdown. Then convert the flow to a SharedFlow with shareIn and observe the difference.
Mistake #1: Forgetting that cold flows do nothing until collected. Symptom: "my onEach never runs". The fix: there must be a terminal operator such as collect, first, toList or launchIn.
Mistake #2: collect in the wrong place. What happens: collect suspends forever for a hot flow, so code after it never runs. The fix: launch the collection in its own coroutine.
Mistake #3: catch placed below the failure. What happens: not caught. The fix: catch only handles upstream exceptions; place it after the code that can throw.
Mistake #4: Using collectAsState() for streams that hit the network or database. What happens: collection continues in the background. The fix: collectAsStateWithLifecycle().
Mistake #5: withContext inside flow { }. Symptom: Flow invariant is violated. The fix: flowOn.
Mistake #6: Expecting StateFlow to emit equal values. What happens: setting the same value twice triggers nothing. The fix: that is by design; use immutable states so changes are real changes.
Mistake #7: Using SharedFlow for one-time UI events and losing them on rotation. The fix: model events as state and clear them after handling.
Mistake #8: toList() on an endless flow in a test. Symptom: the test hangs. The fix: take(n), first() or a background collector.
Mistake #9: Trusting an expected output you did not calculate. What happens: wrong assertions in tests. The fix: hand-calculate, then run it.
Question 1. What does it mean that a Flow is cold?
It does no work until collected, and each collector triggers a fresh run of the producer.
Question 2. What is the difference between StateFlow and SharedFlow?
StateFlow always holds a current value, replays it to new collectors and skips equal consecutive values; it models state. SharedFlow has no current value and delivers every emission to the active collectors, with configurable replay.
Question 3. What does stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), initial) do?
It converts a cold flow into a StateFlow that starts collecting upstream when the first collector subscribes and stops five seconds after the last one leaves, surviving brief interruptions like rotation.
Question 4. What does flatMapLatest do and why is it useful for search?
It maps each value to a new flow and cancels the previous inner flow when a new value arrives, so only the latest query's results are used and stale requests are cancelled.
Question 5. Why can a test of a stateIn flow see only the initial value?
With WhileSubscribed the upstream flow only runs while something collects the state flow; without a collector the value never updates. The test must start a collector.
Go deeper: official resources
- Android Basics with Compose, Unit 6, Pathway 2: Kotlin Flows
- Kotlin coroutines on Android
- Kotlin documentation: Flow
- State production in the UI layer
Project goal: Build a stopwatch whose time is produced by a flow, with Start, Stop and Lap buttons, a ViewModel exposing a StateFlow, and tests.
Requirements:
- A
ticker(periodMs: Long): Flow<Long>that emits0, 1, 2, ...once per period usingflow { }anddelay. - A ViewModel with
isRunningandlapsin a UI state. While running, the elapsed seconds are driven by the ticker; stopping cancels it (use aJob). - A Lap button that appends the current elapsed time to a list shown in a
LazyColumn. - Collect with
collectAsStateWithLifecycle. - Tests: elapsed time after 3 virtual seconds is 3; stop freezes the time; a lap records the correct value.
Stretch goal: format the time as mm:ss with map in the state production pipeline.
package com.example.stopwatch
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
fun ticker(periodMs: Long = 1_000): Flow<Long> = flow {
var n = 0L
while (true) {
delay(periodMs)
emit(++n)
}
}
data class StopwatchUiState(
val elapsedSeconds: Long = 0,
val isRunning: Boolean = false,
val laps: List<Long> = emptyList()
)
class StopwatchViewModel : ViewModel() {
private val _uiState = MutableStateFlow(StopwatchUiState())
val uiState: StateFlow<StopwatchUiState> = _uiState.asStateFlow()
private var tickJob: Job? = null
fun start() {
if (tickJob?.isActive == true) return
_uiState.update { it.copy(isRunning = true) }
val base = _uiState.value.elapsedSeconds
tickJob = viewModelScope.launch {
ticker().collect { tick ->
_uiState.update { it.copy(elapsedSeconds = base + tick) }
}
}
}
fun stop() {
tickJob?.cancel()
tickJob = null
_uiState.update { it.copy(isRunning = false) }
}
fun lap() {
_uiState.update { it.copy(laps = it.laps + it.elapsedSeconds) }
}
}
package com.example.stopwatch
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
class StopwatchViewModelTest {
@get:Rule val mainRule = MainDispatcherRule() // from lesson 25
@Test
fun running_countsSeconds() = runTest {
val vm = StopwatchViewModel()
vm.start()
advanceTimeBy(3_100)
runCurrent()
assertEquals(3L, vm.uiState.value.elapsedSeconds)
vm.stop()
}
@Test
fun stop_freezesTime() = runTest {
val vm = StopwatchViewModel()
vm.start()
advanceTimeBy(2_100); runCurrent()
vm.stop()
advanceTimeBy(5_000); runCurrent()
assertEquals(2L, vm.uiState.value.elapsedSeconds)
}
@Test
fun lap_recordsCurrentTime() = runTest {
val vm = StopwatchViewModel()
vm.start()
advanceTimeBy(4_100); runCurrent()
vm.lap()
assertEquals(listOf(4L), vm.uiState.value.laps)
vm.stop()
}
}
Notes: restarting after a stop continues from the frozen time because start() captures elapsedSeconds as base and adds the new ticker's count. The ticker is a plain cold flow, so every start() creates a new one from zero, which is exactly the cold-flow behaviour from Build 1. Advancing 3,100 ms (not 3,000) avoids an off-by-one at the exact tick boundary. Timing tests depend on runTest sharing the Main test dispatcher's scheduler; if the elapsed values are zero, check the rule from lesson 25. The mm:ss stretch is "%02d:%02d".format(s / 60, s % 60).
Open your finished project in Android Studio. Check that the .gitignore file the wizard created is in the project root. It keeps local.properties, build folders and keystores out of Git. Never commit passwords or signing keys.
Choose Git > Create Git Repository… (or VCS > Enable Version Control Integration in older versions), select the project folder, then open Git > Commit, tick every file, and write the message “Add Android lesson 33 project”.
On github.com click New repository, name it android-lesson-33-kotlin-flows-in-practice, set it to Public, leave Add a README unticked, and click Create repository.
Back in Android Studio choose Git > Manage Remotes…, add the repository URL as origin, then choose Git > Push… and push your main branch. Prefer the terminal? Run git remote add origin <your-url> then git push -u origin main.
Add a README.md with a screenshot of your app running and a short description. Employers and clients read READMEs first. Your project is now part of your public portfolio. 🎉
Lesson 33 complete! 🎉
You covered:
- Lesson Introduction
- Prerequisite Concepts
- Conceptual Understanding: One Value or Many?
- Step-by-Step Build 1: Your First Flow
- Step-by-Step Build 2: Operators
- Step-by-Step Build 3: Hot Flows: StateFlow and SharedFlow
- Step-by-Step Build 4: Collecting Flows in Compose
- Testing Flows
- Guided Practice Exercises
- Common Beginner Mistakes (and How to Fix Them)
- Knowledge Check