feat(alarm): the engine — fire, ring, snooze, dismiss, re-resolve

The engine owns every write and talks to Android through four interfaces it
does not implement, which is what keeps it a plain Kotlin class with 54 JVM
tests over fakes rather than something that needs a device to exercise.

At most one alarm rings, and a newly-firing alarm takes over — so closing a
cycle only touches the shared ring and auto-silence slots when that alarm is
the one actually ringing. Dismissing a snoozed alarm from its notification must
not silence a different alarm mid-ring.

Every read-modify-write over alarm_states runs under one mutex. A cold start
triggered by the fire broadcast otherwise races its own reschedule pass and can
overwrite ringingSince, which silences the alarm that woke the process.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 16:05:03 +02:00
co-authored by Claude Opus 5
parent 92483cac4b
commit edbbf0070d
10 changed files with 1594 additions and 0 deletions
@@ -0,0 +1,8 @@
package de.jeanlucmakiola.clockula.alarm
import de.jeanlucmakiola.clockula.domain.alarm.CapabilitySnapshot
/** What the platform will currently let the app do, read afresh. */
interface AlarmCapabilities {
fun snapshot(): CapabilitySnapshot
}
@@ -0,0 +1,283 @@
package de.jeanlucmakiola.clockula.alarm
import de.jeanlucmakiola.clockula.data.alarms.AlarmRepository
import de.jeanlucmakiola.clockula.data.alarms.AlarmStateRepository
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.clockula.domain.Alarm
import de.jeanlucmakiola.clockula.domain.AlarmSettings
import de.jeanlucmakiola.clockula.domain.alarm.AlarmResolution
import de.jeanlucmakiola.clockula.domain.alarm.AlarmResolver
import de.jeanlucmakiola.clockula.domain.alarm.AlarmRing
import de.jeanlucmakiola.clockula.domain.alarm.AlarmRingState
import de.jeanlucmakiola.clockula.domain.alarm.FireOutcome
import de.jeanlucmakiola.clockula.domain.alarm.FireSource
import de.jeanlucmakiola.clockula.domain.alarm.RingSession
import de.jeanlucmakiola.clockula.domain.alarm.ScheduledFire
import de.jeanlucmakiola.clockula.domain.alarm.SnoozeOutcome
import de.jeanlucmakiola.clockula.domain.alarm.UpcomingAlarm
import de.jeanlucmakiola.clockula.domain.resolveSettings
import de.jeanlucmakiola.clockula.domain.time.WallClock
import de.jeanlucmakiola.clockula.domain.time.ZoneProvider
import de.jeanlucmakiola.clockula.system.RebootRepair
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant
import javax.inject.Inject
import javax.inject.Singleton
/**
* The orchestrator: it owns every write the pure resolver asks for, and every
* call into the four Android seams. Pure Kotlin — no Android import may ever
* appear in this file.
*
* It is never driven by a Flow. Resolution writes state, so an engine that
* rescheduled on every repository emission would re-trigger itself on its own
* writes; [reschedule] is called explicitly instead, from the app class, the
* receivers and the edit surface.
*
* Every entry point is a read-modify-write over `alarm_states`, and they arrive
* from threads that know nothing about each other: a broadcast on
* `Dispatchers.Default`, the app class's launch-time re-sync, the ring screen.
* They are serialised on [lock] so that a cold start *caused by* the fire
* broadcast cannot overwrite `ringingSince` or the handled-occurrence watermark
* with a value it read before [onFire] wrote it — which would either silence the
* alarm or let it ring twice.
*/
@Singleton
class AlarmEngine @Inject constructor(
private val alarms: AlarmRepository,
private val states: AlarmStateRepository,
private val settings: SettingsPrefs,
private val scheduler: AlarmScheduler,
private val ring: RingCoordinator,
private val notifier: AlarmNotifier,
private val rebootRepair: RebootRepair,
private val wallClock: WallClock,
private val zones: ZoneProvider,
) {
/**
* A `Mutex` is not reentrant, so exactly one layer takes it: the public
* entry points below. Every private `…Locked` body assumes it is held and
* calls its neighbours' locked forms, never the public ones.
*/
private val lock = Mutex()
/** Resolve every alarm, persist what changed, register the single earliest. Ties: lower id. */
suspend fun reschedule(): ScheduledFire? = lock.withLock { rescheduleLocked() }
/** [scheduledFor] is the instant the PendingIntent carried, not `now`. */
suspend fun onFire(alarmId: Long, scheduledFor: Instant): FireOutcome =
lock.withLock { onFireLocked(alarmId, scheduledFor) }
suspend fun onAutoSilence(alarmId: Long): Unit = lock.withLock {
val state = states.state(alarmId)
if (state.ringingSince == null) return@withLock
// Missed, not snoozed: the ring stops, the cycle closes, the next
// occurrence is scheduled. There is no missed-alarm notification in v1.
closeCycleLocked(alarmId, state)
}
suspend fun snooze(alarmId: Long): SnoozeOutcome = lock.withLock { snoozeLocked(alarmId) }
suspend fun dismiss(alarmId: Long): Unit =
lock.withLock { closeCycleLocked(alarmId, states.state(alarmId)) }
/** Boot repair (timers + stopwatch), then resume any interrupted ring, then reschedule. */
suspend fun onBootCompleted(): ScheduledFire? = lock.withLock {
rebootRepair.repairIfRebooted()
resumeRingingIfAnyLocked()
rescheduleLocked()
}
suspend fun onSystemTimeOrZoneChanged(): ScheduledFire? = reschedule()
suspend fun onPackageReplaced(): ScheduledFire? = reschedule()
/** Restarts a ring the process death or reboot interrupted. Returns the alarm id, or null. */
suspend fun resumeRingingIfAny(): Long? = lock.withLock { resumeRingingIfAnyLocked() }
/** What the ringing service needs. Null when nothing is ringing. */
suspend fun ringSession(): RingSession? = lock.withLock {
val state = states.ringing() ?: return@withLock null
val since = state.ringingSince ?: return@withLock null
val alarm = alarms.find(state.alarmId) ?: return@withLock null
val resolved: AlarmSettings = alarm.resolveSettings(settings.currentDefaults())
RingSession(
alarm = alarm,
settings = resolved,
startedAt = since,
canSnooze = state.snoozeCount < resolved.snoozeLimit,
)
}
/** Read-only. Never writes. Sorted by nextFire ascending, nulls last, then by id. */
fun upcoming(): Flow<List<UpcomingAlarm>> =
combine(alarms.alarms(), states.states()) { alarms, states ->
val now = wallClock.now()
val zone = zones.current()
val byId = states.associateBy { it.alarmId }
alarms
.map { alarm ->
// The resolver's state is deliberately discarded: a read must
// not write, however much it resolved on the way past.
val resolution = AlarmResolver.resolve(
alarm,
byId[alarm.id] ?: AlarmRingState.initial(alarm.id),
now,
zone,
)
UpcomingAlarm(alarm, resolution.nextFire, resolution.source)
}
.sortedWith(
compareBy<UpcomingAlarm, Instant?>(nullsLast()) { it.nextFire }
.thenBy { it.alarm.id },
)
}
private suspend fun rescheduleLocked(): ScheduledFire? {
val now = wallClock.now()
val zone = zones.current()
var earliest: Pair<Alarm, AlarmResolution>? = null
for (alarm in alarms.alarms().first()) {
val state = states.state(alarm.id)
val resolution = AlarmResolver.resolve(alarm, state, now, zone)
persist(alarm, state, resolution)
val fireAt = resolution.nextFire ?: continue
val best = earliest
val isEarlier = best == null ||
fireAt < best.second.nextFire!! ||
(fireAt == best.second.nextFire && alarm.id < best.first.id)
if (isEarlier) earliest = alarm to resolution
}
val winner = earliest ?: run {
scheduler.cancelNext()
return null
}
val fireAt = winner.second.nextFire!!
val mode = scheduler.scheduleNext(fireAt, winner.first.id)
return ScheduledFire(winner.first.id, fireAt, winner.second.source, mode)
}
private suspend fun onFireLocked(alarmId: Long, scheduledFor: Instant): FireOutcome {
val now = wallClock.now()
val alarm = alarms.find(alarmId)
// Deleted, switched off, or a PendingIntent so stale the moment has gone:
// re-resolve the slot and stay quiet.
if (alarm == null || !alarm.enabled || now - scheduledFor > AlarmRing.FIRE_GRACE) {
rescheduleLocked()
return FireOutcome.Ignored
}
// At most one alarm rings, and the newly-firing one takes over. The one
// it displaces keeps its handled-occurrence watermark, so it does not
// come back the moment the slot is re-resolved.
states.ringing()?.takeIf { it.alarmId != alarmId }?.let { other ->
ring.stopRinging(other.alarmId)
states.save(other.copy(ringingSince = null, snoozedUntil = null, snoozeCount = 0))
}
val state = states.state(alarmId)
val isSnoozeFire = state.snoozedUntil != null && state.snoozedUntil == scheduledFor
val opened = if (isSnoozeFire) {
// The cycle is the same one: the count and the watermark carry over.
state.copy(snoozedUntil = null)
} else {
state.copy(snoozedUntil = null, snoozeCount = 0, handledOccurrence = scheduledFor)
}
// The watermark is written before anything else can interrupt, and the
// slot is re-resolved while the alarm is not yet marked as ringing — so
// it holds this alarm's *next* occurrence rather than nothing at all.
states.save(opened)
notifier.clearSnoozed(alarmId)
rescheduleLocked()
states.save(states.state(alarmId).copy(ringingSince = now))
ring.startRinging(alarmId)
scheduler.scheduleAutoSilence(now + AlarmRing.AUTO_SILENCE_AFTER, alarmId)
return FireOutcome.Ringing(alarmId)
}
private suspend fun snoozeLocked(alarmId: Long): SnoozeOutcome {
val state = states.state(alarmId)
if (state.ringingSince == null) return SnoozeOutcome.NotRinging
val alarm = alarms.find(alarmId) ?: return SnoozeOutcome.NotRinging
val resolved = alarm.resolveSettings(settings.currentDefaults())
// A limit of 0 means snoozing is off, not unlimited — and a refused
// snooze dismisses, so the dismiss path can never strand the user.
if (state.snoozeCount >= resolved.snoozeLimit) {
closeCycleLocked(alarmId, state)
return SnoozeOutcome.LimitReached(alarmId)
}
val until = wallClock.now() + resolved.snoozeMinutes.minutes
val snoozed = state.copy(
ringingSince = null,
snoozedUntil = until,
snoozeCount = state.snoozeCount + 1,
)
states.save(snoozed)
ring.stopRinging(alarmId)
scheduler.cancelAutoSilence()
notifier.showSnoozed(alarm, until)
rescheduleLocked()
return SnoozeOutcome.Snoozed(alarmId, until, resolved.snoozeLimit - snoozed.snoozeCount)
}
private suspend fun resumeRingingIfAnyLocked(): Long? {
val state = states.ringing() ?: return null
val since = state.ringingSince ?: return null
// Slept through it: leave it to the next resolution to close the cycle.
if (wallClock.now() - since >= AlarmRing.AUTO_SILENCE_AFTER) return null
val alarm = alarms.find(state.alarmId)?.takeIf { it.enabled } ?: return null
ring.startRinging(alarm.id)
// The backstop is measured from when the ring started, not from now, so
// a reboot does not buy the alarm another ten minutes.
scheduler.scheduleAutoSilence(since + AlarmRing.AUTO_SILENCE_AFTER, alarm.id)
return alarm.id
}
/** Everything the resolver asked to be written, and nothing it did not. */
private suspend fun persist(alarm: Alarm, before: AlarmRingState, resolution: AlarmResolution) {
if (resolution.state != before) states.save(resolution.state)
if (resolution.clearSkipFlag) alarms.setSkipNextOccurrence(alarm.id, false)
if (resolution.disableAlarm) alarms.setEnabled(alarm.id, false)
}
/**
* Dismiss and auto-silence differ only in who asked: both stop the ring,
* drop the snooze, disable a one-shot alarm (the cycle has closed, D8) and
* re-resolve the slot. The handled-occurrence watermark stays, so the
* occurrence just closed cannot ring again on its way out of the grace window.
*
* The ring service and the auto-silence registration are *global* — one slot
* each, because at most one alarm rings (D7) — so only the alarm that is
* actually ringing may touch them. Dismissing a merely *snoozed* alarm from
* its notification must leave a different, genuinely ringing alarm sounding,
* with its backstop intact.
*/
private suspend fun closeCycleLocked(alarmId: Long, state: AlarmRingState) {
if (state.ringingSince != null) {
ring.stopRinging(alarmId)
scheduler.cancelAutoSilence()
}
notifier.clearSnoozed(alarmId)
val closed = state.copy(ringingSince = null, snoozedUntil = null, snoozeCount = 0)
if (closed != state) states.save(closed)
val alarm = alarms.find(alarmId)
if (alarm != null && !alarm.repeatDays.isRepeating) alarms.setEnabled(alarmId, false)
rescheduleLocked()
}
}
@@ -0,0 +1,33 @@
package de.jeanlucmakiola.clockula.alarm
/**
* The actions, extras and `PendingIntent` request codes, spelled once. Two
* PendingIntents sharing a request code silently overwrite each other, which is
* how an internal backstop eats a user's next alarm — so the codes are declared
* together and asserted to be distinct.
*/
object AlarmIntents {
private const val PREFIX = "de.jeanlucmakiola.clockula."
const val ACTION_FIRE: String = PREFIX + "action.ALARM_FIRE"
const val ACTION_AUTO_SILENCE: String = PREFIX + "action.ALARM_AUTO_SILENCE"
const val ACTION_SNOOZE: String = PREFIX + "action.ALARM_SNOOZE"
const val ACTION_DISMISS: String = PREFIX + "action.ALARM_DISMISS"
const val EXTRA_ALARM_ID: String = PREFIX + "extra.ALARM_ID"
const val EXTRA_FIRE_AT: String = PREFIX + "extra.FIRE_AT"
const val REQUEST_FIRE: Int = 1
const val REQUEST_AUTO_SILENCE: Int = 2
const val REQUEST_SHOW: Int = 3
const val REQUEST_SNOOZE: Int = 4
const val REQUEST_DISMISS: Int = 5
val ALL_REQUEST_CODES: List<Int> = listOf(
REQUEST_FIRE,
REQUEST_AUTO_SILENCE,
REQUEST_SHOW,
REQUEST_SNOOZE,
REQUEST_DISMISS,
)
}
@@ -0,0 +1,11 @@
package de.jeanlucmakiola.clockula.alarm
import de.jeanlucmakiola.clockula.domain.Alarm
import kotlin.time.Instant
/** The snoozed notification — the one surface the engine posts directly. */
interface AlarmNotifier {
fun showSnoozed(alarm: Alarm, until: Instant)
fun clearSnoozed(alarmId: Long)
}
@@ -0,0 +1,19 @@
package de.jeanlucmakiola.clockula.alarm
import de.jeanlucmakiola.clockula.domain.alarm.ScheduleMode
import kotlin.time.Instant
/** The AlarmManager seam. Two slots, deliberately separate. */
interface AlarmScheduler {
/** Replaces the single next-alarm registration. Returns the mode actually used. */
fun scheduleNext(fireAt: Instant, alarmId: Long): ScheduleMode
fun cancelNext()
fun scheduleAutoSilence(at: Instant, alarmId: Long)
fun cancelAutoSilence()
/** `AlarmManager.getNextAlarmClock()`, as the *system* sees it. */
fun systemNextAlarm(): Instant?
}
@@ -0,0 +1,13 @@
package de.jeanlucmakiola.clockula.alarm
import kotlinx.coroutines.flow.StateFlow
/** Starts and stops the ringing service, and says which alarm is ringing. */
interface RingCoordinator {
/** The ringing alarm id, or null. Seeded null in a fresh process. */
val ringing: StateFlow<Long?>
fun startRinging(alarmId: Long)
fun stopRinging(alarmId: Long)
}
@@ -0,0 +1,41 @@
package de.jeanlucmakiola.clockula.alarm.di
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.clockula.alarm.AlarmCapabilities
import de.jeanlucmakiola.clockula.alarm.AlarmNotifier
import de.jeanlucmakiola.clockula.alarm.AlarmScheduler
import de.jeanlucmakiola.clockula.alarm.RingCoordinator
import de.jeanlucmakiola.clockula.alarm.android.AndroidAlarmCapabilities
import de.jeanlucmakiola.clockula.alarm.android.AndroidAlarmNotifier
import de.jeanlucmakiola.clockula.alarm.android.AndroidAlarmScheduler
import de.jeanlucmakiola.clockula.alarm.android.ServiceRingCoordinator
import javax.inject.Singleton
/**
* The four Android seams the engine sees. Everything platform-specific about an
* alarm is on the far side of one of these, which is what keeps `AlarmEngine`
* itself JVM-testable without a single Robolectric shadow.
*/
@Module
@InstallIn(SingletonComponent::class)
abstract class AlarmModule {
@Binds
@Singleton
abstract fun bindAlarmScheduler(impl: AndroidAlarmScheduler): AlarmScheduler
@Binds
@Singleton
abstract fun bindAlarmCapabilities(impl: AndroidAlarmCapabilities): AlarmCapabilities
@Binds
@Singleton
abstract fun bindRingCoordinator(impl: ServiceRingCoordinator): RingCoordinator
@Binds
@Singleton
abstract fun bindAlarmNotifier(impl: AndroidAlarmNotifier): AlarmNotifier
}
@@ -0,0 +1,916 @@
package de.jeanlucmakiola.clockula.alarm
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.data.alarms.AlarmMapper
import de.jeanlucmakiola.clockula.data.alarms.AlarmRepositoryImpl
import de.jeanlucmakiola.clockula.data.alarms.AlarmStateRepository
import de.jeanlucmakiola.clockula.data.alarms.AlarmStateRepositoryImpl
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.clockula.domain.Alarm
import de.jeanlucmakiola.clockula.domain.AlarmDraft
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import de.jeanlucmakiola.clockula.domain.alarm.AlarmRing
import de.jeanlucmakiola.clockula.domain.alarm.AlarmRingState
import de.jeanlucmakiola.clockula.domain.alarm.FireOutcome
import de.jeanlucmakiola.clockula.domain.alarm.FireSource
import de.jeanlucmakiola.clockula.domain.alarm.ScheduleMode
import de.jeanlucmakiola.clockula.domain.alarm.SnoozeOutcome
import de.jeanlucmakiola.clockula.domain.resolveSettings
import de.jeanlucmakiola.clockula.testing.BERLIN
import de.jeanlucmakiola.clockula.testing.FakeAlarmDao
import de.jeanlucmakiola.clockula.testing.FakeAlarmNotifier
import de.jeanlucmakiola.clockula.testing.FakeAlarmScheduler
import de.jeanlucmakiola.clockula.testing.FakeAlarmStateDao
import de.jeanlucmakiola.clockula.testing.FakeRingCoordinator
import de.jeanlucmakiola.clockula.testing.FakeWallClock
import de.jeanlucmakiola.clockula.testing.FakeZoneProvider
import de.jeanlucmakiola.clockula.testing.NEW_YORK
import de.jeanlucmakiola.clockula.testing.RecordingRebootRepair
import de.jeanlucmakiola.clockula.testing.Registration
import de.jeanlucmakiola.clockula.testing.RingEvent
import de.jeanlucmakiola.clockula.testing.alarmAt
import de.jeanlucmakiola.clockula.testing.instantAt
import de.jeanlucmakiola.clockula.testing.offsetInstant
import de.jeanlucmakiola.clockula.testing.ringState
import de.jeanlucmakiola.floret.prefs.PrefStore
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
/**
* The engine over the real repositories, fake DAOs and the four injected Android
* seams — the whole milestone end to end, with nothing left to race.
*/
class AlarmEngineTest {
private val alarmDao = FakeAlarmDao()
private val stateDao = FakeAlarmStateDao(alarms = alarmDao)
private val wallClock = FakeWallClock(instantAt(BERLIN, "2026-06-10T05:00"))
private val zones = FakeZoneProvider(BERLIN)
private val scheduler = FakeAlarmScheduler()
private val ring = FakeRingCoordinator()
private val notifier = FakeAlarmNotifier()
private val rebootRepair = RecordingRebootRepair()
private val alarms = AlarmRepositoryImpl(alarmDao, wallClock)
private val states = AlarmStateRepositoryImpl(stateDao)
private val sevenToday = offsetInstant("2026-06-10T07:00+02:00")
private val sevenTomorrow = offsetInstant("2026-06-11T07:00+02:00")
private fun TestScope.settings(tempDir: Path): SettingsPrefs = SettingsPrefs(
PrefStore(
PreferenceDataStoreFactory.create(
scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler) + Job()),
produceFile = { tempDir.resolve("clockula_prefs_test.preferences_pb").toFile() },
),
),
)
private fun engineOver(settings: SettingsPrefs) = AlarmEngine(
alarms = alarms,
states = states,
settings = settings,
scheduler = scheduler,
ring = ring,
notifier = notifier,
rebootRepair = rebootRepair,
wallClock = wallClock,
zones = zones,
)
private fun TestScope.engine(tempDir: Path) = engineOver(settings(tempDir))
private suspend fun givenAlarm(
id: Long = 1L,
hour: Int = 7,
minute: Int = 0,
repeatDays: RepeatDays = RepeatDays.NONE,
enabled: Boolean = true,
skipNextOccurrence: Boolean = false,
snoozeMinutes: Int? = null,
snoozeLimit: Int? = null,
): Alarm {
val alarm = alarmAt(
id = id,
hour = hour,
minute = minute,
repeatDays = repeatDays,
enabled = enabled,
skipNextOccurrence = skipNextOccurrence,
snoozeMinutes = snoozeMinutes,
snoozeLimit = snoozeLimit,
)
alarmDao.insert(AlarmMapper.toEntity(alarm))
return alarm
}
private suspend fun givenRingingAt(alarmId: Long, occurrence: Instant, since: Instant) {
states.save(ringState(alarmId = alarmId, ringingSince = since, handledOccurrence = occurrence))
}
// --- T86–T96: reschedule ---
@Test
fun `with no alarms the next-alarm slot is cancelled and nothing is written`(@TempDir dir: Path) = runTest {
val engine = engine(dir)
val scheduled = engine.reschedule()
assertThat(scheduled).isNull()
assertThat(scheduler.cancelNextCount).isEqualTo(1)
assertThat(stateDao.writes).isEqualTo(0)
}
@Test
fun `one enabled alarm is registered at its next occurrence`(@TempDir dir: Path) = runTest {
givenAlarm()
val engine = engine(dir)
val scheduled = engine.reschedule()
assertThat(scheduler.next).isEqualTo(Registration(sevenToday, 1L))
assertThat(scheduled?.alarmId).isEqualTo(1L)
assertThat(scheduled?.source).isEqualTo(FireSource.OCCURRENCE)
}
@Test
fun `only the earliest of several alarms occupies the single slot`(@TempDir dir: Path) = runTest {
givenAlarm(id = 1L, hour = 8)
givenAlarm(id = 2L, hour = 6)
givenAlarm(id = 3L, hour = 7)
val engine = engine(dir)
engine.reschedule()
assertThat(scheduler.next).isEqualTo(Registration(offsetInstant("2026-06-10T06:00+02:00"), 2L))
}
@Test
fun `two alarms at the same instant are broken by the lower id`(@TempDir dir: Path) = runTest {
givenAlarm(id = 2L, hour = 7)
givenAlarm(id = 1L, hour = 7)
val engine = engine(dir)
engine.reschedule()
assertThat(scheduler.next).isEqualTo(Registration(sevenToday, 1L))
}
@Test
fun `only disabled alarms means nothing is registered`(@TempDir dir: Path) = runTest {
givenAlarm(enabled = false)
val engine = engine(dir)
val scheduled = engine.reschedule()
assertThat(scheduled).isNull()
assertThat(scheduler.cancelNextCount).isEqualTo(1)
}
@Test
fun `a disabled earlier alarm does not take the slot from an enabled later one`(@TempDir dir: Path) =
runTest {
givenAlarm(id = 1L, hour = 6, enabled = false)
givenAlarm(id = 2L, hour = 8)
val engine = engine(dir)
engine.reschedule()
assertThat(scheduler.next).isEqualTo(Registration(offsetInstant("2026-06-10T08:00+02:00"), 2L))
}
@Test
fun `without exact alarms the alarm is still registered, and says so`(@TempDir dir: Path) = runTest {
scheduler.mode = ScheduleMode.INEXACT_FALLBACK
givenAlarm()
val engine = engine(dir)
val scheduled = engine.reschedule()
assertThat(scheduler.next).isEqualTo(Registration(sevenToday, 1L))
assertThat(scheduled?.mode).isEqualTo(ScheduleMode.INEXACT_FALLBACK)
}
@Test
fun `rescheduling a skipping alarm persists the watermark and registers the one after`(
@TempDir dir: Path,
) = runTest {
givenAlarm(repeatDays = RepeatDays.EVERY_DAY, skipNextOccurrence = true)
val engine = engine(dir)
engine.reschedule()
assertThat(states.state(1L).skippedOccurrence).isEqualTo(sevenToday)
assertThat(scheduler.next?.fireAt).isEqualTo(sevenTomorrow)
}
@Test
fun `a consumed skip clears both the flag and the watermark`(@TempDir dir: Path) = runTest {
givenAlarm(repeatDays = RepeatDays.EVERY_DAY, skipNextOccurrence = true)
states.save(ringState(skippedOccurrence = offsetInstant("2026-06-09T07:00+02:00")))
val engine = engine(dir)
engine.reschedule()
assertThat(alarms.find(1L)?.skipNextOccurrence).isFalse()
assertThat(states.state(1L).skippedOccurrence).isNull()
}
@Test
fun `skipping a one-shot alarm disables it and empties the slot`(@TempDir dir: Path) = runTest {
givenAlarm(skipNextOccurrence = true)
val engine = engine(dir)
engine.reschedule()
assertThat(alarms.find(1L)?.enabled).isFalse()
assertThat(scheduler.cancelNextCount).isEqualTo(1)
assertThat(scheduler.next).isNull()
}
@Test
fun `rescheduling twice with the clock unmoved writes nothing the second time`(@TempDir dir: Path) =
runTest {
givenAlarm(repeatDays = RepeatDays.EVERY_DAY, skipNextOccurrence = true)
val engine = engine(dir)
engine.reschedule()
val writesAfterArming = stateDao.writes
engine.reschedule()
assertThat(scheduler.next?.fireAt).isEqualTo(sevenTomorrow)
assertThat(stateDao.writes).isEqualTo(writesAfterArming)
}
// --- T97–T106: onFire ---
@Test
fun `a live occurrence starts the ring, records it and arms the backstop`(@TempDir dir: Path) = runTest {
givenAlarm(repeatDays = RepeatDays.EVERY_DAY)
wallClock.instant = sevenToday
val engine = engine(dir)
val outcome = engine.onFire(alarmId = 1L, scheduledFor = sevenToday)
assertThat(outcome).isEqualTo(FireOutcome.Ringing(1L))
assertThat(ring.ringing.value).isEqualTo(1L)
assertThat(states.state(1L).ringingSince).isEqualTo(sevenToday)
assertThat(states.state(1L).handledOccurrence).isEqualTo(sevenToday)
assertThat(scheduler.autoSilence)
.isEqualTo(Registration(sevenToday + AlarmRing.AUTO_SILENCE_AFTER, 1L))
}
@Test
fun `an alarm deleted between scheduling and firing is ignored, and the slot is re-resolved`(
@TempDir dir: Path,
) = runTest {
wallClock.instant = sevenToday
val engine = engine(dir)
val outcome = engine.onFire(alarmId = 404L, scheduledFor = sevenToday)
assertThat(outcome).isEqualTo(FireOutcome.Ignored)
assertThat(ring.events).isEmpty()
assertThat(stateDao.stored).isEmpty()
assertThat(scheduler.cancelNextCount).isEqualTo(1)
}
@Test
fun `an alarm disabled between scheduling and firing does not ring`(@TempDir dir: Path) = runTest {
givenAlarm(enabled = false)
wallClock.instant = sevenToday
val engine = engine(dir)
val outcome = engine.onFire(alarmId = 1L, scheduledFor = sevenToday)
assertThat(outcome).isEqualTo(FireOutcome.Ignored)
assertThat(ring.events).isEmpty()
}
@Test
fun `a stale fire from half an hour ago is ignored`(@TempDir dir: Path) = runTest {
givenAlarm(repeatDays = RepeatDays.EVERY_DAY)
wallClock.instant = sevenToday + 30.minutes
val engine = engine(dir)
val outcome = engine.onFire(alarmId = 1L, scheduledFor = sevenToday)
assertThat(outcome).isEqualTo(FireOutcome.Ignored)
assertThat(ring.events).isEmpty()
assertThat(scheduler.next?.fireAt).isEqualTo(sevenTomorrow)
}
@Test
fun `a fire a minute late still rings`(@TempDir dir: Path) = runTest {
givenAlarm(repeatDays = RepeatDays.EVERY_DAY)
wallClock.instant = sevenToday + 60.seconds
val engine = engine(dir)
val outcome = engine.onFire(alarmId = 1L, scheduledFor = sevenToday)
assertThat(outcome).isEqualTo(FireOutcome.Ringing(1L))
}
@Test
fun `a newly firing alarm takes over from the one already ringing`(@TempDir dir: Path) = runTest {
givenAlarm(id = 1L, hour = 7, repeatDays = RepeatDays.EVERY_DAY)
val eightToday = offsetInstant("2026-06-10T08:00+02:00")
givenAlarm(id = 2L, hour = 8, repeatDays = RepeatDays.EVERY_DAY)
givenRingingAt(alarmId = 1L, occurrence = sevenToday, since = sevenToday)
wallClock.instant = eightToday
val engine = engine(dir)
engine.onFire(alarmId = 2L, scheduledFor = eightToday)
assertThat(states.state(1L).ringingSince).isNull()
assertThat(states.state(1L).handledOccurrence).isEqualTo(sevenToday)
assertThat(ring.ringing.value).isEqualTo(2L)
assertThat(ring.events).containsAtLeast(RingEvent.Stopped(1L), RingEvent.Started(2L)).inOrder()
}
@Test
fun `a snooze fire clears the snooze without moving the handled occurrence`(@TempDir dir: Path) =
runTest {
givenAlarm(repeatDays = RepeatDays.EVERY_DAY)
val snoozeAt = sevenToday + 10.minutes
states.save(
ringState(snoozedUntil = snoozeAt, snoozeCount = 1, handledOccurrence = sevenToday),
)
wallClock.instant = snoozeAt
val engine = engine(dir)
engine.onFire(alarmId = 1L, scheduledFor = snoozeAt)
assertThat(states.state(1L).snoozedUntil).isNull()
assertThat(states.state(1L).handledOccurrence).isEqualTo(sevenToday)
assertThat(states.state(1L).snoozeCount).isEqualTo(1)
}
@Test
fun `the ringing alarm never takes the next-alarm slot from another one`(@TempDir dir: Path) = runTest {
givenAlarm(id = 1L, hour = 7, repeatDays = RepeatDays.EVERY_DAY)
givenAlarm(id = 2L, hour = 8, repeatDays = RepeatDays.EVERY_DAY)
wallClock.instant = sevenToday
val engine = engine(dir)
engine.onFire(alarmId = 1L, scheduledFor = sevenToday)
assertThat(scheduler.next?.alarmId).isEqualTo(2L)
}
@Test
fun `after firing, a repeating alarm's slot holds tomorrow rather than today`(@TempDir dir: Path) =
runTest {
givenAlarm(repeatDays = RepeatDays.EVERY_DAY)
wallClock.instant = sevenToday
val engine = engine(dir)
engine.onFire(alarmId = 1L, scheduledFor = sevenToday)
assertThat(scheduler.next?.fireAt).isEqualTo(sevenTomorrow)
}
@Test
fun `firing clears any snoozed notification`(@TempDir dir: Path) = runTest {
givenAlarm(repeatDays = RepeatDays.EVERY_DAY)
wallClock.instant = sevenToday
val engine = engine(dir)
engine.onFire(alarmId = 1L, scheduledFor = sevenToday)
assertThat(notifier.cleared).contains(1L)
}
// --- T107–T114: snooze ---
@Test
fun `snoozing stops the ring and registers the snooze as the next alarm`(@TempDir dir: Path) = runTest {
givenAlarm(repeatDays = RepeatDays.EVERY_DAY)
wallClock.instant = sevenToday
val engine = engine(dir)
engine.onFire(alarmId = 1L, scheduledFor = sevenToday)
val outcome = engine.snooze(1L)
assertThat(outcome).isEqualTo(SnoozeOutcome.Snoozed(1L, sevenToday + 10.minutes, 2))
assertThat(states.state(1L).ringingSince).isNull()
assertThat(states.state(1L).snoozeCount).isEqualTo(1)
assertThat(ring.events).contains(RingEvent.Stopped(1L))
assertThat(scheduler.cancelAutoSilenceCount).isEqualTo(1)
assertThat(notifier.shown).contains(1L to sevenToday + 10.minutes)
assertThat(scheduler.next?.fireAt).isEqualTo(sevenToday + 10.minutes)
}
@Test
fun `a per-alarm snooze interval beats the default`(@TempDir dir: Path) = runTest {
givenAlarm(repeatDays = RepeatDays.EVERY_DAY, snoozeMinutes = 7)
wallClock.instant = sevenToday
val engine = engine(dir)
engine.onFire(alarmId = 1L, scheduledFor = sevenToday)
val outcome = engine.snooze(1L)
assertThat((outcome as SnoozeOutcome.Snoozed).until).isEqualTo(sevenToday + 7.minutes)
}
@Test
fun `without an override the stored default interval is used`(@TempDir dir: Path) = runTest {
givenAlarm(repeatDays = RepeatDays.EVERY_DAY)
val settings = settings(dir)
settings.setSnoozeMinutes(20)
wallClock.instant = sevenToday
val engine = engineOver(settings)
engine.onFire(alarmId = 1L, scheduledFor = sevenToday)
val outcome = engine.snooze(1L)
assertThat((outcome as SnoozeOutcome.Snoozed).until).isEqualTo(sevenToday + 20.minutes)
}
@Test
fun `snoozing twice counts twice and leaves one snooze of the default limit`(@TempDir dir: Path) =
runTest {
givenAlarm(repeatDays = RepeatDays.EVERY_DAY)
wallClock.instant = sevenToday
val engine = engine(dir)
engine.onFire(alarmId = 1L, scheduledFor = sevenToday)
engine.snooze(1L)
wallClock.instant = sevenToday + 10.minutes
engine.onFire(alarmId = 1L, scheduledFor = sevenToday + 10.minutes)
val outcome = engine.snooze(1L)
assertThat(states.state(1L).snoozeCount).isEqualTo(2)
assertThat((outcome as SnoozeOutcome.Snoozed).remainingSnoozes).isEqualTo(1)
}
@Test
fun `a snooze past the limit dismisses rather than leaving the alarm ringing`(@TempDir dir: Path) =
runTest {
givenAlarm(repeatDays = RepeatDays.EVERY_DAY, snoozeLimit = 1)
wallClock.instant = sevenToday
val engine = engine(dir)
engine.onFire(alarmId = 1L, scheduledFor = sevenToday)
engine.snooze(1L)
wallClock.instant = sevenToday + 10.minutes
engine.onFire(alarmId = 1L, scheduledFor = sevenToday + 10.minutes)
val outcome = engine.snooze(1L)
assertThat(outcome).isEqualTo(SnoozeOutcome.LimitReached(1L))
assertThat(ring.ringing.value).isNull()
assertThat(states.state(1L).snoozedUntil).isNull()
assertThat(states.state(1L).ringingSince).isNull()
assertThat(scheduler.next?.fireAt).isEqualTo(sevenTomorrow)
}
@Test
fun `a snooze limit of zero refuses the very first snooze`(@TempDir dir: Path) = runTest {
givenAlarm(repeatDays = RepeatDays.EVERY_DAY, snoozeLimit = 0)
wallClock.instant = sevenToday
val engine = engine(dir)
engine.onFire(alarmId = 1L, scheduledFor = sevenToday)
val outcome = engine.snooze(1L)
assertThat(outcome).isEqualTo(SnoozeOutcome.LimitReached(1L))
}
@Test
fun `snoozing when nothing is ringing changes nothing`(@TempDir dir: Path) = runTest {
givenAlarm(repeatDays = RepeatDays.EVERY_DAY)
val engine = engine(dir)
val before = stateDao.stored
val outcome = engine.snooze(1L)
assertThat(outcome).isEqualTo(SnoozeOutcome.NotRinging)
assertThat(stateDao.stored).isEqualTo(before)
}
@Test
fun `snoozing an unknown alarm is a no-op rather than a crash`(@TempDir dir: Path) = runTest {
val engine = engine(dir)
val outcome = engine.snooze(404L)
assertThat(outcome).isEqualTo(SnoozeOutcome.NotRinging)
assertThat(stateDao.writes).isEqualTo(0)
}
// --- T115–T121: dismiss and auto-silence ---
@Test
fun `dismissing closes the cycle and keeps the dismissal watermark`(@TempDir dir: Path) = runTest {
givenAlarm(repeatDays = RepeatDays.EVERY_DAY)
wallClock.instant = sevenToday
val engine = engine(dir)
engine.onFire(alarmId = 1L, scheduledFor = sevenToday)
engine.dismiss(1L)
assertThat(ring.events).contains(RingEvent.Stopped(1L))
assertThat(scheduler.cancelAutoSilenceCount).isEqualTo(1)
assertThat(notifier.cleared).contains(1L)
val state = states.state(1L)
assertThat(state.ringingSince).isNull()
assertThat(state.snoozedUntil).isNull()
assertThat(state.snoozeCount).isEqualTo(0)
assertThat(state.handledOccurrence).isEqualTo(sevenToday)
}
@Test
fun `dismissing a repeating alarm leaves it enabled and armed for the next occurrence`(
@TempDir dir: Path,
) = runTest {
givenAlarm(repeatDays = RepeatDays.EVERY_DAY)
wallClock.instant = sevenToday
val engine = engine(dir)
engine.onFire(alarmId = 1L, scheduledFor = sevenToday)
engine.dismiss(1L)
assertThat(alarms.find(1L)?.enabled).isTrue()
assertThat(scheduler.next?.fireAt).isEqualTo(sevenTomorrow)
}
@Test
fun `dismissing a one-shot alarm disables it and empties the slot`(@TempDir dir: Path) = runTest {
givenAlarm()
wallClock.instant = sevenToday
val engine = engine(dir)
engine.onFire(alarmId = 1L, scheduledFor = sevenToday)
engine.dismiss(1L)
assertThat(alarms.find(1L)?.enabled).isFalse()
assertThat(scheduler.next).isNull()
}
@Test
fun `dismissing a snoozed alarm drops the snooze and re-arms the occurrence`(@TempDir dir: Path) =
runTest {
givenAlarm(repeatDays = RepeatDays.EVERY_DAY)
wallClock.instant = sevenToday
val engine = engine(dir)
engine.onFire(alarmId = 1L, scheduledFor = sevenToday)
engine.snooze(1L)
engine.dismiss(1L)
assertThat(states.state(1L).snoozedUntil).isNull()
assertThat(notifier.cleared).contains(1L)
assertThat(scheduler.next?.fireAt).isEqualTo(sevenTomorrow)
}
@Test
fun `auto-silence closes the cycle as missed rather than as a snooze`(@TempDir dir: Path) = runTest {
givenAlarm(repeatDays = RepeatDays.EVERY_DAY)
wallClock.instant = sevenToday
val engine = engine(dir)
engine.onFire(alarmId = 1L, scheduledFor = sevenToday)
wallClock.instant = sevenToday + 10.minutes
engine.onAutoSilence(1L)
assertThat(ring.ringing.value).isNull()
assertThat(states.state(1L).snoozeCount).isEqualTo(0)
assertThat(states.state(1L).snoozedUntil).isNull()
assertThat(scheduler.next?.fireAt).isEqualTo(sevenTomorrow)
}
@Test
fun `auto-silence for an alarm that is no longer ringing does nothing`(@TempDir dir: Path) = runTest {
givenAlarm(repeatDays = RepeatDays.EVERY_DAY)
val engine = engine(dir)
val writesBefore = stateDao.writes
engine.onAutoSilence(1L)
assertThat(ring.events).isEmpty()
assertThat(stateDao.writes).isEqualTo(writesBefore)
}
@Test
fun `auto-silencing a one-shot alarm closes its cycle, so it is disabled`(@TempDir dir: Path) = runTest {
givenAlarm()
wallClock.instant = sevenToday
val engine = engine(dir)
engine.onFire(alarmId = 1L, scheduledFor = sevenToday)
wallClock.instant = sevenToday + 10.minutes
engine.onAutoSilence(1L)
assertThat(alarms.find(1L)?.enabled).isFalse()
}
// --- T122–T131: boot, process death and system events ---
@Test
fun `boot repairs first and then registers the next alarm`(@TempDir dir: Path) = runTest {
givenAlarm()
val engine = engine(dir)
engine.onBootCompleted()
assertThat(rebootRepair.calls).isEqualTo(1)
assertThat(scheduler.next).isEqualTo(Registration(sevenToday, 1L))
}
@Test
fun `a ring interrupted by a reboot is resumed with its backstop re-armed`(@TempDir dir: Path) =
runTest {
givenAlarm(repeatDays = RepeatDays.EVERY_DAY)
wallClock.instant = sevenToday + 2.minutes
givenRingingAt(alarmId = 1L, occurrence = sevenToday, since = sevenToday)
val engine = engine(dir)
engine.onBootCompleted()
assertThat(ring.ringing.value).isEqualTo(1L)
assertThat(scheduler.autoSilence)
.isEqualTo(Registration(sevenToday + AlarmRing.AUTO_SILENCE_AFTER, 1L))
}
@Test
fun `a ring the device slept through is not resumed, it is missed`(@TempDir dir: Path) = runTest {
givenAlarm(repeatDays = RepeatDays.EVERY_DAY)
wallClock.instant = sevenToday + 40.minutes
givenRingingAt(alarmId = 1L, occurrence = sevenToday, since = sevenToday)
val engine = engine(dir)
engine.onBootCompleted()
assertThat(ring.events).isEmpty()
assertThat(states.state(1L).ringingSince).isNull()
assertThat(scheduler.next?.fireAt).isEqualTo(sevenTomorrow)
}
@Test
fun `a ring survives process death because it is rebuilt from storage`(@TempDir dir: Path) = runTest {
val alarm = givenAlarm(repeatDays = RepeatDays.EVERY_DAY, snoozeMinutes = 7)
val settings = settings(dir)
wallClock.instant = sevenToday
engineOver(settings).onFire(alarmId = 1L, scheduledFor = sevenToday)
val revived = engineOver(settings)
assertThat(revived.resumeRingingIfAny()).isEqualTo(1L)
val session = revived.ringSession()
assertThat(session?.alarm).isEqualTo(alarm)
assertThat(session?.settings).isEqualTo(alarm.resolveSettings(settings.currentDefaults()))
assertThat(session?.startedAt).isEqualTo(sevenToday)
}
@Test
fun `there is no ring session when nothing is ringing`(@TempDir dir: Path) = runTest {
givenAlarm()
val engine = engine(dir)
assertThat(engine.ringSession()).isNull()
}
@Test
fun `a ring session may only be snoozed while snoozes are left`(@TempDir dir: Path) = runTest {
givenAlarm(repeatDays = RepeatDays.EVERY_DAY, snoozeLimit = 1)
wallClock.instant = sevenToday
val engine = engine(dir)
engine.onFire(alarmId = 1L, scheduledFor = sevenToday)
assertThat(engine.ringSession()?.canSnooze).isTrue()
engine.snooze(1L)
wallClock.instant = sevenToday + 10.minutes
engine.onFire(alarmId = 1L, scheduledFor = sevenToday + 10.minutes)
assertThat(engine.ringSession()?.canSnooze).isFalse()
}
@Test
fun `a zone change re-resolves the same local time into a different instant`(@TempDir dir: Path) =
runTest {
givenAlarm()
val engine = engine(dir)
engine.reschedule()
zones.zone = NEW_YORK
engine.onSystemTimeOrZoneChanged()
assertThat(scheduler.next?.fireAt).isEqualTo(offsetInstant("2026-06-10T07:00-04:00"))
}
@Test
fun `a clock nudged back inside the grace window does not re-ring a dismissed alarm`(
@TempDir dir: Path,
) = runTest {
givenAlarm(repeatDays = RepeatDays.EVERY_DAY)
wallClock.instant = sevenToday + 5.seconds
val engine = engine(dir)
engine.onFire(alarmId = 1L, scheduledFor = sevenToday)
engine.dismiss(1L)
wallClock.instant = sevenToday + 30.seconds
engine.onSystemTimeOrZoneChanged()
assertThat(scheduler.next?.fireAt).isEqualTo(sevenTomorrow)
}
@Test
fun `a clock nudged forward past an occurrence keeps it inside the grace window`(@TempDir dir: Path) =
runTest {
givenAlarm(repeatDays = RepeatDays.EVERY_DAY)
val engine = engine(dir)
wallClock.instant = sevenToday + 90.seconds
engine.onSystemTimeOrZoneChanged()
assertThat(scheduler.next?.fireAt).isEqualTo(sevenToday)
}
@Test
fun `a clock jumped ten minutes past an occurrence misses it`(@TempDir dir: Path) = runTest {
givenAlarm(repeatDays = RepeatDays.EVERY_DAY)
val engine = engine(dir)
wallClock.instant = sevenToday + 10.minutes
engine.onSystemTimeOrZoneChanged()
assertThat(scheduler.next?.fireAt).isEqualTo(sevenTomorrow)
}
@Test
fun `an app update re-registers the next alarm`(@TempDir dir: Path) = runTest {
givenAlarm()
val engine = engine(dir)
engine.onPackageReplaced()
assertThat(scheduler.next).isEqualTo(Registration(sevenToday, 1L))
}
// --- upcoming() ---
@Test
fun `upcoming is sorted by next fire, nulls last, ties broken by id`(@TempDir dir: Path) = runTest {
givenAlarm(id = 1L, hour = 9)
givenAlarm(id = 2L, hour = 7)
givenAlarm(id = 3L, hour = 7)
givenAlarm(id = 4L, hour = 6, enabled = false)
val engine = engine(dir)
val upcoming = engine.upcoming().first()
assertThat(upcoming.map { it.alarm.id }).containsExactly(2L, 3L, 1L, 4L).inOrder()
}
@Test
fun `a disabled alarm is upcoming with no next fire at all`(@TempDir dir: Path) = runTest {
givenAlarm(enabled = false)
val engine = engine(dir)
val entry = engine.upcoming().first().single()
assertThat(entry.nextFire).isNull()
assertThat(entry.source).isEqualTo(FireSource.NONE)
}
@Test
fun `a snoozed alarm is upcoming at its snooze instant`(@TempDir dir: Path) = runTest {
givenAlarm(repeatDays = RepeatDays.EVERY_DAY)
val snoozeAt = wallClock.instant + 5.minutes
states.save(ringState(snoozedUntil = snoozeAt, snoozeCount = 1))
val engine = engine(dir)
val entry = engine.upcoming().first().single()
assertThat(entry.source).isEqualTo(FireSource.SNOOZE)
assertThat(entry.nextFire).isEqualTo(snoozeAt)
}
@Test
fun `collecting upcoming writes nothing`(@TempDir dir: Path) = runTest {
givenAlarm(repeatDays = RepeatDays.EVERY_DAY, skipNextOccurrence = true)
val engine = engine(dir)
val before = stateDao.stored
val writesBefore = stateDao.writes
engine.upcoming().first()
assertThat(stateDao.writes).isEqualTo(writesBefore)
assertThat(stateDao.stored).isEqualTo(before)
}
@Test
fun `adding an alarm re-emits upcoming with it`(@TempDir dir: Path) = runTest {
givenAlarm(id = 1L, hour = 7)
val engine = engine(dir)
engine.upcoming().test {
assertThat(awaitItem().map { it.alarm.id }).containsExactly(1L)
alarms.create(AlarmDraft(time = TimeOfDay(9, 0)))
assertThat(awaitItem().map { it.alarm.id }).containsExactly(1L, 2L).inOrder()
cancelAndIgnoreRemainingEvents()
}
}
// --- regression: the single ring slot and the single auto-silence slot ---
@Test
fun `dismissing a snoozed alarm leaves a different, ringing alarm ringing`(@TempDir dir: Path) =
runTest {
val eightToday = offsetInstant("2026-06-10T08:00+02:00")
givenAlarm(id = 1L, hour = 7, repeatDays = RepeatDays.EVERY_DAY, snoozeMinutes = 120)
givenAlarm(id = 2L, hour = 8, repeatDays = RepeatDays.EVERY_DAY)
wallClock.instant = sevenToday
val engine = engine(dir)
engine.onFire(alarmId = 1L, scheduledFor = sevenToday)
engine.snooze(1L)
wallClock.instant = eightToday
engine.onFire(alarmId = 2L, scheduledFor = eightToday)
// From alarm 1's *snoozed* notification, while alarm 2 is ringing.
engine.dismiss(1L)
assertThat(ring.ringing.value).isEqualTo(2L)
assertThat(states.state(2L).ringingSince).isEqualTo(eightToday)
assertThat(scheduler.autoSilence)
.isEqualTo(Registration(eightToday + AlarmRing.AUTO_SILENCE_AFTER, 2L))
assertThat(states.state(1L).snoozedUntil).isNull()
assertThat(notifier.cleared).contains(1L)
}
@Test
fun `a reschedule that races a fire waits for it instead of overwriting it`(@TempDir dir: Path) =
runTest {
givenAlarm(repeatDays = RepeatDays.EVERY_DAY)
wallClock.instant = sevenToday
val gate = CompletableDeferred<Unit>()
val engine = AlarmEngine(
alarms = alarms,
states = GatedStateRepository(states, gate),
settings = settings(dir),
scheduler = scheduler,
ring = ring,
notifier = notifier,
rebootRepair = rebootRepair,
wallClock = wallClock,
zones = zones,
)
val firing = launch { engine.onFire(alarmId = 1L, scheduledFor = sevenToday) }
runCurrent()
// A cold start caused by this very broadcast, arriving mid-cycle.
val racing = launch { engine.reschedule() }
runCurrent()
// It made no progress at all: the engine is serialised, so it cannot
// read the pre-ring state and write it back over the ring.
assertThat(scheduler.next).isNull()
gate.complete(Unit)
firing.join()
racing.join()
assertThat(states.state(1L).ringingSince).isEqualTo(sevenToday)
assertThat(states.state(1L).handledOccurrence).isEqualTo(sevenToday)
assertThat(ring.ringing.value).isEqualTo(1L)
}
}
/**
* An [AlarmStateRepository] that parks its *first* [ringing] call until [gate] is
* completed — which is a read-modify-write caught exactly half-way through, so a
* second caller either waits for the engine's lock or corrupts it.
*/
private class GatedStateRepository(
private val delegate: AlarmStateRepository,
private val gate: CompletableDeferred<Unit>,
) : AlarmStateRepository by delegate {
private var parked = false
override suspend fun ringing(): AlarmRingState? {
if (!parked) {
parked = true
gate.await()
}
return delegate.ringing()
}
}
@@ -0,0 +1,47 @@
package de.jeanlucmakiola.clockula.alarm
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
/**
* Two `PendingIntent`s sharing a request code silently overwrite each other,
* which is how an internal backstop eats a user's next alarm. These constants
* are cheap to get wrong and expensive to notice.
*/
class AlarmIntentsTest {
private val actions: List<String> get() = listOf(
AlarmIntents.ACTION_FIRE,
AlarmIntents.ACTION_AUTO_SILENCE,
AlarmIntents.ACTION_SNOOZE,
AlarmIntents.ACTION_DISMISS,
)
@Test
fun `no two pending-intent request codes collide`() {
val codes = AlarmIntents.ALL_REQUEST_CODES
assertThat(codes).containsNoDuplicates()
assertThat(codes).containsAtLeast(
AlarmIntents.REQUEST_FIRE,
AlarmIntents.REQUEST_AUTO_SILENCE,
AlarmIntents.REQUEST_SHOW,
AlarmIntents.REQUEST_SNOOZE,
AlarmIntents.REQUEST_DISMISS,
)
}
@Test
fun `every action is namespaced to this application`() {
assertThat(actions.filterNot { it.startsWith("de.jeanlucmakiola.clockula.") }).isEmpty()
assertThat(actions).containsNoDuplicates()
}
@Test
fun `every extra key is distinct and non-blank`() {
val extras = listOf(AlarmIntents.EXTRA_ALARM_ID, AlarmIntents.EXTRA_FIRE_AT)
assertThat(extras.filter { it.isBlank() }).isEmpty()
assertThat(extras).containsNoDuplicates()
}
}
@@ -0,0 +1,223 @@
package de.jeanlucmakiola.clockula.testing
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.emptyPreferences
import de.jeanlucmakiola.clockula.alarm.AlarmCapabilities
import de.jeanlucmakiola.clockula.alarm.AlarmNotifier
import de.jeanlucmakiola.clockula.alarm.AlarmScheduler
import de.jeanlucmakiola.clockula.alarm.RingCoordinator
import de.jeanlucmakiola.clockula.data.prefs.BootStateStore
import de.jeanlucmakiola.clockula.data.stopwatch.StopwatchRepository
import de.jeanlucmakiola.clockula.data.timers.TimerRepository
import de.jeanlucmakiola.clockula.domain.Alarm
import de.jeanlucmakiola.clockula.domain.Lap
import de.jeanlucmakiola.clockula.domain.StopwatchRun
import de.jeanlucmakiola.clockula.domain.Timer
import de.jeanlucmakiola.clockula.domain.TimerDraft
import de.jeanlucmakiola.clockula.domain.alarm.CapabilitySnapshot
import de.jeanlucmakiola.clockula.domain.alarm.ScheduleMode
import de.jeanlucmakiola.clockula.domain.time.BootId
import de.jeanlucmakiola.clockula.domain.time.BootIdProvider
import de.jeanlucmakiola.clockula.domain.time.ZoneProvider
import de.jeanlucmakiola.clockula.system.RebootRepair
import de.jeanlucmakiola.floret.prefs.PrefStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.time.ZoneId
import kotlin.time.Duration
import kotlin.time.Instant
/** One registration in one of the two AlarmManager slots. */
data class Registration(val fireAt: Instant, val alarmId: Long)
/**
* The AlarmManager seam. Each slot is held as the value it currently holds, so
* a test asserts on what the system would be showing rather than on a call log
* — except for the cancels, which have no other observable shape.
*/
class FakeAlarmScheduler(
var mode: ScheduleMode = ScheduleMode.EXACT_ALARM_CLOCK,
) : AlarmScheduler {
var next: Registration? = null
private set
var autoSilence: Registration? = null
private set
var cancelNextCount: Int = 0
private set
var cancelAutoSilenceCount: Int = 0
private set
override fun scheduleNext(fireAt: Instant, alarmId: Long): ScheduleMode {
next = Registration(fireAt, alarmId)
return mode
}
override fun cancelNext() {
cancelNextCount++
next = null
}
override fun scheduleAutoSilence(at: Instant, alarmId: Long) {
autoSilence = Registration(at, alarmId)
}
override fun cancelAutoSilence() {
cancelAutoSilenceCount++
autoSilence = null
}
override fun systemNextAlarm(): Instant? = next?.fireAt
}
/** What the ring coordinator was told to do, in order — the order is the contract (D7). */
sealed interface RingEvent {
data class Started(val alarmId: Long) : RingEvent
data class Stopped(val alarmId: Long) : RingEvent
}
class FakeRingCoordinator : RingCoordinator {
private val state = MutableStateFlow<Long?>(null)
val events: MutableList<RingEvent> = mutableListOf()
override val ringing: StateFlow<Long?> = state.asStateFlow()
override fun startRinging(alarmId: Long) {
events += RingEvent.Started(alarmId)
state.value = alarmId
}
/**
* Deliberately unconditional, exactly like `ServiceRingCoordinator`: there
* is one ring service and stopping it stops whatever it is playing, whoever
* asked. A fake that only cleared its own alarm would hide a caller that
* silences someone else's ring.
*/
override fun stopRinging(alarmId: Long) {
events += RingEvent.Stopped(alarmId)
state.value = null
}
}
class FakeAlarmNotifier : AlarmNotifier {
val shown: MutableList<Pair<Long, Instant>> = mutableListOf()
val cleared: MutableList<Long> = mutableListOf()
override fun showSnoozed(alarm: Alarm, until: Instant) {
shown += alarm.id to until
}
override fun clearSnoozed(alarmId: Long) {
cleared += alarmId
}
}
class FakeAlarmCapabilities(
var snapshot: CapabilitySnapshot = CapabilitySnapshot(
canScheduleExactAlarms = true,
canUseFullScreenIntent = true,
notificationsEnabled = true,
),
) : AlarmCapabilities {
override fun snapshot(): CapabilitySnapshot = snapshot
}
/** The device's zone, which a test can change under a scheduled alarm. */
class FakeZoneProvider(var zone: ZoneId = BERLIN) : ZoneProvider {
override fun current(): ZoneId = zone
}
class FakeBootIdProvider(
var bootId: BootId = BootId(bootCount = 1, approximateBootInstant = T0),
) : BootIdProvider {
override fun current(): BootId = bootId
}
/** A [TimerRepository] that records only the one call the reboot gate makes. */
class RecordingTimerRepository : TimerRepository {
var repairCalls: Int = 0
private set
override suspend fun repairAfterReboot() {
repairCalls++
}
override fun timers(): Flow<List<Timer>> = TODO("not used by the reboot gate")
override fun timer(id: Long): Flow<Timer?> = TODO("not used by the reboot gate")
override suspend fun find(id: Long): Timer? = TODO("not used by the reboot gate")
override suspend fun create(draft: TimerDraft): Long = TODO("not used by the reboot gate")
override suspend fun rename(id: Long, label: String): Unit = TODO("not used by the reboot gate")
override suspend fun delete(id: Long): Unit = TODO("not used by the reboot gate")
override suspend fun start(id: Long): Unit = TODO("not used by the reboot gate")
override suspend fun pause(id: Long): Unit = TODO("not used by the reboot gate")
override suspend fun reset(id: Long): Unit = TODO("not used by the reboot gate")
override suspend fun addTime(id: Long, extra: Duration): Unit = TODO("not used by the reboot gate")
override suspend fun markExpired(id: Long): Unit = TODO("not used by the reboot gate")
override suspend fun reorder(idsInOrder: List<Long>): Unit = TODO("not used by the reboot gate")
}
/** A [StopwatchRepository] that records only the one call the reboot gate makes. */
class RecordingStopwatchRepository : StopwatchRepository {
var pauseCalls: Int = 0
private set
override suspend fun pauseAfterReboot() {
pauseCalls++
}
override fun run(): Flow<StopwatchRun> = TODO("not used by the reboot gate")
override fun laps(): Flow<List<Lap>> = TODO("not used by the reboot gate")
override suspend fun currentRun(): StopwatchRun = TODO("not used by the reboot gate")
override suspend fun start(): Unit = TODO("not used by the reboot gate")
override suspend fun pause(): Unit = TODO("not used by the reboot gate")
override suspend fun reset(): Unit = TODO("not used by the reboot gate")
override suspend fun lap(): Lap? = TODO("not used by the reboot gate")
}
/** A DataStore with no file behind it, for collaborators a test never exercises. */
class InMemoryPreferencesDataStore : DataStore<Preferences> {
private val state = MutableStateFlow(emptyPreferences())
override val data: Flow<Preferences> = state
override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences {
val updated = transform(state.value)
state.value = updated
return updated
}
}
/**
* The reboot gate as the engine sees it: it answers [rebooted] and counts the
* calls, so "the engine repaired before it rescheduled" is assertable without
* a DataStore file.
*/
class RecordingRebootRepair(
var rebooted: Boolean = false,
) : RebootRepair(
bootIds = FakeBootIdProvider(),
bootState = BootStateStore(PrefStore(InMemoryPreferencesDataStore())),
timers = RecordingTimerRepository(),
stopwatch = RecordingStopwatchRepository(),
) {
var calls: Int = 0
private set
override suspend fun repairIfRebooted(): Boolean {
calls++
return rebooted
}
}