fix: turn the screen on when an alarm or timer goes off

SystemUI launches a full-screen intent only when a notification is first
posted, never on an update. The alarm service went foreground with a
placeholder that had none and added it in the update, so a locked phone
could ring behind a black screen. The placeholder now carries it.

Timers had no full-screen screen at all. A finished timer now opens
TimerRingActivity over the lock screen with Stop and +1 min, posted
under a second notification id so the expiry is a fresh post. It only
does so while the timer is sounding, so it never covers a ringing
alarm's screen or wakes the display for an already auto-silenced timer.

Fixes #15
This commit is contained in:
makiolaj committed 2026-10-21 14:16:47 +02:00
1 parent 2f39b00398
commit badd7aaa54
12 files changed
+519 -19

No files matched your search

@@ -0,0 +1,134 @@
package de.jeanlucmakiola.clockula.ui.ring
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.TimerState
import de.jeanlucmakiola.clockula.domain.timer.TimerRing
import de.jeanlucmakiola.clockula.testing.MainDispatcherExtension
import de.jeanlucmakiola.clockula.testing.T0
import de.jeanlucmakiola.clockula.testing.TimerEngineHarness
import de.jeanlucmakiola.clockula.testing.expiredTimer
import de.jeanlucmakiola.clockula.testing.runningTimerWith
import de.jeanlucmakiola.clockula.testing.timerEngineHarness
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.RegisterExtension
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
/**
* The timer ring screen over the **real** timer engine: which timer it speaks
* for, that its buttons are the notification's verbs, and that it closes
* whenever the ring ends — including the auto-silence that leaves rows EXPIRED.
*/
class TimerRingViewModelTest {
@JvmField
@RegisterExtension
val main = MainDispatcherExtension()
/** Subscribed for the whole test, as the visible screen is: the state is shared while observed. */
private fun TestScope.viewModel(harness: TimerEngineHarness) = TimerRingViewModel(
timers = harness.timers,
ringState = harness.ringState,
engine = harness.engine,
elapsed = harness.elapsed,
wall = harness.wallClock,
ticker = harness.ticker,
).also { viewModel -> backgroundScope.launch { viewModel.state.collect {} } }
@Test
fun `an expired timer rings, with the other expired ones counted`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = timerEngineHarness(tempDir)
harness.given(expiredTimer(id = 1L, label = "Pasta", sortOrder = 0))
harness.given(expiredTimer(id = 2L, label = "Eggs", sortOrder = 1))
val viewModel = viewModel(harness)
advanceUntilIdle()
assertThat(viewModel.state.value)
.isEqualTo(TimerRingUiState.Ringing(timerId = 1L, label = "Pasta", otherExpired = 1))
}
@Test
fun `nothing expired finishes the screen`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
val harness = timerEngineHarness(tempDir)
harness.given(runningTimerWith(id = 1L, left = 3.minutes, elapsedNow = harness.elapsed.value))
val viewModel = viewModel(harness)
advanceUntilIdle()
assertThat(viewModel.state.value).isEqualTo(TimerRingUiState.Finished)
}
@Test
fun `stop resets the timer and the screen moves on to the next expired one`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = timerEngineHarness(tempDir)
harness.given(expiredTimer(id = 1L, label = "Pasta", sortOrder = 0))
harness.given(expiredTimer(id = 2L, label = "Eggs", sortOrder = 1))
val viewModel = viewModel(harness)
advanceUntilIdle()
viewModel.onStop()
advanceUntilIdle()
assertThat(harness.stored(1L)!!.state).isEqualTo(TimerState.IDLE)
assertThat(viewModel.state.value)
.isEqualTo(TimerRingUiState.Ringing(timerId = 2L, label = "Eggs", otherExpired = 0))
}
@Test
fun `stop leaves a timer that is running again alone`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = timerEngineHarness(tempDir)
harness.given(expiredTimer(id = 1L))
val viewModel = viewModel(harness)
advanceUntilIdle()
// "+1 min" from the shade lands before the screen has re-read the rows.
harness.engine.addTime(1L)
viewModel.onStop()
advanceUntilIdle()
assertThat(harness.stored(1L)!!.state).isEqualTo(TimerState.RUNNING)
}
@Test
fun `plus one minute runs the timer again and finishes the screen`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = timerEngineHarness(tempDir)
harness.given(expiredTimer(id = 1L))
val viewModel = viewModel(harness)
advanceUntilIdle()
viewModel.onAddMinute()
advanceUntilIdle()
assertThat(harness.stored(1L)!!.state).isEqualTo(TimerState.RUNNING)
assertThat(viewModel.state.value).isEqualTo(TimerRingUiState.Finished)
}
@Test
fun `an auto-silenced ring finishes the screen though the row stays expired`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = timerEngineHarness(tempDir)
harness.given(expiredTimer(id = 1L))
harness.ringState.set(T0)
val viewModel = viewModel(harness)
advanceUntilIdle()
assertThat(viewModel.state.value).isInstanceOf(TimerRingUiState.Ringing::class.java)
harness.wallClock.advance(TimerRing.AUTO_SILENCE_AFTER + 1.seconds)
harness.ticker.tick()
advanceUntilIdle()
assertThat(harness.stored(1L)!!.state).isEqualTo(TimerState.EXPIRED)
assertThat(viewModel.state.value).isEqualTo(TimerRingUiState.Finished)
}
}