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:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user