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

+4 -2
View File
@@ -33,8 +33,10 @@ The first release of Clockula: alarms, timers, a stopwatch and a world clock.
can't silence an alarm.
- **Timers.** Run several at once, each with its own label, length and sound,
set from a keypad with saved presets. Pause, resume, reset and +1 min work
from the timer, from the live pill and from the notification. Timers keep
counting through a reboot and aren't affected by changes to the clock.
from the timer, from the live pill and from the notification. A finished
timer turns the screen on and opens over the lock screen with Stop and
+1 min. Timers keep counting through a reboot and aren't affected by changes
to the clock.
- **Stopwatch** with laps, the fastest and slowest marked, and Lap and Pause
in the notification. It keeps counting while the app is closed.
- **World clock.** An analog face for your own time, plus cities from the
+12
View File
@@ -183,6 +183,18 @@
android:taskAffinity=""
android:turnScreenOn="true" />
<!-- The timer's ring screen: the alarm's window settings, opened by the
expired-timer notification's full-screen intent (issue #15). -->
<activity
android:name=".ui.ring.TimerRingActivity"
android:exported="false"
android:excludeFromRecents="true"
android:launchMode="singleTask"
android:showOnLockScreen="true"
android:showWhenLocked="true"
android:taskAffinity=""
android:turnScreenOn="true" />
<!-- The ring itself. systemExempted per D17: not mediaPlayback (an alarm
is not the user's media session) and emphatically not shortService,
which caps at ~3 minutes against a 10-minute ring window. -->
@@ -109,10 +109,12 @@ class AlarmRingService : Service() {
/**
* The placeholder the service goes foreground with immediately; [ring]
* replaces it with the alarm's own notification as soon as the session is
* read. Same channel and same id, so the user sees one notification.
* read. Same channel and same id, so the user sees one notification — and
* the placeholder is the first post, so it is the one that opens the ring
* screen.
*/
private fun startForegroundPreparing() {
startForeground(RingNotifications.preparing(this))
startForeground(RingNotifications.preparing(this, presentation()))
}
private fun startForeground(session: RingSession) {
@@ -120,11 +122,13 @@ class AlarmRingService : Service() {
RingNotifications.ringing(
context = this,
alarm = session.alarm,
presentation = RingPresentationPolicy.forCapabilities(capabilities.snapshot()),
presentation = presentation(),
),
)
}
private fun presentation() = RingPresentationPolicy.forCapabilities(capabilities.snapshot())
private fun startForeground(notification: Notification) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
// systemExempted: Android's own guidance names an app holding an
@@ -72,9 +72,17 @@ object RingNotifications {
* The notification the service goes foreground with the instant it starts,
* before it has read anything: `startForeground` must not wait on a database
* read. [ringing] replaces it under the same id a moment later.
*
* It already carries the full-screen intent. SystemUI launches one only
* when a notification is first *posted*, never on an update, so an intent
* that only [ringing] carried would leave a locked phone ringing behind a
* black screen (issue #15). The ring screen reads the session itself and
* closes when there is none, so a dismiss that lands before the service's
* read costs one brief screen wake, never a ring screen left up.
*/
fun preparing(context: Context): Notification =
NotificationCompat.Builder(context, RING_CHANNEL_ID)
fun preparing(context: Context, presentation: RingPresentation): Notification {
val show = showPendingIntent(context)
val builder = NotificationCompat.Builder(context, RING_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(context.getString(R.string.alarm_notification_title))
.setCategory(NotificationCompat.CATEGORY_ALARM)
@@ -82,8 +90,10 @@ object RingNotifications {
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setOngoing(true)
.setAutoCancel(false)
.setContentIntent(showPendingIntent(context))
.build()
.setContentIntent(show)
if (presentation.useFullScreenIntent) builder.setFullScreenIntent(show, true)
return builder.build()
}
/** The ringing notification, presented as [presentation] says the platform allows. */
fun ringing(
@@ -24,6 +24,7 @@ object TimerIntents {
const val REQUEST_RESUME: Int = 14
const val REQUEST_RESET: Int = 15
const val REQUEST_ADD_TIME: Int = 16
const val REQUEST_RING: Int = 17
val ALL_REQUEST_CODES: List<Int> = listOf(
REQUEST_EXPIRY,
@@ -32,6 +33,7 @@ object TimerIntents {
REQUEST_RESUME,
REQUEST_RESET,
REQUEST_ADD_TIME,
REQUEST_RING,
)
/** base * 100_000 + (timerId % 100_000) — the stride `RingNotifications` already uses. */
@@ -16,6 +16,7 @@ import de.jeanlucmakiola.clockula.domain.timer.TimerNotificationAction
import de.jeanlucmakiola.clockula.domain.timer.TimerNotificationState
import de.jeanlucmakiola.clockula.timer.TimerIntents
import de.jeanlucmakiola.clockula.timer.receiver.TimerActionReceiver
import de.jeanlucmakiola.clockula.ui.ring.TimerRingActivity
/**
* One channel and one id for however many timers: the foreground service's
@@ -30,9 +31,20 @@ object TimerNotifications {
const val TIMER_CHANNEL_ID: String = "timers"
/** One notification for every timer (D17), so one id. */
/** One notification for every timer (D17), so one id at a time — of these two. */
const val TIMER_NOTIFICATION_ID: Int = 1_002
/**
* The id an expiry's alerting post swaps to, and back from on the next one.
* SystemUI launches a full-screen intent only for a newly *posted*
* notification, and an expiry is otherwise an update of the countdown's.
*/
const val TIMER_ALERT_NOTIFICATION_ID: Int = 1_004
/** The id an alerting post goes up under when [current] is on screen. */
fun alertingIdAfter(current: Int): Int =
if (current == TIMER_NOTIFICATION_ID) TIMER_ALERT_NOTIFICATION_ID else TIMER_NOTIFICATION_ID
/** Idempotent; called from the app class on every start. */
fun createChannel(context: Context) {
val channel = NotificationChannel(
@@ -75,8 +87,12 @@ object TimerNotifications {
.setContentIntent(showPendingIntent(context))
.build()
/** The subject, and a count. Two quick actions; the app row also offers subtraction. */
fun timer(context: Context, state: TimerNotificationState): Notification {
/**
* The subject, and a count. Two quick actions; the app row also offers
* subtraction. An expiry's one alerting post also opens [TimerRingActivity]
* over the lock screen when [fullScreen] allows it.
*/
fun timer(context: Context, state: TimerNotificationState, fullScreen: Boolean): Notification {
val builder = NotificationCompat.Builder(context, TIMER_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(
@@ -130,6 +146,11 @@ object TimerNotifications {
builder.setShowWhen(false).setContentText(bodyFor(context, state))
}
if (fullScreen && state.mode == TimerMode.EXPIRED && !state.alertOnce) {
builder.setPriority(NotificationCompat.PRIORITY_MAX)
.setFullScreenIntent(ringPendingIntent(context), true)
}
state.actions.forEach { action ->
builder.addAction(
R.drawable.ic_notification,
@@ -179,6 +200,14 @@ object TimerNotifications {
PENDING_INTENT_FLAGS,
)
private fun ringPendingIntent(context: Context): PendingIntent = PendingIntent.getActivity(
context,
TimerIntents.REQUEST_RING,
Intent(context, TimerRingActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK),
PENDING_INTENT_FLAGS,
)
private fun actionPendingIntent(
context: Context,
action: TimerNotificationAction,
@@ -10,6 +10,7 @@ import android.os.IBinder
import android.os.PowerManager
import androidx.core.app.NotificationManagerCompat
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.clockula.alarm.AlarmCapabilities
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.clockula.data.timers.TimerRepository
import de.jeanlucmakiola.clockula.domain.ring.RingFallbackPolicy
@@ -71,6 +72,9 @@ class TimerService : Service() {
@Inject
lateinit var settings: SettingsPrefs
@Inject
lateinit var capabilities: AlarmCapabilities
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val audio by lazy { RingAudioPlayer(this) }
@@ -105,6 +109,13 @@ class TimerService : Service() {
*/
private var wentForeground: Boolean = false
/**
* The id the notification is up under. An alerting post moves it to the
* other of the two, so an expiry is a *new* notification and its
* full-screen intent fires — every other post keeps it where it is.
*/
private var notificationId: Int = TimerNotifications.TIMER_NOTIFICATION_ID
/** The last start command's id, so [stop] can decline to die on a newer one. */
private var lastStartId: Int = 0
@@ -170,7 +181,7 @@ class TimerService : Service() {
}
applyRing(state.ring)
post(notification)
post(notification, sounding = state.ring is TimerRingDecision.Sound)
scheduleDelays(state)
progressRefresh?.cancel()
progressRefresh = if (notification.mode == TimerMode.RUNNING) {
@@ -239,9 +250,20 @@ class TimerService : Service() {
}
}
private fun post(state: TimerNotificationState) {
if (!state.alertOnce) alreadyAlerted = true
startForeground(TimerNotifications.timer(this, state))
private fun post(state: TimerNotificationState, sounding: Boolean) {
var fullScreen = false
if (!state.alertOnce) {
alreadyAlerted = true
// Moving the foreground notification to a new id removes the old one,
// so the user still sees one notification.
notificationId = TimerNotifications.alertingIdAfter(notificationId)
// Only a sounding timer opens its ring screen: one suppressed by a
// ringing alarm would cover the alarm's screen (D9), and one that
// auto-silenced before a process restart would wake the display for
// nothing (D11).
fullScreen = sounding && capabilities.snapshot().canUseFullScreenIntent
}
startForeground(TimerNotifications.timer(this, state, fullScreen))
}
/**
@@ -279,12 +301,12 @@ class TimerService : Service() {
// ringing service: an app holding an exact-alarm permission,
// continuing an alarm-shaped event in the background (D6).
startForeground(
TimerNotifications.TIMER_NOTIFICATION_ID,
notificationId,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED,
)
} else {
startForeground(TimerNotifications.TIMER_NOTIFICATION_ID, notification)
startForeground(notificationId, notification)
}
}
@@ -314,7 +336,11 @@ class TimerService : Service() {
stopForeground(STOP_FOREGROUND_REMOVE)
// The placeholder is posted before anything is read, so a service that
// starts with nothing active must take its own notification with it.
NotificationManagerCompat.from(this).cancel(TimerNotifications.TIMER_NOTIFICATION_ID)
NotificationManagerCompat.from(this).apply {
cancel(TimerNotifications.TIMER_NOTIFICATION_ID)
cancel(TimerNotifications.TIMER_ALERT_NOTIFICATION_ID)
}
notificationId = TimerNotifications.TIMER_NOTIFICATION_ID
wentForeground = false
// Not `stopSelf()`: resetting the last timer and immediately starting a
// new one delivers a start command to this still-live instance, and
@@ -0,0 +1,63 @@
package de.jeanlucmakiola.clockula.ui.ring
import android.os.Bundle
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.clockula.ui.theme.ClockulaTheme
/**
* The window an expired timer gets, opened by the timer notification's
* full-screen intent: over the lock screen with the display turned on, for as
* long as any timer has expired. Without it, a timer finishing on a locked
* phone rang behind a black screen (issue #15).
*
* Deliberately not [de.jeanlucmakiola.clockula.MainActivity]: that would put
* the whole app over the lock screen.
*/
@AndroidEntryPoint
class TimerRingActivity : ComponentActivity() {
private val viewModel: TimerRingViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// As AlarmRingActivity: shown over the lock screen, display on, and
// kept on for this window's life only.
setShowWhenLocked(true)
setTurnScreenOn(true)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
setContent {
ClockulaTheme {
BackHandler(enabled = true) { /* A timer is not stopped by leaving. */ }
val state by viewModel.state.collectAsStateWithLifecycle()
when (val current = state) {
TimerRingUiState.Loading ->
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {}
is TimerRingUiState.Ringing -> TimerRingScreen(
state = current,
onStop = viewModel::onStop,
onAddMinute = viewModel::onAddMinute,
)
// Stopped here, from the shade or from the Timers tab, or
// auto-silenced: whoever ended the ring closes this window.
TimerRingUiState.Finished -> LaunchedEffect(Unit) { finish() }
}
}
}
}
}
@@ -0,0 +1,112 @@
package de.jeanlucmakiola.clockula.ui.ring
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Timer
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
/**
* The timer ring screen: the label, "Finished", and the notification's two
* buttons. Back is swallowed at [TimerRingActivity]'s composition root, for the
* same reason the alarm's is.
*/
@Composable
internal fun TimerRingScreen(
state: TimerRingUiState.Ringing,
onStop: () -> Unit,
onAddMinute: () -> Unit,
modifier: Modifier = Modifier,
) {
Surface(modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {
Column(
modifier = Modifier.fillMaxSize().safeDrawingPadding().padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally,
) {
TimerBadge()
Text(
text = stringResource(R.string.timer_finished),
style = MaterialTheme.typography.displayMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = state.label.ifBlank { stringResource(R.string.timer_notification_title) },
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (state.otherExpired > 0) {
Text(
text = pluralStringResource(R.plurals.timer_more_active, state.otherExpired, state.otherExpired),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
OutlinedButton(onClick = onAddMinute) {
Text(stringResource(R.string.timer_add_minute))
}
Button(onClick = onStop) {
Text(stringResource(R.string.timer_stop))
}
}
}
}
}
/** The alarm screen's pulsing badge, with the timer's icon. */
@Composable
private fun TimerBadge() {
val reduceMotion = rememberReduceMotion()
val pulse = rememberInfiniteTransition(label = "timer-ring-pulse")
val scale by pulse.animateFloat(
initialValue = 1f,
targetValue = if (reduceMotion) 1f else 1.08f,
animationSpec = infiniteRepeatable(tween(900), RepeatMode.Reverse),
label = "timer-ring-pulse-scale",
)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(96.dp)
.scale(scale)
.background(MaterialTheme.colorScheme.primaryContainer, CircleShape),
) {
Icon(
imageVector = Icons.Rounded.Timer,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(48.dp),
)
}
}
@@ -0,0 +1,18 @@
package de.jeanlucmakiola.clockula.ui.ring
sealed interface TimerRingUiState {
/** The timers have not been read yet. Every action is ignored. */
data object Loading : TimerRingUiState
data class Ringing(
/** The timer the screen speaks for: the first expired one, in subject order. */
val timerId: Long,
/** Verbatim; blank stays blank — the screen supplies the fallback string. */
val label: String,
/** Expired timers besides [timerId]; each gets its own Stop once this one stops. */
val otherExpired: Int,
) : TimerRingUiState
/** Nothing has expired any more. The activity finishes. */
data object Finished : TimerRingUiState
}
@@ -0,0 +1,88 @@
package de.jeanlucmakiola.clockula.ui.ring
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.clockula.data.timers.TimerRepository
import de.jeanlucmakiola.clockula.data.timers.TimerRingStateStore
import de.jeanlucmakiola.clockula.domain.time.ElapsedRealtimeClock
import de.jeanlucmakiola.clockula.domain.time.Ticker
import de.jeanlucmakiola.clockula.domain.time.WallClock
import de.jeanlucmakiola.clockula.domain.timer.TimerReadings
import de.jeanlucmakiola.clockula.domain.timer.TimerRing
import de.jeanlucmakiola.clockula.timer.TimerEngine
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.seconds
import javax.inject.Inject
/**
* The timer ring screen's behaviour: which expired timer it speaks for, and the
* same two verbs as the notification's buttons, through the engine — so the
* screen, the shade and the Timers tab can never disagree about what Stop does.
*/
@HiltViewModel
class TimerRingViewModel @Inject constructor(
timers: TimerRepository,
ringState: TimerRingStateStore,
private val engine: TimerEngine,
elapsed: ElapsedRealtimeClock,
wall: WallClock,
ticker: Ticker,
) : ViewModel() {
/**
* Re-read on a tick as well as on every write: a row stored RUNNING *reads*
* EXPIRED once its time is up, before the engine's sweep has written it, and
* the auto-silence window ends without anything being written at all.
*/
val state: StateFlow<TimerRingUiState> =
combine(timers.timers(), ringState.soundingSince, ticker.ticks(TICK)) { rows, soundingSince, _ ->
val now = wall.now()
val expired = TimerReadings.expired(
TimerReadings.active(rows, elapsed.elapsedRealtime(), now),
)
val subject = expired.firstOrNull()
// Auto-silence leaves the rows EXPIRED (D11) but ends the ring, and a
// screen held on over a silent timer is only a battery drain.
val silenced = soundingSince != null && now - soundingSince >= TimerRing.AUTO_SILENCE_AFTER
if (subject == null || silenced) {
TimerRingUiState.Finished
} else {
TimerRingUiState.Ringing(
timerId = subject.timer.id,
label = subject.timer.label,
otherExpired = expired.size - 1,
)
}
}
.distinctUntilChanged()
// Stops a while after the screen goes to the background, so a ring
// screen left behind Home does not tick forever; the grace period
// covers a rotation without flashing back to Loading.
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), TimerRingUiState.Loading)
/**
* "Stop": the notification's RESET on an expired timer. The guarded verb,
* because the id may be one emission old: a "+1 min" from here or the shade
* can have set the timer running again, and that countdown must survive.
*/
fun onStop() {
val ringing = state.value as? TimerRingUiState.Ringing ?: return
viewModelScope.launch { engine.dismissExpired(ringing.timerId) }
}
fun onAddMinute() {
val ringing = state.value as? TimerRingUiState.Ringing ?: return
viewModelScope.launch { engine.addTime(ringing.timerId) }
}
private companion object {
val TICK = 1.seconds
const val STOP_TIMEOUT_MS = 5_000L
}
}
@@ -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)
}
}