feat(timer): the engine, its expiry slot, the service and the receivers

One AlarmManager slot for every timer, registered on ELAPSED_REALTIME_WAKEUP
so the clock the domain anchors on is the clock the platform wakes on. Its
fire carries no id and is an idempotent sweep, backed up by a second trigger
inside the service, because neither has to be reliable on its own.

The foreground service posts per state change rather than per second — the
platform chronometer draws the countdown — and its actions are broadcasts, so
pause and reset still work with the process dead. Every verb is guarded
against a stale id.

Expiry reuses the alarm's audio path: the same player, vibrator, source policy
and fallback-to-vibration chain, with a zero ramp and its own channel. No
full-screen intent, no challenge, no snooze — a timer is not an alarm. Two
timers expiring together share one ring; a timer expiring after a previous
one's auto-silence window lapsed gets a sound of its own, which is the whole
point of having a timer.
This commit is contained in:
2026-09-12 16:48:36 +02:00
parent 19efb67740
commit 7f9219e929
18 changed files with 1971 additions and 4 deletions
+22
View File
@@ -104,6 +104,28 @@
android:name=".alarm.receiver.AlarmActionReceiver"
android:exported="false" />
<!-- The timers: one foreground service covering both phases, the
countdown and the ring. systemExempted for the same reason the
alarm's ringing service is, and M6 adds **no** permission for it —
the foreground-service, wake-lock, vibrate and notification set
above already covers it (slice plan D6, D18). -->
<service
android:name=".timer.service.TimerService"
android:exported="false"
android:foregroundServiceType="systemExempted" />
<!-- The single expiry slot arriving. It carries no timer id: the fire is
an idempotent sweep (D5). Unexported, like the alarm's. -->
<receiver
android:name=".timer.receiver.TimerExpiryReceiver"
android:exported="false" />
<!-- The timer notification's buttons. Broadcasts rather than service
starts, so they work with the process dead (D16). -->
<receiver
android:name=".timer.receiver.TimerActionReceiver"
android:exported="false" />
<!-- Protected system broadcasts are delivered to unexported receivers —
this is how androidx.work declares its own RescheduleReceiver.
LOCKED_BOOT_COMPLETED is deliberately absent: the database and the
@@ -4,6 +4,8 @@ import android.app.Application
import dagger.hilt.android.HiltAndroidApp
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
import de.jeanlucmakiola.clockula.alarm.ring.RingNotifications
import de.jeanlucmakiola.clockula.timer.TimerEngine
import de.jeanlucmakiola.clockula.timer.service.TimerNotifications
import de.jeanlucmakiola.floret.crash.CrashConfig
import de.jeanlucmakiola.floret.crash.CrashReporter
import de.jeanlucmakiola.floret.di.ApplicationScope
@@ -27,6 +29,9 @@ class ClockulaApp : Application() {
@Inject
lateinit var engine: AlarmEngine
@Inject
lateinit var timerEngine: TimerEngine
@Inject
@ApplicationScope
lateinit var scope: CoroutineScope
@@ -46,11 +51,20 @@ class ClockulaApp : Application() {
)
RingNotifications.createChannels(this)
TimerNotifications.createChannel(this)
// The same three steps as a BOOT_COMPLETED, and taken through the engine
// rather than one by one so the whole pass runs under the engine's lock:
// a cold start caused by the fire broadcast must not interleave its
// read-modify-write over `alarm_states` with the fire's own.
scope.launch { engine.onBootCompleted() }
scope.launch {
engine.onBootCompleted()
// After the alarm engine's pass, and idempotent within a boot: the
// boot-id gate makes the second repair a no-op, so neither engine
// depends on the other's ordering (M6 D21). Process death costs the
// timers nothing — the anchors and the AlarmManager slot survive it —
// but the service may not have, so this brings it back.
timerEngine.onBootCompleted()
}
}
}
@@ -4,6 +4,7 @@ import android.content.Context
import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
import de.jeanlucmakiola.clockula.timer.TimerEngine
import de.jeanlucmakiola.floret.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@@ -14,6 +15,11 @@ import javax.inject.Inject
* app update clears it too, and a clock or zone change moves what "07:00" means.
* All four are answered the same way — re-resolve from now.
*
* Both engines are told, in that order. For the timers a clock change
* reschedules nothing (their slot is elapsed-realtime anchored); it re-anchors
* the wall-clock fallback, which is what the notification's chronometer counts
* to and what a reboot would fall back on.
*
* `LOCKED_BOOT_COMPLETED` is deliberately not handled: the database and
* DataStore live in credential-encrypted storage, unreadable before first unlock.
*/
@@ -23,6 +29,9 @@ class SystemEventReceiver : HiltBroadcastReceiver() {
@Inject
lateinit var engine: AlarmEngine
@Inject
lateinit var timerEngine: TimerEngine
@Inject
@ApplicationScope
lateinit var scope: CoroutineScope
@@ -35,13 +44,30 @@ class SystemEventReceiver : HiltBroadcastReceiver() {
scope.launch {
try {
when (action) {
Intent.ACTION_BOOT_COMPLETED -> engine.onBootCompleted()
Intent.ACTION_BOOT_COMPLETED -> {
engine.onBootCompleted()
timerEngine.onBootCompleted()
}
Intent.ACTION_MY_PACKAGE_REPLACED -> engine.onPackageReplaced()
Intent.ACTION_MY_PACKAGE_REPLACED -> {
engine.onPackageReplaced()
// An app update clears AlarmManager for the timers too.
timerEngine.resync()
}
Intent.ACTION_TIME_CHANGED,
Intent.ACTION_TIMEZONE_CHANGED,
-> engine.onSystemTimeOrZoneChanged()
-> {
engine.onSystemTimeOrZoneChanged()
// **Not** to reschedule anything: the expiry slot is on
// the elapsed-realtime base, so a clock change cannot
// move it. What does move is `endsAtWallClock` — the
// notification's chronometer base and the reboot
// fallback, and the one wall-clock value in the timer
// path — so it is re-anchored from the monotonic one
// before the resync (M6 D4, D19).
timerEngine.onSystemTimeOrZoneChanged()
}
}
} finally {
pendingResult.finish()
@@ -0,0 +1,10 @@
package de.jeanlucmakiola.clockula.timer
import kotlinx.coroutines.flow.Flow
/** Read-only: does an alarm currently own the audio (D9)? */
interface AlarmRingStatus {
fun isRinging(): Flow<Boolean>
suspend fun currentlyRinging(): Boolean
}
@@ -0,0 +1,30 @@
package de.jeanlucmakiola.clockula.timer
import de.jeanlucmakiola.clockula.data.alarms.AlarmStateRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
/**
* The one question the two engines share, answered over `alarm_states`. The
* timer engine therefore never reaches into the alarm's ring state itself and
* never calls `AlarmEngine` (D9).
*/
@Singleton
class AlarmStateRingStatus @Inject constructor(
private val states: AlarmStateRepository,
) : AlarmRingStatus {
override fun isRinging(): Flow<Boolean> = ringing
override suspend fun currentlyRinging(): Boolean = ringing.first()
// Built once and distinct-until-changed: the service collects this in a
// `combine`, and a flow rebuilt per access would re-subscribe `alarm_states`
// on every pass.
private val ringing: Flow<Boolean> = states.states()
.map { rows -> rows.any { it.ringingSince != null } }
.distinctUntilChanged()
}
@@ -0,0 +1,254 @@
package de.jeanlucmakiola.clockula.timer
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.clockula.data.timers.TimerRepository
import de.jeanlucmakiola.clockula.data.timers.TimerRingStateStore
import de.jeanlucmakiola.clockula.domain.TimerState
import de.jeanlucmakiola.clockula.domain.time.ElapsedRealtimeClock
import de.jeanlucmakiola.clockula.domain.time.WallClock
import de.jeanlucmakiola.clockula.domain.timer.TimerExpiry
import de.jeanlucmakiola.clockula.domain.timer.TimerNotificationPolicy
import de.jeanlucmakiola.clockula.domain.timer.TimerNotificationState
import de.jeanlucmakiola.clockula.domain.timer.TimerReadings
import de.jeanlucmakiola.clockula.domain.timer.TimerRing
import de.jeanlucmakiola.clockula.domain.timer.TimerRingDecision
import de.jeanlucmakiola.clockula.domain.timer.TimerRingPolicy
import de.jeanlucmakiola.clockula.system.RebootRepair
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlin.time.Duration
import kotlin.time.Instant
import javax.inject.Inject
import javax.inject.Singleton
/** What the service must currently be doing. Everything in it came from a pure policy (D15). */
data class TimerServiceState(
/** Null ⇒ nothing is active ⇒ the service stops itself. */
val notification: TimerNotificationState?,
val ring: TimerRingDecision,
/** Until the earliest running timer expires. Null when none is running. */
val expiresIn: Duration?,
/** Until the ring session auto-silences. Null unless it is sounding. */
val silenceIn: Duration?,
)
/**
* The timers' analogue of `AlarmEngine`: plain Kotlin, no `android.*` import
* ever, reaching the platform through three seams it does not implement. It
* owns every write the pure policies ask for, and every public entry point is
* serialised on one non-reentrant `Mutex` (D1, D2, D15).
*
* It is not driven by a Flow, for the same reason the alarm engine is not:
* resolution writes state, so an engine that resynced on every repository
* emission would re-trigger itself on its own writes. [resync] is called
* explicitly — from the app class, the two receivers, the service and the two
* ViewModels, which is to say from threads that know nothing about each other.
*
* The two engines never call each other. The only thing they share is the
* question "is an alarm ringing right now", behind [AlarmRingStatus] (D9).
*/
@Singleton
class TimerEngine @Inject constructor(
private val timers: TimerRepository,
private val settings: SettingsPrefs,
private val ringState: TimerRingStateStore,
private val scheduler: TimerScheduler,
private val service: TimerServiceHandle,
private val alarmRing: AlarmRingStatus,
private val rebootRepair: RebootRepair,
private val elapsed: ElapsedRealtimeClock,
private val wall: WallClock,
) {
/**
* A `Mutex` is not reentrant, so exactly one layer takes it: the public
* entry points below. Every private `…Locked` body assumes it is held.
*/
private val lock = Mutex()
/** Sweep what is due, re-register the earliest deadline, match the service to "anything active". */
suspend fun resync(): Duration? = lock.withLock { resyncLocked() }
/**
* The slot arriving. The sweep is idempotent and carries no id (D5): a fire
* delivered late expires everything that came due while it was delayed, and
* one delivered early — or for a timer the user has since stopped — finds
* nothing due and writes nothing. The anchors *are* the watermark.
*/
suspend fun onExpiryDue(): List<Long> = lock.withLock {
val due = sweepLocked()
registerLocked()
due
}
/** The reboot repair (idempotent within a boot), then a resync. */
suspend fun onBootCompleted(): Duration? = lock.withLock {
// The boot-id gate makes the second call of a boot a no-op, so neither
// engine depends on the other's ordering — one line for one fewer
// invisible coupling (D21). The repair runs *before* the timers are
// read, so the slot is registered from the repaired anchors and a timer
// the repair expired rings.
rebootRepair.repairIfRebooted()
resyncLocked()
}
/**
* A clock or zone change. Nothing is rescheduled — the expiry slot is on the
* elapsed-realtime base, so a `TIME_SET` cannot move it (D4) — but every
* running row's wall-clock fallback has just been invalidated, and that
* value is both the notification's chronometer base (D19) and the reboot
* fallback. Re-anchoring it is the only write; the monotonic anchors are
* the ones that must not move.
*/
suspend fun onSystemTimeOrZoneChanged(): Duration? = lock.withLock {
timers.reanchorWallClocks()
resyncLocked()
}
/** Everything the service must do, with the session anchor persisted if it changed (D15). */
suspend fun serviceState(alreadyAlerted: Boolean): TimerServiceState =
lock.withLock { serviceStateLocked(alreadyAlerted) }
/** Starts IDLE/EXPIRED from the configured duration, resumes PAUSED. No-op when RUNNING. */
suspend fun start(timerId: Long): Unit = lock.withLock {
val timer = timers.find(timerId) ?: return@withLock
if (timer.state == TimerState.RUNNING) return@withLock
timers.start(timerId)
resyncLocked()
}
/** No-op unless RUNNING. */
suspend fun pause(timerId: Long): Unit = lock.withLock {
// Guarded by the state it makes sense for, because the id can be stale:
// the notification was built before the user changed something, and a
// mismatch must be a silent no-op rather than a surprise write (D16).
val timer = timers.find(timerId) ?: return@withLock
if (timer.state != TimerState.RUNNING) return@withLock
timers.pause(timerId)
resyncLocked()
}
/** Back to IDLE at the configured duration; closes the ring session. No-op when already IDLE. */
suspend fun reset(timerId: Long): Unit = lock.withLock {
val timer = timers.find(timerId) ?: return@withLock
if (timer.state == TimerState.IDLE) return@withLock
timers.reset(timerId)
resyncLocked()
}
/** D12. No-op when IDLE. */
suspend fun addTime(timerId: Long, extra: Duration = TimerRing.ADD_TIME): Unit = lock.withLock {
val timer = timers.find(timerId) ?: return@withLock
if (timer.state == TimerState.IDLE) return@withLock
timers.addTime(timerId, extra)
resyncLocked()
}
/**
* Deletes, then resyncs — so a *ringing* timer cannot be deleted into a
* stuck ring (D2). It is on the engine and not on a ViewModel-plus-resync
* path deliberately: the wrong call should be impossible to write.
*/
suspend fun delete(timerId: Long): Unit = lock.withLock {
timers.delete(timerId)
resyncLocked()
}
/**
* The one pass every verb ends with: mark what is due, hand the single slot
* the next earliest deadline, and match the service to "anything active".
* Returns the deadline the slot now holds.
*/
private suspend fun resyncLocked(): Duration? {
sweepLocked()
return registerLocked()
}
/** The half of [resyncLocked] that writes no row: the slot, the service, the session. */
private suspend fun registerLocked(): Duration? {
val rows = timers.timers().first()
val now = elapsed.elapsedRealtime()
val wallNow = wall.now()
val deadline = TimerExpiry.nextDeadline(rows, now, wallNow)
if (deadline == null) scheduler.cancelExpiry() else scheduler.scheduleExpiry(deadline)
// The live pill's predicate, not "running": dropping the notification on
// pause would leave a user who paused from the shade with no way to
// resume without opening the app (D6). One "active" predicate in the
// whole app.
val active = TimerReadings.active(rows, now, wallNow)
// The session closes when the expired set returns to empty — not when
// the list does: a "+1 min" that resumes the timer that was ringing has
// acknowledged it, and a second still-expired timer keeps the session's
// one window (D10, D12).
if (TimerReadings.expired(active).isEmpty()) closeSessionLocked()
service.sync(active.isNotEmpty())
return deadline
}
/** Every timer whose snapshot has reached zero, marked EXPIRED in one pass. */
private suspend fun sweepLocked(): List<Long> {
val due = TimerExpiry.dueIds(
timers.timers().first(),
elapsed.elapsedRealtime(),
wall.now(),
)
if (due.isEmpty()) return due
for (id in due) timers.markExpired(id)
dropLapsedSessionLocked()
return due
}
/**
* A timer reaching zero is an **event**, and `due` is only ever non-empty on
* the transition — so this runs exactly once per expiry.
*
* A session's window is inherited by a timer expiring *inside* it (D10), but
* a window that has already run out belongs to timers the user chose to
* leave EXPIRED (D11): a timer expiring after it must not be born
* auto-silenced. Dropping the lapsed anchor lets the next pass open a fresh
* session, which is also what re-arms the notification's one heads-up.
*/
private suspend fun dropLapsedSessionLocked() {
val anchor = ringState.current() ?: return
if (wall.now() - anchor >= TimerRing.AUTO_SILENCE_AFTER) ringState.set(null)
}
private suspend fun serviceStateLocked(alreadyAlerted: Boolean): TimerServiceState {
val rows = timers.timers().first()
val now = elapsed.elapsedRealtime()
val wallNow = wall.now()
val active = TimerReadings.active(rows, now, wallNow)
val outcome = TimerRingPolicy.decide(
expired = TimerReadings.expired(active),
defaults = settings.currentDefaults(),
alarmIsRinging = alarmRing.currentlyRinging(),
soundingSince = ringState.current(),
now = wallNow,
)
// The engine owns the write; the service is an actuator. Written only
// when it changed, so three reads of an unchanged session cost one
// transaction (D15).
persistSessionLocked(outcome.soundingSince)
val sounding = outcome.decision as? TimerRingDecision.Sound
return TimerServiceState(
notification = TimerNotificationPolicy.stateFor(active, alreadyAlerted),
ring = outcome.decision,
// Floored, so a rounding error cannot spin the service's loop (D20).
expiresIn = TimerExpiry.nextDeadline(rows, now, wallNow)
?.let { (it - now).coerceAtLeast(TimerRing.WAKE_FLOOR) },
silenceIn = sounding?.let { (it.silenceAt - wallNow).coerceAtLeast(TimerRing.WAKE_FLOOR) },
)
}
/** The session closes when the expired set returns to empty (D10). */
private suspend fun closeSessionLocked() = persistSessionLocked(null)
private suspend fun persistSessionLocked(soundingSince: Instant?) {
if (ringState.current() != soundingSince) ringState.set(soundingSince)
}
}
@@ -0,0 +1,43 @@
package de.jeanlucmakiola.clockula.timer
/**
* The timer path's actions, extras and `PendingIntent` request codes, spelled
* once and asserted distinct from the alarm path's — a shared request code is
* how one `PendingIntent` silently eats another's extras (D16).
*/
object TimerIntents {
private const val PREFIX = "de.jeanlucmakiola.clockula."
const val ACTION_EXPIRY: String = PREFIX + "action.TIMER_EXPIRY"
const val ACTION_PAUSE: String = PREFIX + "action.TIMER_PAUSE"
const val ACTION_RESUME: String = PREFIX + "action.TIMER_RESUME"
const val ACTION_RESET: String = PREFIX + "action.TIMER_RESET"
const val ACTION_ADD_TIME: String = PREFIX + "action.TIMER_ADD_TIME"
const val ACTION_SHOW_TIMERS: String = PREFIX + "action.SHOW_TIMERS"
const val ACTION_SERVICE_STOP: String = PREFIX + "action.TIMER_SERVICE_STOP"
const val EXTRA_TIMER_ID: String = PREFIX + "extra.TIMER_ID"
const val REQUEST_EXPIRY: Int = 11
const val REQUEST_SHOW: Int = 12
const val REQUEST_PAUSE: Int = 13
const val REQUEST_RESUME: Int = 14
const val REQUEST_RESET: Int = 15
const val REQUEST_ADD_TIME: Int = 16
val ALL_REQUEST_CODES: List<Int> = listOf(
REQUEST_EXPIRY,
REQUEST_SHOW,
REQUEST_PAUSE,
REQUEST_RESUME,
REQUEST_RESET,
REQUEST_ADD_TIME,
)
/** base * 100_000 + (timerId % 100_000) — the stride `RingNotifications` already uses. */
fun requestCodeFor(base: Int, timerId: Long): Int =
base * REQUEST_CODE_STRIDE + (timerId % REQUEST_CODE_STRIDE).toInt()
/** Keeps a derived code clear of every raw slot code in either object. */
private const val REQUEST_CODE_STRIDE = 100_000
}
@@ -0,0 +1,11 @@
package de.jeanlucmakiola.clockula.timer
import kotlin.time.Duration
/** The single AlarmManager slot for timer expiry. Elapsed-realtime, never wall-clock (D4). */
interface TimerScheduler {
/** Replaces the registration. [at] is a duration **since boot**. */
fun scheduleExpiry(at: Duration)
fun cancelExpiry()
}
@@ -0,0 +1,6 @@
package de.jeanlucmakiola.clockula.timer
/** Brings the foreground service up when something is active and down when nothing is (D6). */
interface TimerServiceHandle {
fun sync(active: Boolean)
}
@@ -0,0 +1,57 @@
package de.jeanlucmakiola.clockula.timer.android
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.clockula.timer.TimerIntents
import de.jeanlucmakiola.clockula.timer.TimerScheduler
import de.jeanlucmakiola.clockula.timer.receiver.TimerExpiryReceiver
import kotlin.time.Duration
import javax.inject.Inject
import javax.inject.Singleton
/**
* One slot, on the `ELAPSED_REALTIME_WAKEUP` base — the clock a timer is
* anchored on, so a `TIME_SET` cannot warp a running timer's expiry (D4).
* A timer never populates `getNextAlarmClock()`: that slot draws the status-bar
* alarm icon and the lockscreen line, and it belongs to the user's next *alarm*.
*
* Two branches only, in the shape the alarm's auto-silence backstop already
* uses: late is survivable, silent is not, so there is no third.
*/
@Singleton
class AndroidTimerScheduler @Inject constructor(
@param:ApplicationContext private val context: Context,
) : TimerScheduler {
private val manager: AlarmManager = context.getSystemService(AlarmManager::class.java)
override fun scheduleExpiry(at: Duration) {
// Milliseconds since boot, the same base `SystemClock.elapsedRealtime()`
// reports and the same one the row's anchor holds.
val millis = at.inWholeMilliseconds
val pendingIntent = expiryPendingIntent()
val exact = runCatching {
manager.setExactAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, millis, pendingIntent)
}.isSuccess
if (!exact) {
manager.setAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, millis, pendingIntent)
}
}
override fun cancelExpiry() = manager.cancel(expiryPendingIntent())
/**
* One request code, because there is one slot for every timer and the fire
* is an idempotent sweep carrying no id (D5) — so the cancel above finds
* exactly the PendingIntent the schedule created.
*/
private fun expiryPendingIntent(): PendingIntent = PendingIntent.getBroadcast(
context,
TimerIntents.REQUEST_EXPIRY,
Intent(context, TimerExpiryReceiver::class.java).setAction(TimerIntents.ACTION_EXPIRY),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
@@ -0,0 +1,29 @@
package de.jeanlucmakiola.clockula.timer.android
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.clockula.timer.TimerServiceHandle
import de.jeanlucmakiola.clockula.timer.service.TimerService
import javax.inject.Inject
import javax.inject.Singleton
/**
* Starts and stops the one foreground service. Both calls are wrapped in
* `runCatching`, because the service is a readout and a control surface, never
* the timekeeper — the stored anchors and the AlarmManager slot are (D6).
*
* Starting it from the background is permitted on every path M6 uses: a user
* action in a visible app, `BOOT_COMPLETED` and an exact-alarm callback are all
* documented exemptions. A refusal still costs the user nothing that matters.
*/
@Singleton
class ServiceTimerHandle @Inject constructor(
@param:ApplicationContext private val context: Context,
) : TimerServiceHandle {
override fun sync(active: Boolean) {
runCatching {
if (active) TimerService.start(context) else TimerService.stop(context)
}
}
}
@@ -0,0 +1,35 @@
package de.jeanlucmakiola.clockula.timer.di
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.clockula.timer.AlarmRingStatus
import de.jeanlucmakiola.clockula.timer.AlarmStateRingStatus
import de.jeanlucmakiola.clockula.timer.TimerScheduler
import de.jeanlucmakiola.clockula.timer.TimerServiceHandle
import de.jeanlucmakiola.clockula.timer.android.AndroidTimerScheduler
import de.jeanlucmakiola.clockula.timer.android.ServiceTimerHandle
import javax.inject.Singleton
/**
* The three seams `TimerEngine` sees. Everything platform-specific about a
* timer is on the far side of one of these, which is what keeps the engine
* itself JVM-testable (D1, D30).
*/
@Module
@InstallIn(SingletonComponent::class)
abstract class TimerModule {
@Binds
@Singleton
abstract fun bindTimerScheduler(impl: AndroidTimerScheduler): TimerScheduler
@Binds
@Singleton
abstract fun bindTimerServiceHandle(impl: ServiceTimerHandle): TimerServiceHandle
@Binds
@Singleton
abstract fun bindAlarmRingStatus(impl: AlarmStateRingStatus): AlarmRingStatus
}
@@ -0,0 +1,52 @@
package de.jeanlucmakiola.clockula.timer.receiver
import android.content.Context
import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.clockula.alarm.receiver.HiltBroadcastReceiver
import de.jeanlucmakiola.clockula.timer.TimerEngine
import de.jeanlucmakiola.clockula.timer.TimerIntents
import de.jeanlucmakiola.floret.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* The notification's buttons. A broadcast and not a service start, because a
* background foreground-service start can be refused and a broadcast cannot —
* and not an activity, because a control should not have to open the app (D16).
*
* Nothing is carried across but the timer id; the engine reads the world from
* storage, and every verb is guarded by the state it makes sense for, so a
* stale button is a silent no-op.
*/
@AndroidEntryPoint
class TimerActionReceiver : HiltBroadcastReceiver() {
@Inject
lateinit var engine: TimerEngine
@Inject
@ApplicationScope
lateinit var scope: CoroutineScope
override fun onReceive(context: Context, intent: Intent) {
super.onReceive(context, intent)
val timerId = intent.getLongExtra(TimerIntents.EXTRA_TIMER_ID, 0L)
val action = intent.action ?: return
val pendingResult = goAsync()
scope.launch {
try {
when (action) {
TimerIntents.ACTION_PAUSE -> engine.pause(timerId)
TimerIntents.ACTION_RESUME -> engine.start(timerId)
TimerIntents.ACTION_RESET -> engine.reset(timerId)
TimerIntents.ACTION_ADD_TIME -> engine.addTime(timerId)
}
} finally {
pendingResult.finish()
}
}
}
}
@@ -0,0 +1,40 @@
package de.jeanlucmakiola.clockula.timer.receiver
import android.content.Context
import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.clockula.alarm.receiver.HiltBroadcastReceiver
import de.jeanlucmakiola.clockula.timer.TimerEngine
import de.jeanlucmakiola.floret.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* The single expiry slot arriving. It carries no timer id: the fire is an
* idempotent sweep that marks everything due and writes nothing when nothing
* is (D5), so a late delivery and an early one are both safe.
*/
@AndroidEntryPoint
class TimerExpiryReceiver : HiltBroadcastReceiver() {
@Inject
lateinit var engine: TimerEngine
@Inject
@ApplicationScope
lateinit var scope: CoroutineScope
override fun onReceive(context: Context, intent: Intent) {
super.onReceive(context, intent)
val pendingResult = goAsync()
scope.launch {
try {
engine.onExpiryDue()
} finally {
pendingResult.finish()
}
}
}
}
@@ -0,0 +1,192 @@
package de.jeanlucmakiola.clockula.timer.service
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import de.jeanlucmakiola.clockula.MainActivity
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.format.ClockFormat
import de.jeanlucmakiola.clockula.domain.timer.TimerMode
import de.jeanlucmakiola.clockula.domain.timer.TimerNotificationAction
import de.jeanlucmakiola.clockula.domain.timer.TimerNotificationState
import de.jeanlucmakiola.clockula.timer.TimerIntents
import de.jeanlucmakiola.clockula.timer.receiver.TimerActionReceiver
/**
* One channel and one id for however many timers: the foreground service's
* notification *is* the timer notification, and the subject rule already
* answers "which timer matters now" for the live pill — so the shade and the
* pill can never disagree (D17).
*
* The countdown is the **platform chronometer** (D19), so the service posts
* once per state change rather than once per second for forty-five minutes.
*/
object TimerNotifications {
const val TIMER_CHANNEL_ID: String = "timers"
/** One notification for every timer (D17), so one id. */
const val TIMER_NOTIFICATION_ID: Int = 1_002
/** Idempotent; called from the app class on every start. */
fun createChannel(context: Context) {
val channel = NotificationChannel(
TIMER_CHANNEL_ID,
context.getString(R.string.channel_timers_name),
// HIGH so an expiry can heads-up — once per session, and never for a
// countdown update (D17).
NotificationManager.IMPORTANCE_HIGH,
).apply {
description = context.getString(R.string.channel_timers_description)
// The service owns the sound and the vibration, so the channel adds
// neither — two ringtones at once is not a feature.
setSound(null, null)
enableVibration(false)
lockscreenVisibility = Notification.VISIBILITY_PUBLIC
}
context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
}
/**
* The notification the service goes foreground with the instant it starts,
* before it has read anything: `startForeground` must not wait on a
* database read. [timer] replaces it under the same id a moment later.
*/
fun preparing(context: Context): Notification =
NotificationCompat.Builder(context, TIMER_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(context.getString(R.string.timer_notification_title))
.setCategory(NotificationCompat.CATEGORY_ALARM)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
// `setOnlyAlertOnce` suppresses alerting on an *update*, so it does
// nothing for a first post: on an IMPORTANCE_HIGH channel the
// placeholder would heads-up a "Timer" banner every time a timer is
// started. `setSilent` is what makes a post unable to alert at all —
// never for a countdown, only for the expiry re-post (D17).
.setSilent(true)
.setOnlyAlertOnce(true)
.setOngoing(true)
.setAutoCancel(false)
.setContentIntent(showPendingIntent(context))
.build()
/** The subject, and a count. Two actions at most — the pair the row offers. */
fun timer(context: Context, state: TimerNotificationState): Notification {
val builder = NotificationCompat.Builder(context, TIMER_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(
state.label.ifBlank { context.getString(R.string.timer_notification_title) },
)
.setCategory(NotificationCompat.CATEGORY_ALARM)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setOngoing(true)
.setAutoCancel(false)
// False exactly once per ring session, so an expiry heads-ups once
// and a countdown update never does (D17). `setOnlyAlertOnce` alone
// would let the *first* post of a countdown alert, because it only
// speaks about updates to a notification already on screen.
.setSilent(state.alertOnce)
.setOnlyAlertOnce(state.alertOnce)
.setContentIntent(showPendingIntent(context))
if (state.otherActiveTimers > 0) {
builder.setSubText(
context.resources.getQuantityString(
R.plurals.timer_more_active,
state.otherActiveTimers,
state.otherActiveTimers,
),
)
}
val base = state.chronometerBase
if (base != null) {
// The system renders the ticking. The base is the row's stored
// wall-clock end — the single wall-clock value in the whole timer
// path, and the reason a `TIME_SET` re-posts the notification.
builder.setWhen(base.toEpochMilliseconds())
.setShowWhen(true)
.setUsesChronometer(true)
.setChronometerCountDown(true)
} else {
builder.setShowWhen(false).setContentText(bodyFor(context, state))
}
state.actions.forEach { action ->
builder.addAction(
R.drawable.ic_notification,
context.getString(labelFor(state.mode, action)),
actionPendingIntent(context, action, state.subjectId),
)
}
return builder.build()
}
/** A static readout: a paused timer, an expired one, or a corrupt running row (D19). */
private fun bodyFor(context: Context, state: TimerNotificationState): String = when (state.mode) {
TimerMode.EXPIRED -> context.getString(R.string.timer_finished)
TimerMode.PAUSED -> context.getString(
R.string.timer_paused_at,
ClockFormat.countdown(state.remaining),
)
TimerMode.RUNNING, TimerMode.IDLE -> ClockFormat.countdown(state.remaining)
}
/**
* "Stop" and "Reset" are the same verb — `RESET` — under two names: the one
* the user reads depends on whether the timer is ringing at them.
*/
private fun labelFor(mode: TimerMode, action: TimerNotificationAction): Int = when (action) {
TimerNotificationAction.PAUSE -> R.string.timer_pause
TimerNotificationAction.RESUME -> R.string.timer_resume
TimerNotificationAction.ADD_MINUTE -> R.string.timer_add_minute
TimerNotificationAction.RESET ->
if (mode == TimerMode.EXPIRED) R.string.timer_stop else R.string.timer_reset
}
/** Opens the Timers tab, through the hook M9 will finish (D24). */
private fun showPendingIntent(context: Context): PendingIntent = PendingIntent.getActivity(
context,
TimerIntents.REQUEST_SHOW,
Intent(context, MainActivity::class.java)
.setAction(TimerIntents.ACTION_SHOW_TIMERS)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP),
PENDING_INTENT_FLAGS,
)
private fun actionPendingIntent(
context: Context,
action: TimerNotificationAction,
timerId: Long,
): PendingIntent = PendingIntent.getBroadcast(
context,
// Per timer *and* per action, so two timers' buttons cannot overwrite
// each other's extras the way one shared request code would (D16).
TimerIntents.requestCodeFor(requestCodeBase(action), timerId),
Intent(context, TimerActionReceiver::class.java)
.setAction(actionString(action))
.putExtra(TimerIntents.EXTRA_TIMER_ID, timerId),
PENDING_INTENT_FLAGS,
)
private fun actionString(action: TimerNotificationAction): String = when (action) {
TimerNotificationAction.PAUSE -> TimerIntents.ACTION_PAUSE
TimerNotificationAction.RESUME -> TimerIntents.ACTION_RESUME
TimerNotificationAction.RESET -> TimerIntents.ACTION_RESET
TimerNotificationAction.ADD_MINUTE -> TimerIntents.ACTION_ADD_TIME
}
private fun requestCodeBase(action: TimerNotificationAction): Int = when (action) {
TimerNotificationAction.PAUSE -> TimerIntents.REQUEST_PAUSE
TimerNotificationAction.RESUME -> TimerIntents.REQUEST_RESUME
TimerNotificationAction.RESET -> TimerIntents.REQUEST_RESET
TimerNotificationAction.ADD_MINUTE -> TimerIntents.REQUEST_ADD_TIME
}
private const val PENDING_INTENT_FLAGS: Int =
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
}
@@ -0,0 +1,323 @@
package de.jeanlucmakiola.clockula.timer.service
import android.app.Notification
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import androidx.core.app.NotificationManagerCompat
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.clockula.data.timers.TimerRepository
import de.jeanlucmakiola.clockula.domain.ring.RingFallbackPolicy
import de.jeanlucmakiola.clockula.domain.timer.TimerNotificationState
import de.jeanlucmakiola.clockula.domain.timer.TimerRingDecision
import de.jeanlucmakiola.clockula.ring.RingAudioPlayer
import de.jeanlucmakiola.clockula.ring.RingVibrator
import de.jeanlucmakiola.clockula.timer.AlarmRingStatus
import de.jeanlucmakiola.clockula.timer.TimerEngine
import de.jeanlucmakiola.clockula.timer.TimerIntents
import de.jeanlucmakiola.clockula.timer.TimerServiceState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.launch
import kotlin.time.Instant
import javax.inject.Inject
/**
* One foreground service for both phases of a timer's life — the countdown and
* the ring — alive exactly while something is **active**: RUNNING, PAUSED or
* EXPIRED. That is the live pill's predicate, and it is one predicate in the
* whole app (D6). PAUSED is in it because dropping the notification on pause
* would leave a user who paused from the shade with no way to resume without
* opening the app.
*
* The service holds **no policy and no state of its own** beyond the
* per-session `alreadyAlerted` flag: every decision arrives from
* [TimerEngine.serviceState], which ran the pure policies and persisted what
* they asked for (D15). It is a readout and a control surface, never the
* timekeeper — the stored anchors and the AlarmManager slot are, and both
* survive its absence.
*
* The countdown holds **no wake lock**: a forty-five-minute timer keeping the
* CPU awake is a battery bug, and the AlarmManager slot is what wakes the
* device. The *ring* holds one, with a fifteen-minute timeout, exactly as
* `AlarmRingService` does.
*/
@AndroidEntryPoint
class TimerService : Service() {
@Inject
lateinit var engine: TimerEngine
@Inject
lateinit var timers: TimerRepository
@Inject
lateinit var alarmRing: AlarmRingStatus
@Inject
lateinit var settings: SettingsPrefs
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val audio by lazy { RingAudioPlayer(this) }
private val vibrator by lazy { RingVibrator(this) }
private var wakeLock: PowerManager.WakeLock? = null
private var collector: Job? = null
/** The two independent delays the engine hands down (D20). */
private var expiry: Job? = null
private var silence: Job? = null
/** Which timer the audio is currently sounding for, so an unchanged decision does nothing. */
private var sounding: Long? = null
/** Owned here, scoped to the ring session it was spent in: the rule itself is pure (D17). */
private var alreadyAlerted: Boolean = false
/**
* The auto-silence window the heads-up above was spent inside. A window is
* one ring session (D10), so a *new* session is a new event and gets its
* own one heads-up — even while an earlier timer stays EXPIRED.
*/
private var alertedWindow: Instant? = null
/**
* `startForeground` is for going foreground, not for posting: every engine
* verb ends in `sync(true)`, so re-posting the placeholder on each
* `onStartCommand` would replace the live notification — label, count,
* chronometer and both buttons — until the collector's next pass (D6).
*/
private var wentForeground: Boolean = false
/** The last start command's id, so [stop] can decline to die on a newer one. */
private var lastStartId: Int = 0
/** The delays' way of asking for another pass without a repository write. */
private val refresh = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
lastStartId = startId
if (intent?.action == TimerIntents.ACTION_SERVICE_STOP) {
stop()
return START_NOT_STICKY
}
// Before any suspension point, and on the *first* path only: the platform
// gives a foreground service a few seconds to post its notification, and
// a database read is not something to spend them on — but a service that
// is already foreground has a real notification up, and replacing it with
// the placeholder on every verb is a visible flicker (D6).
if (!wentForeground) startForeground(TimerNotifications.preparing(this))
if (collector == null) {
collector = scope.launch {
triggers().collect { apply(engine.serviceState(alreadyAlerted)) }
}
} else {
refresh.tryEmit(Unit)
}
// Sticky rather than re-delivered: the intent carries nothing, because a
// service the system recreated must know exactly as much as one the
// engine started — and it reads all of it from storage.
return START_STICKY
}
override fun onDestroy() {
releaseRing()
scope.cancel()
super.onDestroy()
}
/**
* The timers, the alarm-ringing flag and the defaults — and deliberately
* **not** the ring session's anchor, which the engine writes: including it
* would let the engine's own write re-trigger the collector that caused it
* (D15).
*/
private fun triggers(): Flow<Unit> = merge(
combine(timers.timers(), alarmRing.isRinging(), settings.defaults) { _, _, _ -> Unit },
refresh,
)
private suspend fun apply(incoming: TimerServiceState) {
val state = rescopeHeadsUp(incoming)
val notification = state.notification
if (notification == null) {
// Nothing active: the service stops itself rather than waiting to be
// told to, so "down when every timer is idle" needs no second caller.
stop()
return
}
applyRing(state.ring)
post(notification)
scheduleDelays(state)
}
/**
* A ring session's window is its identity (D10), so when the window moves the
* flag that spends the session's one heads-up moves with it. The state is
* asked for again, because it was computed from the answer that has just
* changed — a second pass rather than a notification that says the right
* thing one trigger too late.
*/
private suspend fun rescopeHeadsUp(state: TimerServiceState): TimerServiceState {
// Only a sounding decision carries a window; a suppressed or silenced one
// must not clear the record, or an alarm ringing across a session would
// buy it a second heads-up (D9).
val window = (state.ring as? TimerRingDecision.Sound)?.silenceAt ?: return state
val reopened = alreadyAlerted && window != alertedWindow
alertedWindow = window
if (!reopened) return state
alreadyAlerted = false
return engine.serviceState(alreadyAlerted)
}
private suspend fun applyRing(decision: TimerRingDecision) {
when (decision) {
is TimerRingDecision.Sound -> {
// An unchanged decision does nothing: restarting the player on
// every emission would stutter the ring.
if (sounding == decision.subjectId) return
releaseRing()
sounding = decision.subjectId
acquireWakeLock()
val audible = audio.start(scope, decision.settings.ringtoneUri, decision.settings.rampSeconds)
// Silence is not one of the outcomes: with no audio source at
// all the timer vibrates even when vibration is switched off.
if (RingFallbackPolicy.vibrationRequired(audible, decision.settings.vibrate)) {
vibrator.start()
}
}
// Suppressed by a ringing alarm, auto-silenced, or nothing expired:
// the noise stops and the row keeps saying what it says (D9, D11).
is TimerRingDecision.Suppressed, is TimerRingDecision.AutoSilenced -> releaseRing()
TimerRingDecision.Silent -> {
releaseRing()
// The next session gets its one heads-up.
alreadyAlerted = false
}
}
}
private fun post(state: TimerNotificationState) {
if (!state.alertOnce) alreadyAlerted = true
startForeground(TimerNotifications.timer(this, state))
}
/**
* Both come from the engine, so the timings are asserted in a JVM test and
* the service only ever does `delay`. Re-armed on every pass, because the
* earliest deadline may have moved.
*/
private fun scheduleDelays(state: TimerServiceState) {
expiry?.cancel()
expiry = state.expiresIn?.let { until ->
scope.launch {
delay(until)
// The same idempotent sweep the AlarmManager slot triggers.
// Neither has to be reliable alone (D20).
engine.onExpiryDue()
refresh.tryEmit(Unit)
}
}
silence?.cancel()
silence = state.silenceIn?.let { until ->
scope.launch {
delay(until)
// Nothing in the trigger flow fires at the window's end, and the
// wake lock is held while sounding, so this delay will land.
refresh.tryEmit(Unit)
}
}
}
private fun startForeground(notification: Notification) {
wentForeground = true
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
// systemExempted, the same type and justification as the alarm's
// ringing service: an app holding an exact-alarm permission,
// continuing an alarm-shaped event in the background (D6).
startForeground(
TimerNotifications.TIMER_NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED,
)
} else {
startForeground(TimerNotifications.TIMER_NOTIFICATION_ID, notification)
}
}
private fun acquireWakeLock() {
if (wakeLock != null) return
val power = getSystemService(PowerManager::class.java)
wakeLock = power.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG).apply {
setReferenceCounted(false)
runCatching { acquire(WAKE_LOCK_TIMEOUT_MILLIS) }
}
}
/** The whole ring released, not just the audio: an orphaned looping player cannot be stopped. */
private fun releaseRing() {
sounding = null
audio.stop()
vibrator.stop()
wakeLock?.let { if (it.isHeld) runCatching { it.release() } }
wakeLock = null
}
private fun stop() {
expiry?.cancel()
silence?.cancel()
releaseRing()
stopForeground(STOP_FOREGROUND_REMOVE)
// The placeholder is posted before anything is read, so a service that
// starts with nothing active must take its own notification with it.
NotificationManagerCompat.from(this).cancel(TimerNotifications.TIMER_NOTIFICATION_ID)
wentForeground = false
// Not `stopSelf()`: resetting the last timer and immediately starting a
// new one delivers a start command to this still-live instance, and
// `stopSelf` would tear it down anyway — leaving a running timer with no
// service and no countdown notification until the next resync.
// `stopSelfResult` declines to die when a newer start command arrived,
// and that command has already asked the collector for another pass.
stopSelfResult(lastStartId)
}
companion object {
private const val WAKE_LOCK_TAG = "clockula:timer-ring"
/** A backstop on the backstop: the ring window is ten minutes. */
private const val WAKE_LOCK_TIMEOUT_MILLIS = 15L * 60L * 1_000L
fun start(context: Context) {
context.startForegroundService(Intent(context, TimerService::class.java))
}
fun stop(context: Context) {
context.startService(
Intent(context, TimerService::class.java).setAction(TimerIntents.ACTION_SERVICE_STOP),
)
}
}
}
@@ -0,0 +1,739 @@
package de.jeanlucmakiola.clockula.timer
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.TimerState
import de.jeanlucmakiola.clockula.domain.time.BootId
import de.jeanlucmakiola.clockula.domain.timer.TimerNotificationAction
import de.jeanlucmakiola.clockula.domain.timer.TimerRing
import de.jeanlucmakiola.clockula.domain.timer.TimerRingDecision
import de.jeanlucmakiola.clockula.testing.T0
import de.jeanlucmakiola.clockula.testing.TimerEngineHarness
import de.jeanlucmakiola.clockula.testing.expiredTimer
import de.jeanlucmakiola.clockula.testing.idleTimer
import de.jeanlucmakiola.clockula.testing.pausedTimer
import de.jeanlucmakiola.clockula.testing.runningTimerWith
import de.jeanlucmakiola.clockula.testing.timerAt
import de.jeanlucmakiola.clockula.testing.timerEngineHarness
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
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
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
/**
* §5.8 — 38 cases over the **real** [TimerEngine], the real
* `TimerRepositoryImpl` across the fake DAO, a real DataStore under a
* `@TempDir` and the three fake seams. The engine owns every write and every
* call into the seams (D15), so this is where the milestone's guarantees live:
* one slot on the elapsed-realtime base (D4), an idempotent id-less sweep (D5),
* the deferred-not-lost arbitration (D9) and "+1 min" (D12).
*/
class TimerEngineTest {
private val uptime: Duration = 1_000.seconds
// --- start, pause, reset: the slot and the service ---
/** §5.8 #1 */
@Test
fun `starting an idle timer registers its deadline on the monotonic clock`(
@TempDir tempDir: Path,
) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(idleTimer(id = 1L, duration = 5.minutes))
harness.engine.start(1L)
assertThat(harness.scheduler.expiryAt).isEqualTo(uptime + 5.minutes)
}
/** §5.8 #2 */
@Test
fun `starting a timer brings the service up`(@TempDir tempDir: Path) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(idleTimer(id = 1L, duration = 5.minutes))
harness.engine.start(1L)
assertThat(harness.service.active).isTrue()
}
/** §5.8 #3 */
@Test
fun `pausing cancels the slot and keeps the service up`(@TempDir tempDir: Path) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(runningTimerWith(id = 1L, left = 5.minutes, elapsedNow = uptime, wallNow = T0))
harness.engine.pause(1L)
assertThat(harness.scheduler.expiryAt to harness.service.active).isEqualTo(null to true)
}
/** §5.8 #4 */
@Test
fun `pausing the earliest timer hands the slot to the next one`(@TempDir tempDir: Path) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(runningTimerWith(id = 1L, left = 1.minutes, elapsedNow = uptime, wallNow = T0))
harness.given(runningTimerWith(id = 2L, left = 3.minutes, elapsedNow = uptime, wallNow = T0))
harness.engine.pause(1L)
assertThat(harness.scheduler.expiryAt).isEqualTo(uptime + 3.minutes)
}
/** §5.8 #5 */
@Test
fun `resetting the last active timer cancels the slot and takes the service down`(
@TempDir tempDir: Path,
) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(pausedTimer(id = 1L, remaining = 2.minutes))
harness.engine.reset(1L)
assertThat(harness.scheduler.expiryAt to harness.service.active).isEqualTo(null to false)
}
// --- the sweep ---
/** §5.8 #6 */
@Test
fun `the sweep expires only what is due`(@TempDir tempDir: Path) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(runningTimerWith(id = 1L, left = 1.minutes, elapsedNow = uptime - 2.minutes))
harness.given(runningTimerWith(id = 2L, left = 5.minutes, elapsedNow = uptime))
val untouched = harness.timerDao.stored.single { it.id == 2L }
val expired = harness.engine.onExpiryDue()
assertThat(expired).containsExactly(1L)
assertThat(harness.timerDao.stored.single { it.id == 2L }).isEqualTo(untouched)
}
/** §5.8 #7 */
@Test
fun `a fire that arrives early writes nothing`(@TempDir tempDir: Path) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(runningTimerWith(id = 1L, left = 60.seconds, elapsedNow = uptime))
val before = harness.timerDao.stored
val expired = harness.engine.onExpiryDue()
assertThat(expired).isEmpty()
assertThat(harness.timerDao.stored).isEqualTo(before)
}
/** §5.8 #8 */
@Test
fun `a fire for a timer the user has since deleted re-registers what remains`(
@TempDir tempDir: Path,
) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(runningTimerWith(id = 2L, left = 4.minutes, elapsedNow = uptime, wallNow = T0))
val expired = harness.engine.onExpiryDue()
assertThat(expired).isEmpty()
assertThat(harness.scheduler.expiryAt).isEqualTo(uptime + 4.minutes)
}
/** §5.8 #9 */
@Test
fun `the slot moves to the next deadline after an expiry`(@TempDir tempDir: Path) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(runningTimerWith(id = 1L, left = 1.minutes, elapsedNow = uptime - 2.minutes))
harness.given(runningTimerWith(id = 2L, left = 4.minutes, elapsedNow = uptime, wallNow = T0))
harness.engine.onExpiryDue()
assertThat(harness.scheduler.expiryAt).isEqualTo(uptime + 4.minutes)
}
/** §5.8 #10 */
@Test
fun `the last timer expiring cancels the slot but keeps the service up`(
@TempDir tempDir: Path,
) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(runningTimerWith(id = 1L, left = 1.minutes, elapsedNow = uptime - 2.minutes))
harness.engine.onExpiryDue()
assertThat(harness.scheduler.expiryAt to harness.service.active).isEqualTo(null to true)
}
/** §5.8 #11 */
@Test
fun `two timers due in the same second are one pass`(@TempDir tempDir: Path) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(runningTimerWith(id = 1L, left = 1.minutes, elapsedNow = uptime - 1.minutes))
harness.given(runningTimerWith(id = 2L, left = 1.minutes, elapsedNow = uptime - 1.minutes))
harness.engine.onExpiryDue()
assertThat(harness.timerDao.stored.map { it.state }).containsExactly("EXPIRED", "EXPIRED")
}
// --- "+1 min" (D12) ---
/** §5.8 #12 */
@Test
fun `a running timer gains a minute and keeps its configured duration`(
@TempDir tempDir: Path,
) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(
runningTimerWith(id = 1L, left = 60.seconds, elapsedNow = uptime, wallNow = T0, duration = 5.minutes),
)
harness.engine.addTime(1L, 1.minutes)
val timer = harness.stored(1L)!!
assertThat(listOf(timer.state, timer.duration, harness.scheduler.expiryAt))
.containsExactly(TimerState.RUNNING, 5.minutes, uptime + 120.seconds)
.inOrder()
}
/** §5.8 #13 */
@Test
fun `a paused timer gains a minute without being resumed`(@TempDir tempDir: Path) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(pausedTimer(id = 1L, remaining = 2.minutes))
harness.engine.addTime(1L, 1.minutes)
val timer = harness.stored(1L)!!
assertThat(listOf(timer.state, timer.remaining, harness.scheduler.expiryAt))
.containsExactly(TimerState.PAUSED, 3.minutes, null)
.inOrder()
}
/** §5.8 #14 */
@Test
fun `an expired timer that gains a minute is running again with exactly that minute`(
@TempDir tempDir: Path,
) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(expiredTimer(id = 1L, duration = 5.minutes))
harness.engine.addTime(1L, 1.minutes)
val timer = harness.stored(1L)!!
assertThat(
listOf(
timer.state,
timer.remaining,
timer.startedAtElapsedRealtime,
timer.endsAtElapsedRealtime,
timer.endsAtWallClock,
harness.scheduler.expiryAt,
),
).containsExactly(
TimerState.RUNNING,
1.minutes,
uptime,
uptime + 1.minutes,
T0 + 1.minutes,
uptime + 1.minutes,
).inOrder()
}
/** §5.8 #15 */
@Test
fun `resuming an expired timer with a minute closes the ring session`(
@TempDir tempDir: Path,
) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(expiredTimer(id = 1L))
harness.ringState.set(T0 - 30.seconds)
harness.engine.addTime(1L, 1.minutes)
assertThat(harness.ringState.current()).isNull()
}
/** §5.8 #16 */
@Test
fun `a stale plus-one-minute on an idle timer writes nothing`(@TempDir tempDir: Path) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(idleTimer(id = 1L, duration = 5.minutes))
val before = harness.timerDao.stored
harness.engine.addTime(1L, 1.minutes)
assertThat(harness.timerDao.stored).isEqualTo(before)
}
// --- every verb is guarded by the state it makes sense for (D16) ---
/** §5.8 #17 */
@Test
fun `pausing a paused timer writes nothing`(@TempDir tempDir: Path) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(pausedTimer(id = 1L, remaining = 2.minutes))
val before = harness.timerDao.stored
harness.engine.pause(1L)
assertThat(harness.timerDao.stored).isEqualTo(before)
}
/** §5.8 #18 */
@Test
fun `starting a running timer writes nothing and leaves the slot alone`(
@TempDir tempDir: Path,
) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(runningTimerWith(id = 1L, left = 4.minutes, elapsedNow = uptime, wallNow = T0))
harness.engine.resync()
val before = harness.timerDao.stored
harness.engine.start(1L)
assertThat(harness.timerDao.stored).isEqualTo(before)
assertThat(harness.scheduler.expiryAt).isEqualTo(uptime + 4.minutes)
}
/** §5.8 #19 */
@Test
fun `resetting an idle timer writes nothing`(@TempDir tempDir: Path) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(idleTimer(id = 1L, duration = 5.minutes))
val before = harness.timerDao.stored
harness.engine.reset(1L)
assertThat(harness.timerDao.stored).isEqualTo(before)
assertThat(harness.scheduler.expiryAt).isNull()
}
// --- delete (D2) ---
/** §5.8 #20 */
@Test
fun `deleting the only running timer cancels the slot and stops the service`(
@TempDir tempDir: Path,
) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(runningTimerWith(id = 1L, left = 4.minutes, elapsedNow = uptime, wallNow = T0))
harness.engine.resync()
harness.engine.delete(1L)
assertThat(harness.scheduler.expiryAt to harness.service.active).isEqualTo(null to false)
}
/** §5.8 #21 */
@Test
fun `deleting a ringing timer cannot leave the ring sounding`(@TempDir tempDir: Path) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(expiredTimer(id = 1L))
harness.ringState.set(T0 - 30.seconds)
harness.engine.delete(1L)
assertThat(harness.ringState.current()).isNull()
assertThat(harness.engine.serviceState(alreadyAlerted = false).ring)
.isEqualTo(TimerRingDecision.Silent)
}
// --- boot and reboot (D21) ---
/** §5.8 #22 */
@Test
fun `the boot pass repairs once per boot`(@TempDir tempDir: Path) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(idleTimer(id = 1L, duration = 5.minutes))
harness.engine.onBootCompleted()
val afterFirst = harness.stopwatch.pauseCalls
harness.engine.onBootCompleted()
// The *ordering* — repair before the timers are read — is what #23
// pins, by the value the slot ends up holding.
assertThat(afterFirst to harness.stopwatch.pauseCalls).isEqualTo(1 to 1)
}
/** §5.8 #23 */
@Test
fun `the boot pass registers the repaired remainder, not the dead anchor`(
@TempDir tempDir: Path,
) = runTest {
val harness = timerEngineHarness(tempDir, uptime = 50.seconds)
harness.bootIds.bootId = BootId(bootCount = 2, approximateBootInstant = T0)
harness.given(
timerAt(
id = 1L,
state = TimerState.RUNNING,
duration = 5.minutes,
remaining = 5.minutes,
startedAtElapsedRealtime = 9_000.seconds,
endsAtElapsedRealtime = 9_300.seconds,
endsAtWallClock = T0 + 3.minutes,
),
)
harness.engine.onBootCompleted()
assertThat(harness.scheduler.expiryAt).isEqualTo(50.seconds + 3.minutes)
}
/** §5.8 #24 */
@Test
fun `a timer whose end passed during the reboot rings when the device comes back`(
@TempDir tempDir: Path,
) = runTest {
val harness = timerEngineHarness(tempDir, uptime = 50.seconds)
harness.bootIds.bootId = BootId(bootCount = 2, approximateBootInstant = T0)
harness.given(
timerAt(
id = 1L,
state = TimerState.RUNNING,
duration = 5.minutes,
remaining = 5.minutes,
startedAtElapsedRealtime = 9_000.seconds,
endsAtElapsedRealtime = 9_300.seconds,
endsAtWallClock = T0 - 2.minutes,
),
)
harness.engine.onBootCompleted()
assertThat(harness.stored(1L)!!.state).isEqualTo(TimerState.EXPIRED)
assertThat(harness.engine.serviceState(alreadyAlerted = false).ring)
.isInstanceOf(TimerRingDecision.Sound::class.java)
}
// --- the system clock cannot move a timer (D4, D19) ---
/** §5.8 #25 */
@Test
fun `three hours of clock jump each way rewrites no row`(@TempDir tempDir: Path) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(runningTimerWith(id = 1L, left = 4.minutes, elapsedNow = uptime, wallNow = T0))
harness.engine.resync()
val before = harness.timerDao.stored
harness.wallClock.advance(3.hours)
harness.engine.resync()
harness.wallClock.advance(-6.hours)
harness.engine.resync()
assertThat(harness.timerDao.stored).isEqualTo(before)
}
/** §5.8 #26 */
@Test
fun `three hours of clock jump each way leaves the slot where it was`(
@TempDir tempDir: Path,
) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(runningTimerWith(id = 1L, left = 4.minutes, elapsedNow = uptime, wallNow = T0))
harness.engine.resync()
harness.wallClock.advance(3.hours)
harness.engine.resync()
harness.wallClock.advance(-6.hours)
harness.engine.resync()
assertThat(harness.scheduler.expiryAt).isEqualTo(uptime + 4.minutes)
}
// --- what the service is told to do (D15, D20) ---
/** §5.8 #27 */
@Test
fun `nothing active is nothing to do`(@TempDir tempDir: Path) = runTest {
val harness = timerEngineHarness(tempDir)
val state = harness.engine.serviceState(alreadyAlerted = false)
assertThat(listOf(state.notification, state.ring, state.expiresIn, state.silenceIn))
.containsExactly(null, TimerRingDecision.Silent, null, null)
.inOrder()
}
/** §5.8 #28 */
@Test
fun `an expired timer sounds and offers stop and plus one minute`(@TempDir tempDir: Path) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(expiredTimer(id = 1L))
val state = harness.engine.serviceState(alreadyAlerted = false)
assertThat((state.ring as TimerRingDecision.Sound).subjectId).isEqualTo(1L)
assertThat(state.notification!!.actions)
.containsExactly(TimerNotificationAction.RESET, TimerNotificationAction.ADD_MINUTE)
.inOrder()
}
/** §5.8 #29 */
@Test
fun `the service is told how long until the earliest timer expires`(
@TempDir tempDir: Path,
) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(runningTimerWith(id = 1L, left = 4.minutes, elapsedNow = uptime, wallNow = T0))
harness.given(runningTimerWith(id = 2L, left = 90.seconds, elapsedNow = uptime, wallNow = T0))
val state = harness.engine.serviceState(alreadyAlerted = false)
assertThat(state.expiresIn).isEqualTo(90.seconds)
}
/** §5.8 #30 */
@Test
fun `the service is told how long is left of the ring session's window`(
@TempDir tempDir: Path,
) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(expiredTimer(id = 1L))
harness.ringState.set(T0 - 30.seconds)
val state = harness.engine.serviceState(alreadyAlerted = false)
assertThat(state.silenceIn).isEqualTo(TimerRing.AUTO_SILENCE_AFTER - 30.seconds)
}
/** §5.8 #31 */
@Test
fun `the session anchor is persisted once, however often the service reads`(
@TempDir tempDir: Path,
) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(expiredTimer(id = 1L))
val before = harness.dataStore.writes
repeat(3) { harness.engine.serviceState(alreadyAlerted = it > 0) }
assertThat(harness.dataStore.writes - before).isEqualTo(1)
}
/** §5.8 #32 */
@Test
fun `acknowledging the last expired timer closes the session`(@TempDir tempDir: Path) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(expiredTimer(id = 1L))
harness.engine.serviceState(alreadyAlerted = false)
harness.engine.reset(1L)
val state = harness.engine.serviceState(alreadyAlerted = true)
assertThat(harness.ringState.current()).isNull()
assertThat(state.ring).isEqualTo(TimerRingDecision.Silent)
}
/** §5.8 #33 */
@Test
fun `two minutes added at once both land`(@TempDir tempDir: Path) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(runningTimerWith(id = 1L, left = 60.seconds, elapsedNow = uptime, wallNow = T0))
coroutineScope {
launch { harness.engine.addTime(1L, 1.minutes) }
launch { harness.engine.addTime(1L, 1.minutes) }
}
assertThat(harness.stored(1L)!!.remaining).isEqualTo(3.minutes)
}
/** §5.8 #34 */
@Test
fun `the timer engine never touches the alarm's ring state or its slots`(
@TempDir tempDir: Path,
) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(idleTimer(id = 1L, duration = 5.minutes))
harness.engine.start(1L)
harness.engine.addTime(1L, 1.minutes)
harness.engine.pause(1L)
harness.engine.onExpiryDue()
harness.engine.reset(1L)
harness.engine.resync()
assertThat(harness.stateDao.writes).isEqualTo(0)
assertThat(
listOf(harness.alarmScheduler.next, harness.alarmScheduler.autoSilence),
).containsExactly(null, null)
assertThat(harness.ringCoordinator.events).isEmpty()
}
// --- the audio arbitration (D9) ---
/** §5.8 #35 */
@Test
fun `an alarm ringing suppresses the audio but not the notification`(
@TempDir tempDir: Path,
) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(expiredTimer(id = 1L, label = "Pasta"))
harness.alarmRing.ringing = true
val state = harness.engine.serviceState(alreadyAlerted = false)
val notification = state.notification!!
assertThat(state.ring).isEqualTo(TimerRingDecision.Suppressed(1L))
assertThat(notification.subjectId to notification.label).isEqualTo(1L to "Pasta")
}
/** §5.8 #36 */
@Test
fun `a timer that comes due while an alarm rings is still marked expired`(
@TempDir tempDir: Path,
) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(runningTimerWith(id = 1L, left = 1.minutes, elapsedNow = uptime - 2.minutes))
harness.alarmRing.ringing = true
harness.engine.onExpiryDue()
assertThat(harness.stored(1L)!!.state).isEqualTo(TimerState.EXPIRED)
}
/** §5.8 #37 */
@Test
fun `a suppressed timer sounds when the alarm stops, with no second expiry write`(
@TempDir tempDir: Path,
) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(expiredTimer(id = 1L))
harness.alarmRing.ringing = true
harness.engine.serviceState(alreadyAlerted = false)
val afterSuppression = harness.timerDao.stored
harness.alarmRing.ringing = false
val state = harness.engine.serviceState(alreadyAlerted = false)
assertThat(state.ring).isInstanceOf(TimerRingDecision.Sound::class.java)
assertThat(harness.timerDao.stored).isEqualTo(afterSuppression)
}
/** §5.8 #38 */
@Test
fun `starting an expired timer takes its whole configured duration back`(
@TempDir tempDir: Path,
) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(expiredTimer(id = 1L, duration = 5.minutes))
harness.ringState.set(T0 - 30.seconds)
harness.engine.start(1L)
val timer = harness.stored(1L)!!
assertThat(listOf(timer.state, timer.remaining, harness.ringState.current()))
.containsExactly(TimerState.RUNNING, 5.minutes, null)
.inOrder()
}
// --- what a clock change does to the wall-clock fallback (review finding 1) ---
/** §5.8 #39 — added with the review fix for finding 1. */
@Test
fun `a clock change re-anchors the wall-clock fallback and moves no monotonic anchor`(
@TempDir tempDir: Path,
) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(runningTimerWith(id = 1L, left = 30.minutes, elapsedNow = uptime, wallNow = T0))
harness.engine.resync()
harness.wallClock.advance(1.hours)
harness.engine.onSystemTimeOrZoneChanged()
val timer = harness.stored(1L)!!
assertThat(timer.endsAtWallClock).isEqualTo(T0 + 1.hours + 30.minutes)
assertThat(timer.startedAtElapsedRealtime to timer.endsAtElapsedRealtime)
.isEqualTo(uptime to uptime + 30.minutes)
}
/** §5.8 #40 — added with the review fix for finding 1. */
@Test
fun `a reboot after a clock change still has the whole remainder to run`(
@TempDir tempDir: Path,
) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(runningTimerWith(id = 1L, left = 30.minutes, elapsedNow = uptime, wallNow = T0))
harness.engine.resync()
harness.wallClock.advance(1.hours)
harness.engine.onSystemTimeOrZoneChanged()
// The reboot: the monotonic clock restarts and a minute of wall clock
// passes with the device off.
harness.elapsed.reboot(uptime = 20.seconds)
harness.wallClock.advance(1.minutes)
harness.bootIds.bootId = BootId(bootCount = 2, approximateBootInstant = T0 + 1.hours)
harness.engine.onBootCompleted()
val timer = harness.stored(1L)!!
assertThat(timer.state to timer.remaining).isEqualTo(TimerState.RUNNING to 29.minutes)
assertThat(harness.scheduler.expiryAt).isEqualTo(20.seconds + 29.minutes)
}
// --- a new expiry is its own ring session (review finding 2) ---
/** §5.8 #41 — added with the review fix for finding 2. */
@Test
fun `a timer expiring after the window lapsed gets a session of its own`(
@TempDir tempDir: Path,
) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(runningTimerWith(id = 1L, left = 1.minutes, elapsedNow = uptime, wallNow = T0))
harness.given(idleTimer(id = 2L, duration = 5.minutes, label = "Eggs", sortOrder = 1))
harness.advance(1.minutes)
harness.engine.onExpiryDue()
// The first timer sounds, and is then left alone until the window lapses.
harness.engine.serviceState(alreadyAlerted = false)
harness.advance(TimerRing.AUTO_SILENCE_AFTER)
val lapsed = harness.engine.serviceState(alreadyAlerted = true)
harness.engine.start(2L)
harness.advance(5.minutes)
harness.engine.onExpiryDue()
val second = harness.engine.serviceState(alreadyAlerted = true)
assertThat(lapsed.ring).isInstanceOf(TimerRingDecision.AutoSilenced::class.java)
assertThat(second.ring).isInstanceOf(TimerRingDecision.Sound::class.java)
assertThat((second.ring as TimerRingDecision.Sound).silenceAt)
.isEqualTo(harness.wallClock.instant + TimerRing.AUTO_SILENCE_AFTER)
}
/** §5.8 #42 — added with the review fix for finding 2. */
@Test
fun `a second timer expiring inside the window still inherits it`(
@TempDir tempDir: Path,
) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(runningTimerWith(id = 1L, left = 1.minutes, elapsedNow = uptime, wallNow = T0))
harness.given(
runningTimerWith(
id = 2L,
left = 2.minutes,
elapsedNow = uptime,
wallNow = T0,
label = "Eggs",
sortOrder = 1,
),
)
harness.advance(1.minutes)
harness.engine.onExpiryDue()
val opened = harness.engine.serviceState(alreadyAlerted = false).ring
harness.advance(1.minutes)
harness.engine.onExpiryDue()
val inherited = harness.engine.serviceState(alreadyAlerted = true).ring
assertThat((opened as TimerRingDecision.Sound).silenceAt)
.isEqualTo((inherited as TimerRingDecision.Sound).silenceAt)
}
/** Both clocks by the same amount: time passing, with nothing else changing. */
private fun TimerEngineHarness.advance(by: Duration) {
elapsed.advance(by)
wallClock.advance(by)
}
}
@@ -0,0 +1,84 @@
package de.jeanlucmakiola.clockula.timer
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.alarm.AlarmIntents
import org.junit.jupiter.api.Test
/**
* §5.17 — 5 cases. Two `PendingIntent`s sharing a request code silently
* overwrite each other, and M6 adds a second family of them — so the assertion
* that matters is over the **union** of both objects' codes (D16).
*/
class TimerIntentsTest {
private val actions: List<String> get() = listOf(
TimerIntents.ACTION_EXPIRY,
TimerIntents.ACTION_PAUSE,
TimerIntents.ACTION_RESUME,
TimerIntents.ACTION_RESET,
TimerIntents.ACTION_ADD_TIME,
TimerIntents.ACTION_SHOW_TIMERS,
TimerIntents.ACTION_SERVICE_STOP,
)
private val alarmActions: List<String> get() = listOf(
AlarmIntents.ACTION_FIRE,
AlarmIntents.ACTION_AUTO_SILENCE,
AlarmIntents.ACTION_SNOOZE,
AlarmIntents.ACTION_DISMISS,
)
/** §5.17 #1 */
@Test
fun `no two timer request codes collide`() {
assertThat(TimerIntents.ALL_REQUEST_CODES).containsNoDuplicates()
}
/** §5.17 #2 */
@Test
fun `no timer request code collides with an alarm's`() {
val union = TimerIntents.ALL_REQUEST_CODES + AlarmIntents.ALL_REQUEST_CODES
assertThat(union).containsNoDuplicates()
}
/** §5.17 #3 */
@Test
fun `the per-timer stride separates both ids and bases`() {
val sameBase = listOf(
TimerIntents.requestCodeFor(TimerIntents.REQUEST_PAUSE, 1L),
TimerIntents.requestCodeFor(TimerIntents.REQUEST_PAUSE, 2L),
)
val sameId = listOf(
TimerIntents.requestCodeFor(TimerIntents.REQUEST_PAUSE, 1L),
TimerIntents.requestCodeFor(TimerIntents.REQUEST_RESET, 1L),
)
// `requestCodeFor(REQUEST_PAUSE, 1L)` is deliberately in both lists: the
// function is deterministic (a `PendingIntent`'s cancel has to recompute
// the code its schedule used), so the union has that one code twice by
// construction. What the stride has to guarantee is that varying either
// input alone moves the code — three distinct codes across four calls.
assertThat(sameBase).containsNoDuplicates()
assertThat(sameId).containsNoDuplicates()
assertThat((sameBase + sameId).distinct()).hasSize(3)
}
/** §5.17 #4 */
@Test
fun `a per-timer code can never be a raw slot code`() {
val raw = TimerIntents.ALL_REQUEST_CODES + AlarmIntents.ALL_REQUEST_CODES
val derived = TimerIntents.ALL_REQUEST_CODES.flatMap { base ->
listOf(0L, 1L, 7L, 99_999L, 100_000L).map { TimerIntents.requestCodeFor(base, it) }
}
assertThat(derived.filter { it in raw }).isEmpty()
}
/** §5.17 #5 */
@Test
fun `every action is namespaced to this application and distinct from the alarm's`() {
assertThat(actions.filterNot { it.startsWith("de.jeanlucmakiola.clockula.") }).isEmpty()
assertThat(actions + alarmActions).containsNoDuplicates()
}
}