diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmCapabilities.kt b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmCapabilities.kt new file mode 100644 index 0000000..ffa10e4 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmCapabilities.kt @@ -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 +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmEngine.kt b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmEngine.kt new file mode 100644 index 0000000..101100a --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmEngine.kt @@ -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> = + 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(nullsLast()) { it.nextFire } + .thenBy { it.alarm.id }, + ) + } + + private suspend fun rescheduleLocked(): ScheduledFire? { + val now = wallClock.now() + val zone = zones.current() + + var earliest: Pair? = 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() + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmIntents.kt b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmIntents.kt new file mode 100644 index 0000000..423b0f4 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmIntents.kt @@ -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 = listOf( + REQUEST_FIRE, + REQUEST_AUTO_SILENCE, + REQUEST_SHOW, + REQUEST_SNOOZE, + REQUEST_DISMISS, + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmNotifier.kt b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmNotifier.kt new file mode 100644 index 0000000..2aeda4b --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmNotifier.kt @@ -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) +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmScheduler.kt b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmScheduler.kt new file mode 100644 index 0000000..dc3c81a --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmScheduler.kt @@ -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? +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/RingCoordinator.kt b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/RingCoordinator.kt new file mode 100644 index 0000000..857bf13 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/RingCoordinator.kt @@ -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 + + fun startRinging(alarmId: Long) + + fun stopRinging(alarmId: Long) +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/di/AlarmModule.kt b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/di/AlarmModule.kt new file mode 100644 index 0000000..130a5e4 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/di/AlarmModule.kt @@ -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 +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/alarm/AlarmEngineTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/alarm/AlarmEngineTest.kt new file mode 100644 index 0000000..1e17b4b --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/alarm/AlarmEngineTest.kt @@ -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() + 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, +) : AlarmStateRepository by delegate { + + private var parked = false + + override suspend fun ringing(): AlarmRingState? { + if (!parked) { + parked = true + gate.await() + } + return delegate.ringing() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/alarm/AlarmIntentsTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/alarm/AlarmIntentsTest.kt new file mode 100644 index 0000000..95e67ff --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/alarm/AlarmIntentsTest.kt @@ -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 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() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeAlarmSeams.kt b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeAlarmSeams.kt new file mode 100644 index 0000000..76f94ce --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeAlarmSeams.kt @@ -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(null) + + val events: MutableList = mutableListOf() + + override val ringing: StateFlow = 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> = mutableListOf() + + val cleared: MutableList = 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> = TODO("not used by the reboot gate") + override fun timer(id: Long): Flow = 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): 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 = TODO("not used by the reboot gate") + override fun laps(): Flow> = 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 { + + private val state = MutableStateFlow(emptyPreferences()) + + override val data: Flow = 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 + } +}