Stage 3 · Lifecycle & storageProject 9

Focus Countdown Timer

Build a study timer with duration choices, Start/Pause/Resume/Reset controls, lifecycle-aware pausing, and a countdown based on elapsed time.

Your assignment: Focus Countdown Timer

Build a study timer with duration choices, Start/Pause/Resume/Reset controls, lifecycle-aware pausing, and a countdown based on elapsed time.

Intermediate · 3–4 hours of building

The download is a complete reference app. Read the brief, predict the result, then implement each step in a separate working copy. Code runs in Android Studio on a device or emulator; the website displays and explains the source.

Before you begin

  • Understand Kotlin classes, enums, data classes, and nullable properties.
  • Know Compose state and event callbacks.
  • Be ready to learn ViewModel, StateFlow, coroutine jobs, and Android lifecycle events.

By the end, you can…

  • Model a screen as immutable state and named phases.
  • Keep timer logic in a ViewModel and collect it with lifecycle awareness.
  • Own and cancel one coroutine job without launching duplicate countdowns.
  • Calculate remaining time from a monotonic clock instead of counting callbacks.
  • Pause on lifecycle ON_STOP and explain configuration changes versus durable persistence.
  • Test rounding and nonnegative countdown rules on the JVM.

Required features

  • Provide 10-second demo, 1-minute, 5-minute, and 25-minute durations.
  • Implement Start, Pause, Resume, and Reset with clear enabled states.
  • Display MM:SS and progress, never a negative remaining time.
  • Show a completion message at zero.
  • Pause whenever the activity stops, including on rotation; retain remaining time and resume only after the user taps Resume.
  • Keep a single countdown job; no background alarm, notification, service, or internet permission is needed.

Set up the project and run the reference app

  1. Open: Extract the ZIP. In Android Studio choose Open, then select the project folder containing settings.gradle.kts. Open that folder rather than just a Kotlin file.
  2. Prepare: Use Android Studio’s bundled JDK 17 or 21 in Gradle settings. In SDK Manager install Android SDK Platform 36 and any Build Tools requested during sync.
  3. Sync: Allow Gradle to download the pinned dependencies. Initial setup requires internet. The app runs offline and needs no backend, login or API key.
  4. Run: In Device Manager create an emulator with API 23 or newer, or connect a device with USB debugging. Select the app run configuration and press Run.
  5. Observe: Try the normal and boundary scenarios from Test the app. Record what changes after each action. Use Logcat to investigate a crash instead of changing several files at once.
  6. Practice: Copy the extracted folder to a working folder. Keep the build files, manifest, resources and CommonUi.kt. Rebuild the application files following the steps below. Attempt each task before consulting its reference.
PowerShell — in the project folder
.\gradlew.bat :app:assembleDebug :app:testDebugUnitTest
macOS/Linux: chmod +x gradlew, then ./gradlew :app:assembleDebug :app:testDebugUnitTest. Unit tests run without a device; manual UI checks need a device or emulator.

Keep the toolchain consistent

The projects use the existing course’s pinned toolchain: Kotlin 2.2.21, AGP 8.13.2, Gradle 8.13, and Compose BOM 2025.12.01. The original collection uses lifecycle 2.9.4. Projects 8 and 10 add Navigation 2.9.6; project 10 adds Room 2.8.4. All build files and the Gradle wrapper are included. Open TOOLCHAIN.md for setup details.

Understand the topics before coding

For each topic, connect the Kotlin concept to a visible behavior. Identify the value the screen reads and the event that can change that value.

TOPIC 01

A small state machine

Ready, Running, Paused, and Complete describe the timer's allowed phases. TimerState groups the selected duration, remaining milliseconds, and phase into one snapshot. The UI derives its button text and enabled states from that snapshot so unrelated Boolean flags cannot contradict one another.

TOPIC 02

ViewModel and saved state

A ViewModel survives activity recreation caused by rotation. SavedStateHandle also lets Android restore a small timer snapshot after system-managed process recreation when saved instance state is available. It is not a durable database: force-stop, clearing data, or starting a fresh task can reset the timer. Restored in-progress sessions become paused.

TOPIC 03

Monotonic time and coroutine scheduling

delay(100) schedules another update, but it does not promise an exact 100 ms interval. The timer therefore calculates deadline minus SystemClock.elapsedRealtime at each update. elapsedRealtime measures time since boot and is unaffected by wall-clock changes; a delayed frame changes display freshness without accumulating countdown drift.

TOPIC 04

Structured coroutine ownership

viewModelScope owns the countdown job and cancels it when the ViewModel is cleared. A Job reference prevents starting a second countdown. Pause and Reset cancel the current job. This is different from launching a coroutine in a composable body, which could repeat work during recomposition.

TOPIC 05

Lifecycle effects

DisposableEffect attaches a lifecycle observer while the screen exists and removes it during disposal. ON_STOP pauses the timer when the activity leaves the foreground. Rotation also stops the old activity, so this assignment intentionally pauses at rotation and asks the learner to resume explicitly. collectAsStateWithLifecycle controls observation; it does not cancel ViewModel work by itself.

TOPIC 06

Presentation rules and testing

Milliseconds are rounded up for display so a just-started 10-second session reads 00:10. Internal time remains in milliseconds, and the displayed seconds are derived. Pure formatting and clamping functions can run in a JVM test without an emulator, while lifecycle and button behavior still require an Android device check.

Three different lifetimes

Recomposition redraws UI from state. rememberSaveable restores small supported values after Activity recreation; a ViewModel retains screen state during configuration changes. Neither replaces permanent storage. The notes project uses Room for records that must remain after closing and reopening the app.

Build the app step by step

Work in this order. The code panels are focused excerpts from the complete files below. They may begin or end inside a function: use the full source browser when copying a file, including its imports. After every step, run the app and check the stated result.

Step 1. 1. Launch the timer and establish its behavior

Your task: Open focus-timer in Android Studio, sync, and run on an API 23+ device. Read the duration chips, status, action buttons, and background-policy note. Choose the 10-second demo to make repeated checks quick. Inspect the activity's MiniTheme and FocusTimer entry point.

Open: app/src/main/java/com/mastercoding/miniprojects/timer/MainActivity.kt

How it works: Before writing asynchronous code, define the observable behavior: Start counts down; Pause freezes; Resume continues; Reset restores the selected duration. Backgrounding and rotating both pause in this project. A precise behavior contract makes later lifecycle decisions testable.

MainActivity.kt
import androidx.lifecycle.viewmodel.compose.viewModel

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent { MiniTheme { FocusTimer() } }
    }
}

@Composable
private fun FocusTimer(timer: TimerViewModel = viewModel()) {
    val state by timer.state.collectAsStateWithLifecycle()
    val owner = LocalLifecycleOwner.current
    DisposableEffect(owner, timer) {
        val observer = LifecycleEventObserver { _, event ->
            // Product decision: leaving the screen pauses a session, including a rotation.
            if (event == Lifecycle.Event.ON_STOP) timer.pause()
        }
        owner.lifecycle.addObserver(observer)
        onDispose { owner.lifecycle.removeObserver(observer) }
    }
    MiniScreen(stringResource(R.string.timer_title)) {
        Text(stringResource(R.string.timer_instruction))
        val choices = listOf(
            10 to R.string.duration_demo,
            60 to R.string.duration_one,
Reference excerpt · lines 16–41. The full file, with imports, is in Complete source files.

Stop and check

The app displays duration choices and a ready countdown; selecting the demo displays 00:10.

Step 2. 2. Model phases and one immutable snapshot

Your task: In TimerViewModel.kt, read TimerPhase and TimerState. Sketch the permitted transitions on paper: Ready→Running, Running→Paused or Complete, Paused→Running, and any phase→Ready through Reset. Find the allowedDurations set and explain why the ViewModel validates inputs even though the screen only offers valid chips.

Open: app/src/main/java/com/mastercoding/miniprojects/timer/TimerViewModel.kt

How it works: An enum names mutually exclusive phases. Data-class copy creates a new snapshot while retaining unchanged fields. Validation belongs in the state owner as well as in the UI because a future screen, test, or accessibility action can call these functions directly.

TimerViewModel.kt
import kotlinx.coroutines.launch

enum class TimerPhase { Ready, Running, Paused, Complete }

data class TimerState(
    val durationSeconds: Int = 25 * 60,
    val remainingMillis: Long = 25 * 60 * 1_000L,
    val phase: TimerPhase = TimerPhase.Ready,
)

class TimerViewModel(private val savedState: SavedStateHandle) : ViewModel() {
    private val allowedDurations = setOf(10, 60, 300, 1500)
    private val restoredDuration = savedState.get<Int>("duration")
        ?.takeIf { it in allowedDurations } ?: 1500
    private val restoredRemaining = (savedState.get<Long>("remaining") ?: restoredDuration * 1_000L)
        .coerceIn(0L, restoredDuration * 1_000L)
    private val _state = MutableStateFlow(
        TimerState(
            durationSeconds = restoredDuration,
            remainingMillis = restoredRemaining,
            phase = when {
                restoredRemaining == 0L -> TimerPhase.Complete
                savedState.get<Boolean>("started") == true -> TimerPhase.Paused
                else -> TimerPhase.Ready
            },
        ),
Reference excerpt · lines 11–36. The full file, with imports, is in Complete source files.

Stop and check

You can predict the button label and remaining time after every state transition, including selecting another duration while paused.

Step 3. 3. Calculate and format remaining time

Your task: Open TimerMath.kt. Calculate remainingUntil(1000, 750), remainingUntil(1000, 1050), formatRemaining(9999), and formatRemaining(1) by hand. Run the provided TimerMathTest from Android Studio or with gradle testDebugUnitTest when Gradle is installed.

Open: app/src/main/java/com/mastercoding/miniprojects/timer/TimerMath.kt

How it works: Clamping at zero prevents negative countdowns after a delayed update. Adding 999 before integer division rounds positive milliseconds upward to whole seconds. Formatting uses Locale.ROOT to keep the training examples predictable. These pure rules are small enough to verify independently from coroutines and Android lifecycle.

TimerMath.kt
package com.mastercoding.miniprojects.timer

fun remainingUntil(deadlineMillis: Long, nowMillis: Long): Long =
    (deadlineMillis - nowMillis).coerceAtLeast(0L)

// Round up: a freshly started 10-second timer should still display 00:10.
fun formatRemaining(millis: Long): String {
    val seconds = (millis.coerceAtLeast(0L) + 999L) / 1_000L
    return "%02d:%02d".format(java.util.Locale.ROOT, seconds / 60, seconds % 60)
}
Reference excerpt · lines 1–11. The full file, with imports, is in Complete source files.

Stop and check

The calculations yield 250 ms, 0 ms, 00:10, and 00:01, and the JVM tests pass.

Step 4. 4. Own exactly one coroutine countdown

Your task: Read startOrResume. Follow the active-job guard, deadline calculation, launch, state publication, and delay. Tap Start repeatedly and observe that the timer completes in roughly ten elapsed seconds rather than accelerating. Do not move launch into the composable body.

Open: app/src/main/java/com/mastercoding/miniprojects/timer/TimerViewModel.kt

How it works: The deadline is captured once for a run. Each tick asks how much real monotonic time remains rather than subtracting a fixed amount per callback. The Job guard makes duplicate events harmless. viewModelScope keeps this work tied to a state owner whose lifetime is longer than one composition.

TimerViewModel.kt
    }

    fun startOrResume() {
        if (ticker?.isActive == true || state.value.remainingMillis <= 0L) return
        deadlineMillis = SystemClock.elapsedRealtime() + state.value.remainingMillis
        publish(state.value.copy(phase = TimerPhase.Running))
        // This is the only place that launches a countdown. Repeated Start events are ignored.
        ticker = viewModelScope.launch {
            while (true) {
                val remaining = remainingUntil(deadlineMillis, SystemClock.elapsedRealtime())
                publish(state.value.copy(
                    remainingMillis = remaining,
                    phase = if (remaining == 0L) TimerPhase.Complete else TimerPhase.Running,
                ))
                if (remaining == 0L) break
                delay(100L)
            }
        }
    }

    fun pause() {
        if (state.value.phase != TimerPhase.Running) return
        val remaining = remainingUntil(deadlineMillis, SystemClock.elapsedRealtime())
        ticker?.cancel()
        ticker = null
        publish(state.value.copy(
Reference excerpt · lines 46–71. The full file, with imports, is in Complete source files.

Stop and check

Repeated Start events produce one countdown; reaching zero changes the phase to Complete and ends the loop.

Step 5. 5. Implement Pause, Resume, and Reset

Your task: Read pause and reset. Start the demo, pause after about three seconds, wait five seconds, then resume. Reset while running and repeat the same check with the one-minute choice. Inspect why Pause recomputes remaining time before canceling the job.

Open: app/src/main/java/com/mastercoding/miniprojects/timer/TimerViewModel.kt

How it works: Pause records the actual remaining duration at the moment of the event. Resume creates a new deadline from that saved duration, so time spent paused does not count. Reset cancels work and creates a Ready snapshot for the selected duration; it is not implemented as another countdown job.

TimerViewModel.kt
    }

    fun pause() {
        if (state.value.phase != TimerPhase.Running) return
        val remaining = remainingUntil(deadlineMillis, SystemClock.elapsedRealtime())
        ticker?.cancel()
        ticker = null
        publish(state.value.copy(
            remainingMillis = remaining,
            phase = if (remaining == 0L) TimerPhase.Complete else TimerPhase.Paused,
        ))
    }

    fun reset() {
        ticker?.cancel()
        ticker = null
        val seconds = state.value.durationSeconds
        publish(TimerState(seconds, seconds * 1_000L))
    }

    private fun publish(next: TimerState) {
        _state.value = next
        savedState["duration"] = next.durationSeconds
        savedState["remaining"] = next.remainingMillis
        savedState["started"] = next.phase != TimerPhase.Ready
    }
Reference excerpt · lines 64–89. The full file, with imports, is in Complete source files.

Stop and check

Paused time stays fixed, Resume continues from it, and Reset restores the chosen full duration immediately.

Step 6. 6. Render state without owning the timer job

Your task: In FocusTimer, find collectAsStateWithLifecycle and trace how phase selects the status text and main button label. Inspect the progress calculation. Increase font size and test a narrow emulator; scroll the screen to reach every action.

Open: app/src/main/java/com/mastercoding/miniprojects/timer/MainActivity.kt

How it works: The composable observes data and sends user events; it does not manage countdown timing. StateFlow supplies the latest snapshot, and lifecycle-aware collection avoids unnecessary UI observation in stopped screens. Progress is derived from duration and remaining time, preventing a second progress variable from drifting out of sync.

MainActivity.kt

@Composable
private fun FocusTimer(timer: TimerViewModel = viewModel()) {
    val state by timer.state.collectAsStateWithLifecycle()
    val owner = LocalLifecycleOwner.current
    DisposableEffect(owner, timer) {
        val observer = LifecycleEventObserver { _, event ->
            // Product decision: leaving the screen pauses a session, including a rotation.
            if (event == Lifecycle.Event.ON_STOP) timer.pause()
        }
        owner.lifecycle.addObserver(observer)
        onDispose { owner.lifecycle.removeObserver(observer) }
    }
    MiniScreen(stringResource(R.string.timer_title)) {
        Text(stringResource(R.string.timer_instruction))
        val choices = listOf(
            10 to R.string.duration_demo,
            60 to R.string.duration_one,
            300 to R.string.duration_five,
            1500 to R.string.duration_twenty_five,
        )
        choices.chunked(2).forEach { row ->
            Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
                row.forEach { (seconds, label) ->
                    FilterChip(
                        selected = state.durationSeconds == seconds,
Reference excerpt · lines 24–49. The full file, with imports, is in Complete source files.

Stop and check

Status, progress, time, and button text agree in Ready, Running, Paused, and Complete phases.

Step 7. 7. Attach and remove the lifecycle observer

Your task: Find DisposableEffect. Start the demo, press Home, wait, and return. Then run another session and rotate the emulator. Explain why ON_STOP calls timer.pause and why onDispose removes the observer. Press Resume after each return.

Open: app/src/main/java/com/mastercoding/miniprojects/timer/MainActivity.kt

How it works: Coroutine ownership and foreground behavior are separate concerns: the ViewModel survives rotation and could keep running unless explicitly paused. The observer enforces this project's foreground-only policy. Removing it avoids keeping an obsolete activity owner attached after the composition is disposed.

MainActivity.kt
    val state by timer.state.collectAsStateWithLifecycle()
    val owner = LocalLifecycleOwner.current
    DisposableEffect(owner, timer) {
        val observer = LifecycleEventObserver { _, event ->
            // Product decision: leaving the screen pauses a session, including a rotation.
            if (event == Lifecycle.Event.ON_STOP) timer.pause()
        }
        owner.lifecycle.addObserver(observer)
        onDispose { owner.lifecycle.removeObserver(observer) }
    }
    MiniScreen(stringResource(R.string.timer_title)) {
        Text(stringResource(R.string.timer_instruction))
        val choices = listOf(
            10 to R.string.duration_demo,
            60 to R.string.duration_one,
            300 to R.string.duration_five,
            1500 to R.string.duration_twenty_five,
        )
        choices.chunked(2).forEach { row ->
            Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
                row.forEach { (seconds, label) ->
                    FilterChip(
                        selected = state.durationSeconds == seconds,
                        enabled = state.phase != TimerPhase.Running,
                        onClick = { timer.chooseDuration(seconds) },
                        label = { Text(stringResource(label)) },
Reference excerpt · lines 27–52. The full file, with imports, is in Complete source files.

Stop and check

Backgrounding and rotation keep the recorded remaining time and show Paused; neither resumes automatically.

Step 8. 8. Inspect restoration and finish your evidence

Your task: Read publish and the restored state initialization. Pause the timer, press Home, and run adb shell am kill com.mastercoding.miniprojects.timer from a terminal with platform-tools available, then return through Recents if the task remains. Record what your device restores. Submit the source, phase-transition sketch, JVM test result, and screenshots for paused and complete states.

Open: app/src/main/java/com/mastercoding/miniprojects/timer/TimerViewModel.kt

How it works: SavedStateHandle stores only small reconstruction values. This is best-effort Android saved-state restoration, not a promise that every fresh launch continues a timer. A system-restored in-progress snapshot is deliberately paused, avoiding accidental background execution. Distinguishing rotation, process recreation, and force-stop is part of the exercise.

TimerViewModel.kt
    }

    private fun publish(next: TimerState) {
        _state.value = next
        savedState["duration"] = next.durationSeconds
        savedState["remaining"] = next.remainingMillis
        savedState["started"] = next.phase != TimerPhase.Ready
    }
}
Reference excerpt · lines 82–91. The full file, with imports, is in Complete source files.

Stop and check

Your report distinguishes guaranteed in-session rotation retention from conditional saved-state restoration and a fresh launch reset.

Complete source files

Every text file in the ZIP is available here, including Kotlin, resources, Gradle scripts and the student README. The binary Gradle wrapper JAR is included in the ZIP. Source excerpts and downloads are generated from these same files.

Start hereResponsibility
MainActivity.ktThe launcher Activity installs the Compose UI with setContent.
CommonUi.ktMaterial 3 theme and reusable screen container with scrolling and system insets.
Application Kotlin filesState, rules, navigation or persistence introduced in the guide.
app/src/main/resImages, text resources, and the launch theme.
app/build.gradle.ktsSDK settings and the exact libraries used by this app.
README.md / TOOLCHAIN.mdStudent instructions and setup, usable without the website.

Android launcher Activity, setContent entry point and screen composition. Start tracing the app here.

21 complete text files. Copy the selected file with the code panel button, or download the full project above.

MainActivity.kt
package com.mastercoding.miniprojects.timer

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent { MiniTheme { FocusTimer() } }
    }
}

@Composable
private fun FocusTimer(timer: TimerViewModel = viewModel()) {
    val state by timer.state.collectAsStateWithLifecycle()
    val owner = LocalLifecycleOwner.current
    DisposableEffect(owner, timer) {
        val observer = LifecycleEventObserver { _, event ->
            // Product decision: leaving the screen pauses a session, including a rotation.
            if (event == Lifecycle.Event.ON_STOP) timer.pause()
        }
        owner.lifecycle.addObserver(observer)
        onDispose { owner.lifecycle.removeObserver(observer) }
    }
    MiniScreen(stringResource(R.string.timer_title)) {
        Text(stringResource(R.string.timer_instruction))
        val choices = listOf(
            10 to R.string.duration_demo,
            60 to R.string.duration_one,
            300 to R.string.duration_five,
            1500 to R.string.duration_twenty_five,
        )
        choices.chunked(2).forEach { row ->
            Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
                row.forEach { (seconds, label) ->
                    FilterChip(
                        selected = state.durationSeconds == seconds,
                        enabled = state.phase != TimerPhase.Running,
                        onClick = { timer.chooseDuration(seconds) },
                        label = { Text(stringResource(label)) },
                    )
                }
            }
        }
        Text(formatRemaining(state.remainingMillis), style = MaterialTheme.typography.displayLarge)
        LinearProgressIndicator(
            progress = { (1f - state.remainingMillis.toFloat() / (state.durationSeconds * 1_000f)).coerceIn(0f, 1f) },
            modifier = Modifier.fillMaxWidth(),
        )
        Text(stringResource(when (state.phase) {
            TimerPhase.Ready -> R.string.timer_ready
            TimerPhase.Running -> R.string.timer_running
            TimerPhase.Paused -> R.string.timer_paused
            TimerPhase.Complete -> R.string.timer_complete
        }))
        Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
            Button(
                enabled = state.phase != TimerPhase.Complete,
                onClick = {
                    if (state.phase == TimerPhase.Running) timer.pause() else timer.startOrResume()
                },
            ) {
                Text(stringResource(when (state.phase) {
                    TimerPhase.Running -> R.string.pause
                    TimerPhase.Paused -> R.string.resume
                    else -> R.string.start
                }))
            }
            OutlinedButton(onClick = timer::reset) { Text(stringResource(R.string.reset)) }
        }
        Text(stringResource(R.string.background_policy), style = MaterialTheme.typography.bodySmall)
    }
}

Test the app with concrete scenarios

Run these scenarios on your device or emulator. Write down the actual result beside the expected result. A successful Gradle build checks compilation; it does not demonstrate tapping, accessibility, keyboard behavior or rotation.

Do thisExpected result
Choose every offered duration before starting.The screen shows 00:10, 01:00, 05:00, and 25:00 respectively.
Rapidly tap Start on the 10-second demo.One timer runs; it does not accelerate or finish early because of duplicate jobs.
Pause a running session and wait five seconds.The displayed remaining time stays fixed and Resume continues from the paused duration.
Reset while running and while paused.The job is canceled, the chosen duration is restored, and the phase becomes Ready.
Let the demo run to completion and wait several more seconds.The screen stays at 00:00 with a completion message; it never displays negative time.
Press Home during a run, wait, and return.The timer is paused, and elapsed background time is not deducted.
Rotate during a run.Selected duration and remaining time are retained, and the timer intentionally shows Paused until Resume is tapped.
Change the device wall clock during a foreground run, if your test device allows it.The countdown is unaffected because it uses elapsedRealtime instead of currentTimeMillis.
Run TimerMathTest.Deadline clamping, near-zero display, and minute-boundary formatting all pass.

Your completion checklist

Project acceptance checklist0 / 6 verified

Check an item only after demonstrating it. Progress is saved in this browser.

Troubleshooting: understand the cause

The countdown speeds up after tapping Start several times.

Cause: Multiple coroutines were launched without guarding the active Job.

Repair: Keep one ticker reference and return when ticker.isActive is true; cancel it on Pause and Reset.

The timer slowly drifts behind real elapsed time.

Cause: The implementation subtracts one second for every delay callback and assumes callbacks arrive on time.

Repair: Calculate a monotonic deadline once and derive remainingUntil(deadline, elapsedRealtime()) on every tick.

The timer keeps running after the user leaves the app.

Cause: Lifecycle-aware collection was mistaken for cancellation of ViewModel work.

Repair: Keep the explicit ON_STOP observer; collecting less often does not stop the producer coroutine.

A fresh launch is expected to restore a timer after force-stop or data clearing.

Cause: SavedStateHandle was treated as permanent storage.

Repair: Document its saved-instance-state scope. Add Room or DataStore only if the product requires durable session history.

Submit your app and explain your decisions

  1. Submit the Android Studio project without build caches, .gradle or machine-specific local.properties.
  2. Include two screenshots demonstrating normal behavior and an edge case.
  3. Add a short README recording the device/API version, actions, observed outcomes, and any known limitation.
  4. Answer the questions below using function and state names from your own implementation.
  • Why does delay(100) not guarantee an exact update every 100 ms, and how does the deadline approach prevent accumulated drift?
  • What happens to this timer on recomposition, rotation, Home, process recreation, and force-stop? Explain each separately.
  • If the requirement changed to notify the user while the app is closed, why would a ViewModel coroutine alone be insufficient?
CriterionPointsWhat to demonstrate
Core behavior40All required actions give the expected result.
Validation and lifecycle20Invalid inputs, empty states, rotation and the project's persistence rules work.
Interface and accessibility20Readable contrast, labeled inputs, meaningful image descriptions, scrollable content and usable touch targets.
Code and explanation20Small named functions, clear state ownership and a correct explanation of one event-to-result trace.

Extra challenges after the core works

  • Add focus/break session phases while retaining one job owner.
  • Add a circular progress indicator and an accessible text equivalent.
  • Inject a clock into the ViewModel and use kotlinx-coroutines-test to test Pause and Resume deterministically.
  • Design a separate alarm-based version only after studying exact-alarm permissions and background execution; describe how its behavior differs from this foreground timer.

Choose one extension, write its acceptance check first, then implement it without breaking the original scenarios.

Further reading