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