feat(settings): let alarms and timers choose when they silence themselves
A ringing alarm and a finished timer used to stop after a fixed ten minutes. Settings > Alarms and Settings > Timers now each have a "Silence after" row with Google Clock's choices: 1, 5, 10, 15, 20 or 25 minutes, or Never. Ten minutes stays the default, so nothing changes on upgrade. The value is app-wide; a per-alarm override would need a new column, a migration and an editor row, and is left out. "Never" rings for an hour, then stops. The backstop is also what keeps a phone left at home from ringing its battery flat, what bounds the ring services' wake locks, and what stops a reboot hours later from resuming a ring nobody was there for. A truly unbounded ring would give all of that up, so Never maps to a one-hour hard stop, longer than any finite choice, and the settings hint says so. Stored values clamp to 0..60 on read, on write and from a backup. The window is read everywhere the fixed constant was: the resolver's ringing and late-snooze checks (which now take it without a default), the backstop onFire arms, how late a snooze fire may arrive, whether an interrupted ring is resumed after a reboot, upcoming(), the timer ring policy, the lapsed-session check and the timer ring screen. Both ring services' wake-lock timeouts now derive from the hard stop instead of a hard-coded fifteen minutes. When the resolver closes a ring (the window was shortened mid-ring, the clock jumped, the alarm was switched off), the engine now also stops the ring service and cancels the backstop. Before, it only cleared ringingSince and left the sound to a backstop armed for the old window. Both values are backed up as default_alarm_silence_after_minutes and default_timer_silence_after_minutes; a file without them imports as ten minutes. Fixes #17
This commit is contained in:
1 parent
3f431a7f32
commit
412e54db09
33 files changed
+680
-80
No files matched your search
@@ -52,6 +52,10 @@ The first release of Clockula: alarms, timers, a stopwatch and a world clock.
|
||||
with Clockula. A voice-set alarm cleans up after itself once dismissed.
|
||||
- Settings: theme, dynamic colour, language, and the defaults every new alarm
|
||||
and timer inherits.
|
||||
- Choose how long a ringing alarm or a finished timer keeps going before it
|
||||
stops on its own: 1, 5, 10, 15, 20 or 25 minutes, or Never, separately for
|
||||
alarms and timers. Ten minutes stays the default, and Never still stops
|
||||
after an hour so a phone left at home can't ring its battery flat.
|
||||
- Back up alarms, timers, world clocks and settings to a JSON file, and restore
|
||||
them on this device or another one.
|
||||
- "Why might my alarm not ring?", a self-check that reads your device's real
|
||||
|
||||
@@ -182,7 +182,12 @@ class AlarmEngine @Inject constructor(
|
||||
* emission; a live readout passes a `Ticker`'s flow (M5 D8).
|
||||
*/
|
||||
fun upcoming(refresh: Flow<Unit> = flowOf(Unit)): Flow<List<UpcomingAlarm>> =
|
||||
combine(alarms.alarms(), states.states(), refresh) { alarms, states, _ ->
|
||||
combine(
|
||||
alarms.alarms(),
|
||||
states.states(),
|
||||
settings.defaults.map { it.alarmSilenceAfter }.distinctUntilChanged(),
|
||||
refresh,
|
||||
) { alarms, states, silenceAfter, _ ->
|
||||
val now = wallClock.now()
|
||||
val zone = zones.current()
|
||||
val byId = states.associateBy { it.alarmId }
|
||||
@@ -195,6 +200,7 @@ class AlarmEngine @Inject constructor(
|
||||
byId[alarm.id] ?: AlarmRingState.initial(alarm.id),
|
||||
now,
|
||||
zone,
|
||||
silenceAfter,
|
||||
)
|
||||
UpcomingAlarm(alarm, resolution.nextFire, resolution.source)
|
||||
}
|
||||
@@ -207,11 +213,12 @@ class AlarmEngine @Inject constructor(
|
||||
private suspend fun rescheduleLocked(): ScheduledFire? {
|
||||
val now = wallClock.now()
|
||||
val zone = zones.current()
|
||||
val silenceAfter = settings.currentDefaults().alarmSilenceAfter
|
||||
|
||||
var earliest: Pair<Alarm, AlarmResolution>? = null
|
||||
for (alarm in alarms.alarms().first()) {
|
||||
val state = states.state(alarm.id)
|
||||
val resolution = AlarmResolver.resolve(alarm, state, now, zone)
|
||||
val resolution = AlarmResolver.resolve(alarm, state, now, zone, silenceAfter)
|
||||
persist(alarm, state, resolution)
|
||||
|
||||
val fireAt = resolution.nextFire ?: continue
|
||||
@@ -236,10 +243,11 @@ class AlarmEngine @Inject constructor(
|
||||
val alarm = alarms.find(alarmId)
|
||||
val state = states.state(alarmId)
|
||||
val isSnoozeFire = state.snoozedUntil != null && state.snoozedUntil == scheduledFor
|
||||
val silenceAfter = settings.currentDefaults().alarmSilenceAfter
|
||||
// A snooze is kept for as long as the resolver keeps it pending, so one
|
||||
// delivered late (after a reboot, or by Doze) still rings. Rejecting it
|
||||
// here would only re-register the same past instant and fire again.
|
||||
val lateBy = if (isSnoozeFire) AlarmRing.AUTO_SILENCE_AFTER else AlarmRing.FIRE_GRACE
|
||||
val lateBy = if (isSnoozeFire) silenceAfter else AlarmRing.FIRE_GRACE
|
||||
// Deleted, switched off, or a PendingIntent so stale the moment has gone:
|
||||
// re-resolve the slot and stay quiet.
|
||||
if (alarm == null || !alarm.enabled || now - scheduledFor > lateBy) {
|
||||
@@ -270,7 +278,7 @@ class AlarmEngine @Inject constructor(
|
||||
|
||||
states.save(states.state(alarmId).copy(ringingSince = now))
|
||||
ring.startRinging(alarmId)
|
||||
scheduler.scheduleAutoSilence(now + AlarmRing.AUTO_SILENCE_AFTER, alarmId)
|
||||
scheduler.scheduleAutoSilence(now + silenceAfter, alarmId)
|
||||
return FireOutcome.Ringing(alarmId)
|
||||
}
|
||||
|
||||
@@ -307,20 +315,33 @@ class AlarmEngine @Inject constructor(
|
||||
private suspend fun resumeRingingIfAnyLocked(): Long? {
|
||||
val state = states.ringing() ?: return null
|
||||
val since = state.ringingSince ?: return null
|
||||
val silenceAfter = settings.currentDefaults().alarmSilenceAfter
|
||||
// Slept through it: leave it to the next resolution to close the cycle.
|
||||
if (wallClock.now() - since >= AlarmRing.AUTO_SILENCE_AFTER) return null
|
||||
if (wallClock.now() - since >= silenceAfter) return null
|
||||
val alarm = alarms.find(state.alarmId)?.takeIf { it.enabled } ?: return null
|
||||
|
||||
ring.startRinging(alarm.id)
|
||||
// The backstop is measured from when the ring started, not from now, so
|
||||
// a reboot does not buy the alarm another ten minutes.
|
||||
scheduler.scheduleAutoSilence(since + AlarmRing.AUTO_SILENCE_AFTER, alarm.id)
|
||||
// a reboot does not buy the alarm a fresh window.
|
||||
scheduler.scheduleAutoSilence(since + silenceAfter, alarm.id)
|
||||
return alarm.id
|
||||
}
|
||||
|
||||
/** Everything the resolver asked to be written, and nothing it did not. */
|
||||
/**
|
||||
* Everything the resolver asked to be written, and nothing it did not —
|
||||
* plus stopping the sound of a ring it closed, which storage alone cannot do.
|
||||
*/
|
||||
private suspend fun persist(alarm: Alarm, before: AlarmRingState, resolution: AlarmResolution) {
|
||||
if (resolution.state != before) states.save(resolution.state)
|
||||
// The resolver closed a ring: its window ran out before the backstop
|
||||
// fired ("Silence after" was shortened mid-ring, or the clock jumped),
|
||||
// or the alarm was switched off while ringing. Storage now says it is
|
||||
// not ringing, so the sound stops with it rather than waiting for a
|
||||
// backstop registered against the old window.
|
||||
if (before.ringingSince != null && resolution.state.ringingSince == null) {
|
||||
ring.stopRinging(alarm.id)
|
||||
scheduler.cancelAutoSilence()
|
||||
}
|
||||
if (resolution.clearSkipFlag) alarms.setSkipNextOccurrence(alarm.id, false)
|
||||
// A transient alarm is deleted wherever a permanent one would be
|
||||
// disabled: the cycle has closed either way (M9 D10).
|
||||
|
||||
@@ -17,6 +17,7 @@ import de.jeanlucmakiola.clockula.alarm.AlarmIntents
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.RingPresentationPolicy
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.RingSession
|
||||
import de.jeanlucmakiola.clockula.domain.ring.RingFallbackPolicy
|
||||
import de.jeanlucmakiola.clockula.domain.ring.SilenceAfter
|
||||
import de.jeanlucmakiola.clockula.ring.RingAudioPlayer
|
||||
import de.jeanlucmakiola.clockula.ring.RingVibrator
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -25,6 +26,7 @@ import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
@@ -198,8 +200,11 @@ class AlarmRingService : Service() {
|
||||
private const val ACTION_REPOST = AlarmIntents.ACTION_DISMISS + ".SERVICE_REPOST"
|
||||
private const val WAKE_LOCK_TAG = "clockula:alarm-ring"
|
||||
|
||||
/** A backstop on the backstop: the ring window is ten minutes. */
|
||||
private const val WAKE_LOCK_TIMEOUT_MILLIS = 15L * 60L * 1_000L
|
||||
/**
|
||||
* A backstop on the backstop: past the longest ring window any "Silence
|
||||
* after" choice allows, so a ring is never cut short by its own wake lock.
|
||||
*/
|
||||
private val WAKE_LOCK_TIMEOUT_MILLIS = (SilenceAfter.HARD_STOP + 5.minutes).inWholeMilliseconds
|
||||
|
||||
fun start(context: Context) {
|
||||
context.startForegroundService(Intent(context, AlarmRingService::class.java))
|
||||
|
||||
@@ -127,6 +127,8 @@ object BackupCodec {
|
||||
timerRingtoneUri = defaults.timerRingtoneUri,
|
||||
dismissChallenge = defaults.dismissChallenge.name,
|
||||
homeZoneId = defaults.homeZoneId,
|
||||
alarmSilenceAfterMinutes = defaults.alarmSilenceAfterMinutes,
|
||||
timerSilenceAfterMinutes = defaults.timerSilenceAfterMinutes,
|
||||
timerPresets = timerPresets.map { it.inWholeMilliseconds },
|
||||
)
|
||||
|
||||
@@ -233,6 +235,9 @@ object BackupCodec {
|
||||
timerRingtoneUri = settingsDto.timerRingtoneUri?.takeIf { it.isNotBlank() },
|
||||
dismissChallenge = settingsDto.dismissChallenge.toEnum(DismissChallenge.NONE),
|
||||
homeZoneId = settingsDto.homeZoneId?.let(Zones::normalise),
|
||||
// Clamped on apply, by the preference, like every other default.
|
||||
alarmSilenceAfterMinutes = settingsDto.alarmSilenceAfterMinutes,
|
||||
timerSilenceAfterMinutes = settingsDto.timerSilenceAfterMinutes,
|
||||
),
|
||||
timerPresets = settingsDto.timerPresets?.map { it.milliseconds } ?: TimerPresets.BUILT_IN,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.clockula.data.backup
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.ring.SilenceAfter
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@@ -71,6 +72,12 @@ data class SettingsDto(
|
||||
@SerialName("default_timer_ringtone_uri") val timerRingtoneUri: String? = null,
|
||||
@SerialName("default_dismiss_challenge") val dismissChallenge: String = "NONE",
|
||||
@SerialName("home_zone_id") val homeZoneId: String? = null,
|
||||
/**
|
||||
* Minutes, or 0 for "Never". Absent in a file from before the setting
|
||||
* existed, which is v1's fixed ten minutes.
|
||||
*/
|
||||
@SerialName("default_alarm_silence_after_minutes") val alarmSilenceAfterMinutes: Int = SilenceAfter.DEFAULT_MINUTES,
|
||||
@SerialName("default_timer_silence_after_minutes") val timerSilenceAfterMinutes: Int = SilenceAfter.DEFAULT_MINUTES,
|
||||
/**
|
||||
* Absent is not empty: an empty list is a user who removed every preset,
|
||||
* while a file without the field (an older or hand-written one) keeps the
|
||||
|
||||
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.clockula.data.prefs
|
||||
import de.jeanlucmakiola.clockula.domain.ClockLimits
|
||||
import de.jeanlucmakiola.clockula.domain.DismissChallenge
|
||||
import de.jeanlucmakiola.clockula.domain.Zones
|
||||
import de.jeanlucmakiola.clockula.domain.ring.SilenceAfter
|
||||
import de.jeanlucmakiola.floret.prefs.Pref
|
||||
import de.jeanlucmakiola.floret.prefs.booleanPref
|
||||
import de.jeanlucmakiola.floret.prefs.enumPref
|
||||
@@ -31,6 +32,19 @@ object ClockPrefs {
|
||||
val dismissChallenge: Pref<DismissChallenge> =
|
||||
enumPref("default_dismiss_challenge", DismissChallenge.NONE)
|
||||
|
||||
/**
|
||||
* How long a ringing alarm, and a finished timer, keep going before they
|
||||
* stop on their own (#17): minutes, or [SilenceAfter.NEVER]. An absent key
|
||||
* reads as v1's fixed ten minutes, so an upgrade changes nothing.
|
||||
*/
|
||||
val alarmSilenceAfterMinutes: Pref<Int> =
|
||||
intPref("default_alarm_silence_after_minutes", SilenceAfter.DEFAULT_MINUTES)
|
||||
.clampedTo(ClockLimits.SILENCE_AFTER_MINUTES)
|
||||
|
||||
val timerSilenceAfterMinutes: Pref<Int> =
|
||||
intPref("default_timer_silence_after_minutes", SilenceAfter.DEFAULT_MINUTES)
|
||||
.clampedTo(ClockLimits.SILENCE_AFTER_MINUTES)
|
||||
|
||||
/** Normalised through [Zones] both ways, so a zone the device dropped reads as absent. */
|
||||
val homeZoneId: Pref<String?> = nullableStringPref("home_zone_id")
|
||||
.map(decode = Zones::normalise, encode = Zones::normalise)
|
||||
|
||||
@@ -62,14 +62,20 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
|
||||
dismissChallenge = dismissChallenge,
|
||||
)
|
||||
},
|
||||
combine(
|
||||
store.flow(ClockPrefs.alarmSilenceAfterMinutes),
|
||||
store.flow(ClockPrefs.timerSilenceAfterMinutes),
|
||||
) { alarmMinutes, timerMinutes -> alarmMinutes to timerMinutes },
|
||||
store.flow(ClockPrefs.alarmRingtoneUri),
|
||||
store.flow(ClockPrefs.timerRingtoneUri),
|
||||
store.flow(ClockPrefs.homeZoneId),
|
||||
) { partial, alarmRingtoneUri, timerRingtoneUri, homeZoneId ->
|
||||
) { partial, silenceAfter, alarmRingtoneUri, timerRingtoneUri, homeZoneId ->
|
||||
partial.copy(
|
||||
alarmRingtoneUri = alarmRingtoneUri,
|
||||
timerRingtoneUri = timerRingtoneUri,
|
||||
homeZoneId = homeZoneId,
|
||||
alarmSilenceAfterMinutes = silenceAfter.first,
|
||||
timerSilenceAfterMinutes = silenceAfter.second,
|
||||
)
|
||||
}.distinctUntilChanged()
|
||||
|
||||
@@ -92,6 +98,8 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
|
||||
prefs[ClockPrefs.timerRingtoneUri] = backup.defaults.timerRingtoneUri
|
||||
prefs[ClockPrefs.dismissChallenge] = backup.defaults.dismissChallenge
|
||||
prefs[ClockPrefs.homeZoneId] = backup.defaults.homeZoneId
|
||||
prefs[ClockPrefs.alarmSilenceAfterMinutes] = backup.defaults.alarmSilenceAfterMinutes
|
||||
prefs[ClockPrefs.timerSilenceAfterMinutes] = backup.defaults.timerSilenceAfterMinutes
|
||||
prefs[TimerPrefs.presets] = backup.timerPresets
|
||||
}
|
||||
}
|
||||
@@ -111,6 +119,12 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
|
||||
suspend fun setDismissChallenge(challenge: DismissChallenge) =
|
||||
store.set(ClockPrefs.dismissChallenge, challenge)
|
||||
|
||||
suspend fun setAlarmSilenceAfterMinutes(minutes: Int) =
|
||||
store.set(ClockPrefs.alarmSilenceAfterMinutes, minutes)
|
||||
|
||||
suspend fun setTimerSilenceAfterMinutes(minutes: Int) =
|
||||
store.set(ClockPrefs.timerSilenceAfterMinutes, minutes)
|
||||
|
||||
suspend fun setHomeZoneId(zoneId: String?) = store.set(ClockPrefs.homeZoneId, zoneId)
|
||||
|
||||
// --- timer presets (M6) ---
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
package de.jeanlucmakiola.clockula.domain
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.ring.SilenceAfter
|
||||
import kotlin.time.Duration
|
||||
|
||||
/**
|
||||
* The app-wide defaults an alarm or timer inherits when it has no override.
|
||||
* Backed by DataStore, edited in M10's settings screen.
|
||||
*
|
||||
* The two silence-after values are app-wide only: no alarm or timer overrides
|
||||
* them (#17). They are stored as [SilenceAfter] minutes; read them as a window
|
||||
* through [alarmSilenceAfter] and [timerSilenceAfter].
|
||||
*/
|
||||
data class ClockDefaults(
|
||||
val snoozeMinutes: Int = 10,
|
||||
@@ -13,7 +20,15 @@ data class ClockDefaults(
|
||||
val timerRingtoneUri: String? = null,
|
||||
val dismissChallenge: DismissChallenge = DismissChallenge.NONE,
|
||||
val homeZoneId: String? = null,
|
||||
)
|
||||
val alarmSilenceAfterMinutes: Int = SilenceAfter.DEFAULT_MINUTES,
|
||||
val timerSilenceAfterMinutes: Int = SilenceAfter.DEFAULT_MINUTES,
|
||||
) {
|
||||
/** How long a ringing alarm rings before it is silenced as missed. */
|
||||
val alarmSilenceAfter: Duration get() = SilenceAfter.window(alarmSilenceAfterMinutes)
|
||||
|
||||
/** How long a finished timer sounds before the noise stops. */
|
||||
val timerSilenceAfter: Duration get() = SilenceAfter.window(timerSilenceAfterMinutes)
|
||||
}
|
||||
|
||||
/**
|
||||
* The ranges a user can choose from, for the defaults and for an alarm's own
|
||||
@@ -24,4 +39,5 @@ object ClockLimits {
|
||||
val SNOOZE_MINUTES: IntRange = 1..60
|
||||
val SNOOZE_LIMIT: IntRange = 0..10
|
||||
val VOLUME_RAMP_SECONDS: IntRange = 0..60
|
||||
val SILENCE_AFTER_MINUTES: IntRange = SilenceAfter.MINUTES
|
||||
}
|
||||
@@ -27,6 +27,10 @@ data class AlarmResolution(
|
||||
*
|
||||
* The precedence is fixed and total: disabled, then ringing, then snoozed, then
|
||||
* the natural occurrence.
|
||||
*
|
||||
* `autoSilenceAfter` is the "Silence after" setting's window
|
||||
* (`ClockDefaults.alarmSilenceAfter`). It has no default, so no caller can
|
||||
* measure a ring against a window the user did not choose.
|
||||
*/
|
||||
object AlarmResolver {
|
||||
fun resolve(
|
||||
@@ -34,7 +38,7 @@ object AlarmResolver {
|
||||
state: AlarmRingState,
|
||||
now: Instant,
|
||||
zone: ZoneId,
|
||||
autoSilenceAfter: Duration = AlarmRing.AUTO_SILENCE_AFTER,
|
||||
autoSilenceAfter: Duration,
|
||||
fireGrace: Duration = AlarmRing.FIRE_GRACE,
|
||||
): AlarmResolution {
|
||||
// 1. Disabled: every volatile field goes, except the dismissal watermark
|
||||
|
||||
@@ -4,17 +4,13 @@ import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
/**
|
||||
* The alarm ring cycle's constants. They are values rather than settings: v1
|
||||
* does not offer them to the user (docs/PLAN.md §4). The audio chain's own
|
||||
* constants live in `domain/ring/RingAudio`, shared with the timer's ring.
|
||||
* The alarm ring cycle's constants. They are values rather than settings
|
||||
* (docs/PLAN.md §4). How long an alarm rings before it is silenced as missed
|
||||
* became a setting in #17: `ClockDefaults.alarmSilenceAfter`. The audio
|
||||
* chain's own constants live in `domain/ring/RingAudio`, shared with the
|
||||
* timer's ring.
|
||||
*/
|
||||
object AlarmRing {
|
||||
/**
|
||||
* How long an undismissed alarm rings before it is silenced as missed.
|
||||
* AOSP DeskClock's default, and the window a resumed ring is measured against.
|
||||
*/
|
||||
val AUTO_SILENCE_AFTER: Duration = 10.minutes
|
||||
|
||||
/**
|
||||
* How late a fire may arrive and still count. A fire delayed by doze, a slow
|
||||
* boot or a `TIME_SET` nudge still rings; one missed by hours does not.
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package de.jeanlucmakiola.clockula.domain.ring
|
||||
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
/**
|
||||
* How long a ring keeps going before it stops on its own: the "Silence after"
|
||||
* setting, one for alarms and one for timers (#17). Stored as whole minutes,
|
||||
* with [NEVER] for the option that has no user-facing limit.
|
||||
*
|
||||
* "Never" is not unbounded. The auto-silence backstop is also what stops a
|
||||
* phone left at home from ringing until its battery is flat, and what keeps a
|
||||
* reboot from resuming a ring that started hours ago, so "Never" rings until
|
||||
* [HARD_STOP] — longer than any finite choice — and then stops as any other
|
||||
* window would.
|
||||
*/
|
||||
object SilenceAfter {
|
||||
/** The stored value for "Never". Zero, because no window can be zero minutes long. */
|
||||
const val NEVER: Int = 0
|
||||
|
||||
/**
|
||||
* Everything a stored value is clamped into, on read, on write and from a
|
||||
* backup. Any value in it means something: [NEVER], or that many minutes.
|
||||
*/
|
||||
val MINUTES: IntRange = NEVER..60
|
||||
|
||||
/**
|
||||
* The longest a ring ever lasts, and what "Never" means in practice. The
|
||||
* settings hints say "an hour" in words; change them with it.
|
||||
*/
|
||||
val HARD_STOP: Duration = MINUTES.last.minutes
|
||||
|
||||
/** The default, for alarms and timers alike: AOSP DeskClock's, and v1's fixed value. */
|
||||
const val DEFAULT_MINUTES: Int = 10
|
||||
|
||||
/** The choices the pickers offer, in order: Google Clock's, so nothing here surprises. */
|
||||
val CHOICES: List<Int> = listOf(1, 5, 10, 15, 20, 25, NEVER)
|
||||
|
||||
/** The ring window [minutes] stands for, clamped first so the engines never see an unofferable value. */
|
||||
fun window(minutes: Int): Duration {
|
||||
val clamped = minutes.coerceIn(MINUTES)
|
||||
return if (clamped == NEVER) HARD_STOP else clamped.minutes
|
||||
}
|
||||
}
|
||||
@@ -5,17 +5,13 @@ import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
/**
|
||||
* The timer ring's constants. Values rather than settings: v1 does not offer
|
||||
* them to the user (docs/PLAN.md §5, slice plan D11).
|
||||
* The timer ring's constants. Values rather than settings (docs/PLAN.md §5,
|
||||
* slice plan D11). How long an unacknowledged timer sounds before the noise
|
||||
* stops became a setting in #17, `ClockDefaults.timerSilenceAfter`: its own
|
||||
* rather than the alarm's, because coupling the two would make one number
|
||||
* answer two questions.
|
||||
*/
|
||||
object TimerRing {
|
||||
/**
|
||||
* How long an unacknowledged timer sounds before the noise stops (D11).
|
||||
* The same value as the alarm's and its own constant, free to diverge:
|
||||
* coupling the two would make one number answer two questions.
|
||||
*/
|
||||
val AUTO_SILENCE_AFTER: Duration = 10.minutes
|
||||
|
||||
/** What "+1 min" grants (D12). */
|
||||
val ADD_TIME: Duration = 1.minutes
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ sealed interface TimerRingDecision {
|
||||
data class Sound(
|
||||
val subjectId: Long,
|
||||
val settings: TimerSettings,
|
||||
/** When the window closes: the session anchor + [TimerRing.AUTO_SILENCE_AFTER]. */
|
||||
/** When the window closes: the session anchor + [ClockDefaults.timerSilenceAfter]. */
|
||||
val silenceAt: Instant,
|
||||
) : TimerRingDecision
|
||||
}
|
||||
@@ -49,7 +49,8 @@ object TimerRingPolicy {
|
||||
|
||||
// Auto-silence before suppression: a window that has run out stays run
|
||||
// out, and an alarm ringing over it must not hand it a fresh one.
|
||||
if (soundingSince != null && now - soundingSince >= TimerRing.AUTO_SILENCE_AFTER) {
|
||||
val window = defaults.timerSilenceAfter
|
||||
if (soundingSince != null && now - soundingSince >= window) {
|
||||
return TimerRingOutcome(
|
||||
TimerRingDecision.AutoSilenced(subject.timer.id),
|
||||
soundingSince = soundingSince,
|
||||
@@ -67,13 +68,13 @@ object TimerRingPolicy {
|
||||
|
||||
// The anchor is the instant the audio *opened*, so a second timer
|
||||
// expiring mid-session inherits the window rather than buying a fresh
|
||||
// ten minutes (D10).
|
||||
// one (D10).
|
||||
val anchor = soundingSince ?: now
|
||||
return TimerRingOutcome(
|
||||
TimerRingDecision.Sound(
|
||||
subjectId = subject.timer.id,
|
||||
settings = subject.timer.resolveSettings(defaults),
|
||||
silenceAt = anchor + TimerRing.AUTO_SILENCE_AFTER,
|
||||
silenceAt = anchor + window,
|
||||
),
|
||||
soundingSince = anchor,
|
||||
)
|
||||
|
||||
@@ -266,7 +266,8 @@ class TimerEngine @Inject constructor(
|
||||
*/
|
||||
private suspend fun dropLapsedSessionLocked() {
|
||||
val anchor = ringState.current() ?: return
|
||||
if (wall.now() - anchor >= TimerRing.AUTO_SILENCE_AFTER) ringState.set(null)
|
||||
val window = settings.currentDefaults().timerSilenceAfter
|
||||
if (wall.now() - anchor >= window) ringState.set(null)
|
||||
}
|
||||
|
||||
private suspend fun serviceStateLocked(alreadyAlerted: Boolean): TimerServiceState {
|
||||
|
||||
@@ -14,6 +14,7 @@ import de.jeanlucmakiola.clockula.alarm.AlarmCapabilities
|
||||
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.ring.SilenceAfter
|
||||
import de.jeanlucmakiola.clockula.domain.timer.TimerNotificationState
|
||||
import de.jeanlucmakiola.clockula.domain.timer.TimerMode
|
||||
import de.jeanlucmakiola.clockula.domain.timer.TimerRingDecision
|
||||
@@ -34,6 +35,7 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -363,8 +365,11 @@ class TimerService : Service() {
|
||||
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
|
||||
/**
|
||||
* A backstop on the backstop: past the longest ring window any "Silence
|
||||
* after" choice allows, so a ring is never cut short by its own wake lock.
|
||||
*/
|
||||
private val WAKE_LOCK_TIMEOUT_MILLIS = (SilenceAfter.HARD_STOP + 5.minutes).inWholeMilliseconds
|
||||
|
||||
fun start(context: Context) {
|
||||
context.startForegroundService(Intent(context, TimerService::class.java))
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import de.jeanlucmakiola.clockula.R
|
||||
import de.jeanlucmakiola.clockula.domain.DismissChallenge
|
||||
import de.jeanlucmakiola.clockula.domain.ring.SilenceAfter
|
||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||
|
||||
/**
|
||||
@@ -67,6 +68,35 @@ internal fun challengeText(challenge: DismissChallenge): String = stringResource
|
||||
},
|
||||
)
|
||||
|
||||
/** [SilenceAfter.NEVER] is "Never" in words, never "0 minutes". */
|
||||
@Composable
|
||||
internal fun silenceAfterText(minutes: Int): String = if (minutes == SilenceAfter.NEVER) {
|
||||
stringResource(R.string.silence_after_never)
|
||||
} else {
|
||||
pluralStringResource(R.plurals.silence_after_minutes, minutes, minutes)
|
||||
}
|
||||
|
||||
/**
|
||||
* "Silence after" is app-wide only (#17): no alarm or timer overrides it, so
|
||||
* this picker never offers "App default". It shares the overrides' shape so
|
||||
* the settings screens' pickers all look and behave alike.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SilenceAfterPicker(
|
||||
selected: Int,
|
||||
onSelect: (Int) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) = OverridePicker(
|
||||
title = stringResource(R.string.settings_silence_after),
|
||||
options = SilenceAfter.CHOICES,
|
||||
stored = selected,
|
||||
default = selected,
|
||||
text = { silenceAfterText(it) },
|
||||
onSelect = { it?.let(onSelect) },
|
||||
onDismiss = onDismiss,
|
||||
includeAppDefault = false,
|
||||
)
|
||||
|
||||
@Composable
|
||||
internal fun VibratePicker(
|
||||
stored: Boolean?,
|
||||
|
||||
@@ -3,18 +3,19 @@ package de.jeanlucmakiola.clockula.ui.ring
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
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.time.ElapsedRealtimeClock
|
||||
import de.jeanlucmakiola.clockula.domain.time.Ticker
|
||||
import de.jeanlucmakiola.clockula.domain.time.WallClock
|
||||
import de.jeanlucmakiola.clockula.domain.timer.TimerReadings
|
||||
import de.jeanlucmakiola.clockula.domain.timer.TimerRing
|
||||
import de.jeanlucmakiola.clockula.timer.TimerEngine
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
@@ -29,6 +30,7 @@ import javax.inject.Inject
|
||||
class TimerRingViewModel @Inject constructor(
|
||||
timers: TimerRepository,
|
||||
ringState: TimerRingStateStore,
|
||||
settings: SettingsPrefs,
|
||||
private val engine: TimerEngine,
|
||||
elapsed: ElapsedRealtimeClock,
|
||||
wall: WallClock,
|
||||
@@ -41,7 +43,12 @@ class TimerRingViewModel @Inject constructor(
|
||||
* the auto-silence window ends without anything being written at all.
|
||||
*/
|
||||
val state: StateFlow<TimerRingUiState> =
|
||||
combine(timers.timers(), ringState.soundingSince, ticker.ticks(TICK)) { rows, soundingSince, _ ->
|
||||
combine(
|
||||
timers.timers(),
|
||||
ringState.soundingSince,
|
||||
settings.defaults.map { it.timerSilenceAfter }.distinctUntilChanged(),
|
||||
ticker.ticks(TICK),
|
||||
) { rows, soundingSince, silenceAfter, _ ->
|
||||
val now = wall.now()
|
||||
val expired = TimerReadings.expired(
|
||||
TimerReadings.active(rows, elapsed.elapsedRealtime(), now),
|
||||
@@ -49,7 +56,7 @@ class TimerRingViewModel @Inject constructor(
|
||||
val subject = expired.firstOrNull()
|
||||
// Auto-silence leaves the rows EXPIRED (D11) but ends the ring, and a
|
||||
// screen held on over a silent timer is only a battery drain.
|
||||
val silenced = soundingSince != null && now - soundingSince >= TimerRing.AUTO_SILENCE_AFTER
|
||||
val silenced = soundingSince != null && now - soundingSince >= silenceAfter
|
||||
if (subject == null || silenced) {
|
||||
TimerRingUiState.Finished
|
||||
} else {
|
||||
|
||||
@@ -12,11 +12,13 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.clockula.R
|
||||
import de.jeanlucmakiola.clockula.ui.alarms.DismissChallengePicker
|
||||
import de.jeanlucmakiola.clockula.ui.alarms.SilenceAfterPicker
|
||||
import de.jeanlucmakiola.clockula.ui.alarms.SnoozeLengthPicker
|
||||
import de.jeanlucmakiola.clockula.ui.alarms.SnoozeLimitPicker
|
||||
import de.jeanlucmakiola.clockula.ui.alarms.VibratePicker
|
||||
import de.jeanlucmakiola.clockula.ui.alarms.VolumeRampPicker
|
||||
import de.jeanlucmakiola.clockula.ui.alarms.challengeText
|
||||
import de.jeanlucmakiola.clockula.ui.alarms.silenceAfterText
|
||||
import de.jeanlucmakiola.clockula.ui.alarms.snoozeLimitText
|
||||
import de.jeanlucmakiola.clockula.ui.alarms.snoozeMinutesText
|
||||
import de.jeanlucmakiola.clockula.ui.alarms.vibrateText
|
||||
@@ -26,11 +28,12 @@ import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
|
||||
/** Which of the alarm-default pickers is open. Mirrors [de.jeanlucmakiola.clockula.ui.alarms.AlarmEditorScreen]'s shape. */
|
||||
private enum class OpenAlarmPicker { VIBRATE, VOLUME_RAMP, SNOOZE_LENGTH, SNOOZE_LIMIT, CHALLENGE }
|
||||
private enum class OpenAlarmPicker { VIBRATE, VOLUME_RAMP, SNOOZE_LENGTH, SNOOZE_LIMIT, CHALLENGE, SILENCE_AFTER }
|
||||
|
||||
/**
|
||||
* What every new alarm starts with: how it sounds, how it snoozes, and how it
|
||||
* is dismissed. The ringtone picker itself is ViewModel state, drawn by the hub.
|
||||
* is dismissed; then how long any alarm rings, which no alarm overrides (#17).
|
||||
* The ringtone picker itself is ViewModel state, drawn by the hub.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AlarmDefaultsScreen(
|
||||
@@ -85,6 +88,17 @@ internal fun AlarmDefaultsScreen(
|
||||
position = Position.Alone,
|
||||
onClick = { openPicker = OpenAlarmPicker.CHALLENGE },
|
||||
)
|
||||
|
||||
// Its own group with its own hint: unlike everything above, this one is
|
||||
// not a default an alarm can change in its editor.
|
||||
Spacer(Modifier.height(24.dp))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_silence_after),
|
||||
summary = silenceAfterText(defaults.alarmSilenceAfterMinutes),
|
||||
position = Position.Alone,
|
||||
onClick = { openPicker = OpenAlarmPicker.SILENCE_AFTER },
|
||||
)
|
||||
SettingsHint(stringResource(R.string.settings_alarm_silence_after_hint))
|
||||
}
|
||||
|
||||
when (openPicker) {
|
||||
@@ -124,5 +138,10 @@ internal fun AlarmDefaultsScreen(
|
||||
onSelect = { viewModel.onDismissChallengeChanged(it ?: defaults.dismissChallenge) },
|
||||
onDismiss = { openPicker = null },
|
||||
)
|
||||
OpenAlarmPicker.SILENCE_AFTER -> SilenceAfterPicker(
|
||||
selected = defaults.alarmSilenceAfterMinutes,
|
||||
onSelect = { viewModel.onAlarmSilenceAfterChanged(it) },
|
||||
onDismiss = { openPicker = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -142,6 +142,12 @@ class SettingsViewModel @Inject internal constructor(
|
||||
fun onDismissChallengeChanged(value: DismissChallenge) =
|
||||
viewModelScope.launch { settings.setDismissChallenge(value) }
|
||||
|
||||
fun onAlarmSilenceAfterChanged(minutes: Int) =
|
||||
viewModelScope.launch { settings.setAlarmSilenceAfterMinutes(minutes) }
|
||||
|
||||
fun onTimerSilenceAfterChanged(minutes: Int) =
|
||||
viewModelScope.launch { settings.setTimerSilenceAfterMinutes(minutes) }
|
||||
|
||||
// --- the default ringtone pickers ---
|
||||
|
||||
internal fun onOpenRingtonePicker(kind: DefaultRingtoneKind) {
|
||||
|
||||
@@ -3,21 +3,29 @@ package de.jeanlucmakiola.clockula.ui.settings
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.clockula.R
|
||||
import de.jeanlucmakiola.clockula.ui.alarms.SilenceAfterPicker
|
||||
import de.jeanlucmakiola.clockula.ui.alarms.silenceAfterText
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
|
||||
/** What a new timer starts with: its sound. */
|
||||
/** What a new timer starts with, its sound; then how long any timer sounds (#17). */
|
||||
@Composable
|
||||
internal fun TimerDefaultsScreen(
|
||||
state: SettingsUiState.Loaded,
|
||||
viewModel: SettingsViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
var silenceAfterOpen by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
CollapsingScaffold(title = stringResource(R.string.settings_section_timers), onBack = onBack) {
|
||||
SettingsHint(stringResource(R.string.settings_timer_sound_hint))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
@@ -28,5 +36,22 @@ internal fun TimerDefaultsScreen(
|
||||
position = Position.Alone,
|
||||
onClick = { viewModel.onOpenRingtonePicker(DefaultRingtoneKind.TIMER) },
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_silence_after),
|
||||
summary = silenceAfterText(state.defaults.timerSilenceAfterMinutes),
|
||||
position = Position.Alone,
|
||||
onClick = { silenceAfterOpen = true },
|
||||
)
|
||||
SettingsHint(stringResource(R.string.settings_timer_silence_after_hint))
|
||||
}
|
||||
|
||||
if (silenceAfterOpen) {
|
||||
SilenceAfterPicker(
|
||||
selected = state.defaults.timerSilenceAfterMinutes,
|
||||
onSelect = { viewModel.onTimerSilenceAfterChanged(it) },
|
||||
onDismiss = { silenceAfterOpen = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -164,6 +164,16 @@
|
||||
<string name="challenge_math">Solve a sum</string>
|
||||
<string name="challenge_hold">Press and hold</string>
|
||||
|
||||
<!-- #17: how long a ring lasts before it stops on its own -->
|
||||
<string name="settings_silence_after">Silence after</string>
|
||||
<string name="silence_after_never">Never</string>
|
||||
<plurals name="silence_after_minutes">
|
||||
<item quantity="one">%1$d minute</item>
|
||||
<item quantity="other">%1$d minutes</item>
|
||||
</plurals>
|
||||
<string name="settings_alarm_silence_after_hint">Applies to every alarm. An alarm nobody stops goes quiet after this long and counts as missed. Never still stops after an hour, so a phone left at home can\'t ring until its battery runs out.</string>
|
||||
<string name="settings_timer_silence_after_hint">Applies to every timer. A finished timer nobody stops goes quiet after this long but stays finished, so you can still add a minute. Never still stops after an hour.</string>
|
||||
|
||||
<!-- M5: the ringtone picker -->
|
||||
<string name="ringtone_picker_title">Alarm sound</string>
|
||||
<string name="ringtone_silent">Silent</string>
|
||||
|
||||
@@ -2,8 +2,8 @@ package de.jeanlucmakiola.clockula.alarm
|
||||
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.clockula.domain.ClockDefaults
|
||||
import de.jeanlucmakiola.clockula.domain.RepeatDays
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.AlarmRing
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.DismissOutcome
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.SnoozeOutcome
|
||||
import de.jeanlucmakiola.clockula.testing.BERLIN
|
||||
@@ -246,7 +246,7 @@ class AlarmEngineInteropTest {
|
||||
harness.givenAlarm(id = 1L, hour = 7, deleteAfterUse = true)
|
||||
harness.engine.reschedule()
|
||||
harness.givenRinging(alarmId = 1L, at = sevenToday)
|
||||
harness.wallClock.instant = sevenToday + AlarmRing.AUTO_SILENCE_AFTER
|
||||
harness.wallClock.instant = sevenToday + ClockDefaults().alarmSilenceAfter
|
||||
|
||||
harness.engine.onAutoSilence(1L)
|
||||
|
||||
|
||||
@@ -10,15 +10,16 @@ import de.jeanlucmakiola.clockula.data.alarms.AlarmStateRepositoryImpl
|
||||
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.clockula.domain.Alarm
|
||||
import de.jeanlucmakiola.clockula.domain.AlarmDraft
|
||||
import de.jeanlucmakiola.clockula.domain.ClockDefaults
|
||||
import de.jeanlucmakiola.clockula.domain.RepeatDays
|
||||
import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.AlarmRing
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.AlarmRingState
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.FireOutcome
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.FireSource
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.ScheduleMode
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.SnoozeOutcome
|
||||
import de.jeanlucmakiola.clockula.domain.resolveSettings
|
||||
import de.jeanlucmakiola.clockula.domain.ring.SilenceAfter
|
||||
import de.jeanlucmakiola.clockula.testing.BERLIN
|
||||
import de.jeanlucmakiola.clockula.testing.FakeAlarmDao
|
||||
import de.jeanlucmakiola.clockula.testing.FakeAlarmNotifier
|
||||
@@ -274,7 +275,7 @@ class AlarmEngineTest {
|
||||
assertThat(states.state(1L).ringingSince).isEqualTo(sevenToday)
|
||||
assertThat(states.state(1L).handledOccurrence).isEqualTo(sevenToday)
|
||||
assertThat(scheduler.autoSilence)
|
||||
.isEqualTo(Registration(sevenToday + AlarmRing.AUTO_SILENCE_AFTER, 1L))
|
||||
.isEqualTo(Registration(sevenToday + ClockDefaults().alarmSilenceAfter, 1L))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -680,7 +681,7 @@ class AlarmEngineTest {
|
||||
|
||||
assertThat(ring.ringing.value).isEqualTo(1L)
|
||||
assertThat(scheduler.autoSilence)
|
||||
.isEqualTo(Registration(sevenToday + AlarmRing.AUTO_SILENCE_AFTER, 1L))
|
||||
.isEqualTo(Registration(sevenToday + ClockDefaults().alarmSilenceAfter, 1L))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -692,7 +693,8 @@ class AlarmEngineTest {
|
||||
|
||||
engine.onBootCompleted()
|
||||
|
||||
assertThat(ring.events).isEmpty()
|
||||
// Closing the stale ring stops whatever might still sound; nothing starts.
|
||||
assertThat(ring.events).containsExactly(RingEvent.Stopped(1L))
|
||||
assertThat(states.state(1L).ringingSince).isNull()
|
||||
assertThat(scheduler.next?.fireAt).isEqualTo(sevenTomorrow)
|
||||
}
|
||||
@@ -798,6 +800,117 @@ class AlarmEngineTest {
|
||||
assertThat(scheduler.next).isEqualTo(Registration(sevenToday, 1L))
|
||||
}
|
||||
|
||||
// --- #17: the ring window is the "Silence after" setting ---
|
||||
|
||||
@Test
|
||||
fun `the backstop is armed at the chosen silence-after, not ten minutes`(@TempDir dir: Path) = runTest {
|
||||
givenAlarm(repeatDays = RepeatDays.EVERY_DAY)
|
||||
wallClock.instant = sevenToday
|
||||
val settings = settings(dir)
|
||||
settings.setAlarmSilenceAfterMinutes(25)
|
||||
val engine = engineOver(settings)
|
||||
|
||||
engine.onFire(alarmId = 1L, scheduledFor = sevenToday)
|
||||
|
||||
assertThat(scheduler.autoSilence).isEqualTo(Registration(sevenToday + 25.minutes, 1L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Never arms the backstop at the hard stop`(@TempDir dir: Path) = runTest {
|
||||
givenAlarm(repeatDays = RepeatDays.EVERY_DAY)
|
||||
wallClock.instant = sevenToday
|
||||
val settings = settings(dir)
|
||||
settings.setAlarmSilenceAfterMinutes(SilenceAfter.NEVER)
|
||||
val engine = engineOver(settings)
|
||||
|
||||
engine.onFire(alarmId = 1L, scheduledFor = sevenToday)
|
||||
|
||||
assertThat(scheduler.autoSilence).isEqualTo(Registration(sevenToday + SilenceAfter.HARD_STOP, 1L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a reschedule inside a longer window keeps the ring going`(@TempDir dir: Path) = runTest {
|
||||
givenAlarm(repeatDays = RepeatDays.EVERY_DAY)
|
||||
wallClock.instant = sevenToday
|
||||
val settings = settings(dir)
|
||||
settings.setAlarmSilenceAfterMinutes(25)
|
||||
val engine = engineOver(settings)
|
||||
engine.onFire(alarmId = 1L, scheduledFor = sevenToday)
|
||||
wallClock.instant = sevenToday + 20.minutes
|
||||
|
||||
engine.reschedule()
|
||||
|
||||
assertThat(ring.ringing.value).isEqualTo(1L)
|
||||
assertThat(states.state(1L).ringingSince).isEqualTo(sevenToday)
|
||||
assertThat(scheduler.autoSilence).isEqualTo(Registration(sevenToday + 25.minutes, 1L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shortening silence-after mid-ring stops a ring already past the new window`(@TempDir dir: Path) =
|
||||
runTest {
|
||||
givenAlarm(repeatDays = RepeatDays.EVERY_DAY)
|
||||
wallClock.instant = sevenToday
|
||||
val settings = settings(dir)
|
||||
val engine = engineOver(settings)
|
||||
engine.onFire(alarmId = 1L, scheduledFor = sevenToday)
|
||||
wallClock.instant = sevenToday + 5.minutes
|
||||
|
||||
settings.setAlarmSilenceAfterMinutes(1)
|
||||
engine.reschedule()
|
||||
|
||||
assertThat(ring.ringing.value).isNull()
|
||||
assertThat(ring.events.last()).isEqualTo(RingEvent.Stopped(1L))
|
||||
assertThat(states.state(1L).ringingSince).isNull()
|
||||
assertThat(scheduler.autoSilence).isNull()
|
||||
assertThat(scheduler.next?.fireAt).isEqualTo(sevenTomorrow)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a ring interrupted by a reboot resumes inside a longer window`(@TempDir dir: Path) = runTest {
|
||||
givenAlarm(repeatDays = RepeatDays.EVERY_DAY)
|
||||
wallClock.instant = sevenToday + 20.minutes
|
||||
givenRingingAt(alarmId = 1L, occurrence = sevenToday, since = sevenToday)
|
||||
val settings = settings(dir)
|
||||
settings.setAlarmSilenceAfterMinutes(25)
|
||||
val engine = engineOver(settings)
|
||||
|
||||
engine.onBootCompleted()
|
||||
|
||||
assertThat(ring.ringing.value).isEqualTo(1L)
|
||||
assertThat(scheduler.autoSilence).isEqualTo(Registration(sevenToday + 25.minutes, 1L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a late snooze is kept for as long as the chosen window`(@TempDir dir: Path) = runTest {
|
||||
givenAlarm(repeatDays = RepeatDays.EVERY_DAY)
|
||||
val snoozeAt = sevenToday + 9.minutes
|
||||
states.save(
|
||||
ringState(snoozedUntil = snoozeAt, snoozeCount = 1, handledOccurrence = sevenToday),
|
||||
)
|
||||
wallClock.instant = snoozeAt + 20.minutes
|
||||
val settings = settings(dir)
|
||||
settings.setAlarmSilenceAfterMinutes(25)
|
||||
val engine = engineOver(settings)
|
||||
|
||||
val outcome = engine.onFire(alarmId = 1L, scheduledFor = snoozeAt)
|
||||
|
||||
assertThat(outcome).isEqualTo(FireOutcome.Ringing(1L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `upcoming measures a ring against the chosen window`(@TempDir dir: Path) = runTest {
|
||||
givenAlarm(repeatDays = RepeatDays.EVERY_DAY)
|
||||
wallClock.instant = sevenToday + 20.minutes
|
||||
givenRingingAt(alarmId = 1L, occurrence = sevenToday, since = sevenToday)
|
||||
val settings = settings(dir)
|
||||
settings.setAlarmSilenceAfterMinutes(25)
|
||||
val engine = engineOver(settings)
|
||||
|
||||
val entry = engine.upcoming().first().single()
|
||||
|
||||
assertThat(entry.source).isEqualTo(FireSource.RESUMED_RING)
|
||||
}
|
||||
|
||||
// --- upcoming() ---
|
||||
|
||||
@Test
|
||||
@@ -886,7 +999,7 @@ class AlarmEngineTest {
|
||||
assertThat(ring.ringing.value).isEqualTo(2L)
|
||||
assertThat(states.state(2L).ringingSince).isEqualTo(eightToday)
|
||||
assertThat(scheduler.autoSilence)
|
||||
.isEqualTo(Registration(eightToday + AlarmRing.AUTO_SILENCE_AFTER, 2L))
|
||||
.isEqualTo(Registration(eightToday + ClockDefaults().alarmSilenceAfter, 2L))
|
||||
assertThat(states.state(1L).snoozedUntil).isNull()
|
||||
assertThat(notifier.cleared).contains(1L)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import de.jeanlucmakiola.clockula.domain.backup.BackupContent
|
||||
import de.jeanlucmakiola.clockula.domain.backup.BackupSettings
|
||||
import de.jeanlucmakiola.clockula.domain.backup.BackupTimer
|
||||
import de.jeanlucmakiola.clockula.domain.backup.BackupWorldClock
|
||||
import de.jeanlucmakiola.clockula.domain.ring.SilenceAfter
|
||||
import de.jeanlucmakiola.clockula.domain.timer.TimerPresets
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import org.junit.jupiter.api.Test
|
||||
@@ -60,6 +61,8 @@ class BackupCodecTest {
|
||||
timerRingtoneUri = "content://media/2",
|
||||
dismissChallenge = DismissChallenge.HOLD,
|
||||
homeZoneId = "America/New_York",
|
||||
alarmSilenceAfterMinutes = 25,
|
||||
timerSilenceAfterMinutes = SilenceAfter.NEVER,
|
||||
),
|
||||
timerPresets = listOf(1.minutes, 2.minutes, 10.minutes),
|
||||
),
|
||||
@@ -263,6 +266,20 @@ class BackupCodecTest {
|
||||
assertThat(settings.timerPresets).isEqualTo(TimerPresets.BUILT_IN)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a file from before silence after keeps the ten minutes it was made with`() {
|
||||
val defaults = (BackupCodec.decode(minimalDocument()) as BackupResult.Success).content.settings.defaults
|
||||
assertThat(defaults.alarmSilenceAfterMinutes).isEqualTo(10)
|
||||
assertThat(defaults.timerSilenceAfterMinutes).isEqualTo(10)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `silence after is written under its documented keys`() {
|
||||
val json = BackupCodec.encode(sampleContent())
|
||||
assertThat(json).contains("\"default_alarm_silence_after_minutes\": 25")
|
||||
assertThat(json).contains("\"default_timer_silence_after_minutes\": 0")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty preset list is honoured as all removed`() {
|
||||
val json = minimalDocument().replace("\"settings\": {}", "\"settings\": {\"timer_presets\": []}")
|
||||
|
||||
@@ -9,6 +9,7 @@ import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.clockula.domain.ClockDefaults
|
||||
import de.jeanlucmakiola.clockula.domain.DismissChallenge
|
||||
import de.jeanlucmakiola.clockula.domain.ring.SilenceAfter
|
||||
import de.jeanlucmakiola.floret.prefs.PrefStore
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -53,6 +54,8 @@ class ClockPrefsTest {
|
||||
prefs.setTimerRingtoneUri("content://t")
|
||||
prefs.setDismissChallenge(DismissChallenge.HOLD)
|
||||
prefs.setHomeZoneId("Europe/Berlin")
|
||||
prefs.setAlarmSilenceAfterMinutes(25)
|
||||
prefs.setTimerSilenceAfterMinutes(SilenceAfter.NEVER)
|
||||
|
||||
assertThat(prefs.defaults.first()).isEqualTo(
|
||||
ClockDefaults(
|
||||
@@ -64,6 +67,8 @@ class ClockPrefsTest {
|
||||
timerRingtoneUri = "content://t",
|
||||
dismissChallenge = DismissChallenge.HOLD,
|
||||
homeZoneId = "Europe/Berlin",
|
||||
alarmSilenceAfterMinutes = 25,
|
||||
timerSilenceAfterMinutes = SilenceAfter.NEVER,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -148,4 +153,21 @@ class ClockPrefsTest {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `silence after clamps on write and on read`(@TempDir tempDir: Path) = runTest {
|
||||
val dataStore = newDataStore(tempDir)
|
||||
val prefs = SettingsPrefs(PrefStore(dataStore))
|
||||
|
||||
prefs.setAlarmSilenceAfterMinutes(999)
|
||||
prefs.setTimerSilenceAfterMinutes(-3)
|
||||
assertThat(prefs.defaults.first().alarmSilenceAfterMinutes).isEqualTo(60)
|
||||
assertThat(prefs.defaults.first().timerSilenceAfterMinutes).isEqualTo(SilenceAfter.NEVER)
|
||||
|
||||
dataStore.updateData { stored ->
|
||||
stored.toMutablePreferences()
|
||||
.apply { this[intPreferencesKey("default_alarm_silence_after_minutes")] = 5_000 }
|
||||
}
|
||||
assertThat(prefs.defaults.first().alarmSilenceAfter).isEqualTo(SilenceAfter.HARD_STOP)
|
||||
}
|
||||
}
|
||||
@@ -74,7 +74,13 @@ class SettingsPrefsTest {
|
||||
BackupSettings(
|
||||
themeMode = ThemeMode.DARK,
|
||||
dynamicColor = false,
|
||||
defaults = ClockDefaults(snoozeMinutes = 99, volumeRampSeconds = 99, alarmRingtoneUri = " "),
|
||||
defaults = ClockDefaults(
|
||||
snoozeMinutes = 99,
|
||||
volumeRampSeconds = 99,
|
||||
alarmRingtoneUri = " ",
|
||||
alarmSilenceAfterMinutes = 25,
|
||||
timerSilenceAfterMinutes = 999,
|
||||
),
|
||||
timerPresets = listOf(5.minutes, 1.minutes, 5.minutes),
|
||||
),
|
||||
)
|
||||
@@ -84,6 +90,8 @@ class SettingsPrefsTest {
|
||||
assertThat(prefs.currentDefaults().snoozeMinutes).isEqualTo(60)
|
||||
assertThat(prefs.currentDefaults().volumeRampSeconds).isEqualTo(60)
|
||||
assertThat(prefs.currentDefaults().alarmRingtoneUri).isNull()
|
||||
assertThat(prefs.currentDefaults().alarmSilenceAfterMinutes).isEqualTo(25)
|
||||
assertThat(prefs.currentDefaults().timerSilenceAfterMinutes).isEqualTo(60)
|
||||
assertThat(prefs.timerPresets.first()).containsExactly(1.minutes, 5.minutes).inOrder()
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@ package de.jeanlucmakiola.clockula.domain.alarm
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.clockula.domain.Alarm
|
||||
import de.jeanlucmakiola.clockula.domain.ClockDefaults
|
||||
import de.jeanlucmakiola.clockula.domain.RepeatDays
|
||||
import de.jeanlucmakiola.clockula.domain.ring.SilenceAfter
|
||||
import de.jeanlucmakiola.clockula.testing.BERLIN
|
||||
import de.jeanlucmakiola.clockula.testing.alarmAt
|
||||
import de.jeanlucmakiola.clockula.testing.instantAt
|
||||
@@ -28,8 +30,9 @@ class AlarmResolverTest {
|
||||
state: AlarmRingState = ringState(alarmId = alarm.id),
|
||||
now: Instant = morning,
|
||||
fireGrace: Duration = AlarmRing.FIRE_GRACE,
|
||||
silenceAfter: Duration = ClockDefaults().alarmSilenceAfter,
|
||||
): AlarmResolution =
|
||||
AlarmResolver.resolve(alarm, state, now, BERLIN, AlarmRing.AUTO_SILENCE_AFTER, fireGrace)
|
||||
AlarmResolver.resolve(alarm, state, now, BERLIN, silenceAfter, fireGrace)
|
||||
|
||||
// --- T32–T34: the disabled and plain cases ---
|
||||
|
||||
@@ -158,7 +161,7 @@ class AlarmResolverTest {
|
||||
@Test
|
||||
fun `a ring exactly as old as the auto-silence window has timed out`() {
|
||||
val alarm = alarmAt(hour = 7, repeatDays = RepeatDays.EVERY_DAY)
|
||||
val state = ringState(ringingSince = morning - AlarmRing.AUTO_SILENCE_AFTER)
|
||||
val state = ringState(ringingSince = morning - ClockDefaults().alarmSilenceAfter)
|
||||
|
||||
val resolution = resolve(alarm, state)
|
||||
|
||||
@@ -357,4 +360,53 @@ class AlarmResolverTest {
|
||||
assertThat(resolve(alarm, state)).isEqualTo(first)
|
||||
assertThat(state).isEqualTo(copy)
|
||||
}
|
||||
|
||||
// --- #17: the window is the "Silence after" setting ---
|
||||
|
||||
@Test
|
||||
fun `a longer window keeps a ring going past the default's ten minutes`() {
|
||||
val alarm = alarmAt(hour = 7, repeatDays = RepeatDays.EVERY_DAY)
|
||||
val state = ringState(ringingSince = morning - 20.minutes)
|
||||
|
||||
val resolution = resolve(alarm, state, silenceAfter = SilenceAfter.window(25))
|
||||
|
||||
assertThat(resolution.source).isEqualTo(FireSource.RESUMED_RING)
|
||||
assertThat(resolution.state).isEqualTo(state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a shorter window closes a ring the default would still keep`() {
|
||||
val alarm = alarmAt(hour = 7, repeatDays = RepeatDays.EVERY_DAY)
|
||||
val state = ringState(ringingSince = morning - 2.minutes)
|
||||
|
||||
val resolution = resolve(alarm, state, silenceAfter = SilenceAfter.window(1))
|
||||
|
||||
assertThat(resolution.source).isEqualTo(FireSource.OCCURRENCE)
|
||||
assertThat(resolution.state.ringingSince).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Never still closes a ring at the hard stop`() {
|
||||
val alarm = alarmAt(hour = 7, repeatDays = RepeatDays.EVERY_DAY)
|
||||
val never = SilenceAfter.window(SilenceAfter.NEVER)
|
||||
|
||||
val ringing = resolve(alarm, ringState(ringingSince = morning - 59.minutes), silenceAfter = never)
|
||||
val stopped = resolve(alarm, ringState(ringingSince = morning - 1.hours), silenceAfter = never)
|
||||
|
||||
assertThat(ringing.source).isEqualTo(FireSource.RESUMED_RING)
|
||||
assertThat(stopped.state.ringingSince).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a late snooze is kept for as long as the window the user chose`() {
|
||||
val alarm = alarmAt(hour = 7, repeatDays = RepeatDays.EVERY_DAY)
|
||||
val snoozedUntil = morning - 20.minutes
|
||||
val state = ringState(snoozedUntil = snoozedUntil, snoozeCount = 1)
|
||||
|
||||
val kept = resolve(alarm, state, silenceAfter = SilenceAfter.window(25))
|
||||
val dropped = resolve(alarm, state, silenceAfter = SilenceAfter.window(15))
|
||||
|
||||
assertThat(kept.nextFire to kept.source).isEqualTo(snoozedUntil to FireSource.SNOOZE)
|
||||
assertThat(dropped.state.snoozedUntil).isNull()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package de.jeanlucmakiola.clockula.domain.ring
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.clockula.domain.ClockDefaults
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
/** #17: what a stored "Silence after" value means to the two engines. */
|
||||
class SilenceAfterTest {
|
||||
|
||||
@Test
|
||||
fun `the default is v1's fixed ten minutes, for alarms and timers alike`() {
|
||||
val defaults = ClockDefaults()
|
||||
|
||||
assertThat(defaults.alarmSilenceAfter).isEqualTo(10.minutes)
|
||||
assertThat(defaults.timerSilenceAfter).isEqualTo(10.minutes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stored value is that many minutes`() {
|
||||
assertThat(SilenceAfter.window(1)).isEqualTo(1.minutes)
|
||||
assertThat(SilenceAfter.window(25)).isEqualTo(25.minutes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Never is the hard stop, an hour`() {
|
||||
assertThat(SilenceAfter.window(SilenceAfter.NEVER)).isEqualTo(SilenceAfter.HARD_STOP)
|
||||
assertThat(SilenceAfter.HARD_STOP).isEqualTo(1.hours)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no value reaches the engines as a window longer than the hard stop`() {
|
||||
assertThat(SilenceAfter.window(24 * 60)).isEqualTo(SilenceAfter.HARD_STOP)
|
||||
// Negative is clamped to Never rather than read as an instant silence.
|
||||
assertThat(SilenceAfter.window(-5)).isEqualTo(SilenceAfter.HARD_STOP)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every choice is in range, and the hard stop outlasts every finite one`() {
|
||||
assertThat(SilenceAfter.CHOICES).contains(SilenceAfter.DEFAULT_MINUTES)
|
||||
assertThat(SilenceAfter.CHOICES).contains(SilenceAfter.NEVER)
|
||||
SilenceAfter.CHOICES.forEach { minutes ->
|
||||
assertThat(minutes).isIn(SilenceAfter.MINUTES)
|
||||
assertThat(SilenceAfter.window(minutes)).isAtMost(SilenceAfter.HARD_STOP)
|
||||
}
|
||||
assertThat(SilenceAfter.CHOICES.filter { it != SilenceAfter.NEVER }.max().minutes)
|
||||
.isLessThan(SilenceAfter.HARD_STOP)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.clockula.domain.timer
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.clockula.domain.ClockDefaults
|
||||
import de.jeanlucmakiola.clockula.domain.Ringtones
|
||||
import de.jeanlucmakiola.clockula.domain.ring.SilenceAfter
|
||||
import de.jeanlucmakiola.clockula.testing.T0
|
||||
import de.jeanlucmakiola.clockula.testing.expiredTimer
|
||||
import org.junit.jupiter.api.Test
|
||||
@@ -154,7 +155,7 @@ class TimerRingPolicyTest {
|
||||
TimerRingDecision.Sound(
|
||||
subjectId = 7L,
|
||||
settings = released.sound().settings,
|
||||
silenceAt = now + TimerRing.AUTO_SILENCE_AFTER,
|
||||
silenceAt = now + ClockDefaults().timerSilenceAfter,
|
||||
),
|
||||
soundingSince = now,
|
||||
),
|
||||
@@ -174,7 +175,7 @@ class TimerRingPolicyTest {
|
||||
/** §5.4 #14 */
|
||||
@Test
|
||||
fun `a session that has run its window auto-silences`() {
|
||||
val opened = now - TimerRing.AUTO_SILENCE_AFTER
|
||||
val opened = now - ClockDefaults().timerSilenceAfter
|
||||
|
||||
val outcome = decide(soundingSince = opened)
|
||||
|
||||
@@ -185,7 +186,7 @@ class TimerRingPolicyTest {
|
||||
/** §5.4 #15 */
|
||||
@Test
|
||||
fun `auto-silence is decided before suppression`() {
|
||||
val opened = now - TimerRing.AUTO_SILENCE_AFTER
|
||||
val opened = now - ClockDefaults().timerSilenceAfter
|
||||
|
||||
val outcome = decide(alarmIsRinging = true, soundingSince = opened)
|
||||
|
||||
@@ -195,7 +196,7 @@ class TimerRingPolicyTest {
|
||||
/** §5.4 #16 */
|
||||
@Test
|
||||
fun `an auto-silenced session does not restart a minute later`() {
|
||||
val opened = now - TimerRing.AUTO_SILENCE_AFTER
|
||||
val opened = now - ClockDefaults().timerSilenceAfter
|
||||
|
||||
val outcome = decide(soundingSince = opened, at = now + 1.minutes)
|
||||
|
||||
@@ -207,7 +208,7 @@ class TimerRingPolicyTest {
|
||||
@Test
|
||||
fun `resetting the last expired timer closes an auto-silenced session`() {
|
||||
val outcome =
|
||||
decide(expired = emptyList(), soundingSince = now - TimerRing.AUTO_SILENCE_AFTER)
|
||||
decide(expired = emptyList(), soundingSince = now - ClockDefaults().timerSilenceAfter)
|
||||
|
||||
assertThat(outcome).isEqualTo(TimerRingOutcome(TimerRingDecision.Silent, soundingSince = null))
|
||||
}
|
||||
@@ -227,7 +228,7 @@ class TimerRingPolicyTest {
|
||||
fun `the window closes ten minutes after the audio opened`() {
|
||||
val outcome = decide()
|
||||
|
||||
assertThat(outcome.sound().silenceAt).isEqualTo(now + TimerRing.AUTO_SILENCE_AFTER)
|
||||
assertThat(outcome.sound().silenceAt).isEqualTo(now + ClockDefaults().timerSilenceAfter)
|
||||
}
|
||||
|
||||
/** §5.4 #20 */
|
||||
@@ -242,4 +243,35 @@ class TimerRingPolicyTest {
|
||||
|
||||
assertThat(outcome.sound().subjectId).isEqualTo(9L)
|
||||
}
|
||||
|
||||
// --- #17: the window is the timer's own "Silence after" setting ---
|
||||
|
||||
@Test
|
||||
fun `the window is the timer setting, not the alarm's`() {
|
||||
val custom = defaults.copy(timerSilenceAfterMinutes = 25, alarmSilenceAfterMinutes = 1)
|
||||
|
||||
val outcome = decide(defaults = custom)
|
||||
|
||||
assertThat(outcome.sound().silenceAt).isEqualTo(now + 25.minutes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a shorter window auto-silences a session the default would keep`() {
|
||||
val custom = defaults.copy(timerSilenceAfterMinutes = 1)
|
||||
|
||||
val outcome = decide(defaults = custom, soundingSince = now - 1.minutes)
|
||||
|
||||
assertThat(outcome.decision).isEqualTo(TimerRingDecision.AutoSilenced(7L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Never sounds up to the hard stop and no further`() {
|
||||
val never = defaults.copy(timerSilenceAfterMinutes = SilenceAfter.NEVER)
|
||||
|
||||
val sounding = decide(defaults = never, soundingSince = now - 59.minutes)
|
||||
val silenced = decide(defaults = never, soundingSince = now - SilenceAfter.HARD_STOP)
|
||||
|
||||
assertThat(sounding.sound().silenceAt).isEqualTo(now - 59.minutes + SilenceAfter.HARD_STOP)
|
||||
assertThat(silenced.decision).isEqualTo(TimerRingDecision.AutoSilenced(7L))
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
package de.jeanlucmakiola.clockula.timer
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.clockula.domain.ClockDefaults
|
||||
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
|
||||
@@ -497,7 +497,22 @@ class TimerEngineTest {
|
||||
|
||||
val state = harness.engine.serviceState(alreadyAlerted = false)
|
||||
|
||||
assertThat(state.silenceIn).isEqualTo(TimerRing.AUTO_SILENCE_AFTER - 30.seconds)
|
||||
assertThat(state.silenceIn).isEqualTo(ClockDefaults().timerSilenceAfter - 30.seconds)
|
||||
}
|
||||
|
||||
/** #17 */
|
||||
@Test
|
||||
fun `the ring session's window is the timer's silence-after setting`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest {
|
||||
val harness = timerEngineHarness(tempDir)
|
||||
harness.settings.setTimerSilenceAfterMinutes(25)
|
||||
harness.given(expiredTimer(id = 1L))
|
||||
harness.ringState.set(T0 - 30.seconds)
|
||||
|
||||
val state = harness.engine.serviceState(alreadyAlerted = false)
|
||||
|
||||
assertThat(state.silenceIn).isEqualTo(25.minutes - 30.seconds)
|
||||
}
|
||||
|
||||
/** §5.8 #31 */
|
||||
@@ -688,7 +703,7 @@ class TimerEngineTest {
|
||||
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)
|
||||
harness.advance(ClockDefaults().timerSilenceAfter)
|
||||
val lapsed = harness.engine.serviceState(alreadyAlerted = true)
|
||||
|
||||
harness.engine.start(2L)
|
||||
@@ -699,7 +714,7 @@ class TimerEngineTest {
|
||||
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)
|
||||
.isEqualTo(harness.wallClock.instant + ClockDefaults().timerSilenceAfter)
|
||||
}
|
||||
|
||||
/** §5.8 #42 — added with the review fix for finding 2. */
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package de.jeanlucmakiola.clockula.ui.ring
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.clockula.domain.ClockDefaults
|
||||
import de.jeanlucmakiola.clockula.domain.TimerState
|
||||
import de.jeanlucmakiola.clockula.domain.timer.TimerRing
|
||||
import de.jeanlucmakiola.clockula.testing.MainDispatcherExtension
|
||||
import de.jeanlucmakiola.clockula.testing.T0
|
||||
import de.jeanlucmakiola.clockula.testing.TimerEngineHarness
|
||||
@@ -35,6 +35,7 @@ class TimerRingViewModelTest {
|
||||
private fun TestScope.viewModel(harness: TimerEngineHarness) = TimerRingViewModel(
|
||||
timers = harness.timers,
|
||||
ringState = harness.ringState,
|
||||
settings = harness.settings,
|
||||
engine = harness.engine,
|
||||
elapsed = harness.elapsed,
|
||||
wall = harness.wallClock,
|
||||
@@ -124,11 +125,29 @@ class TimerRingViewModelTest {
|
||||
advanceUntilIdle()
|
||||
assertThat(viewModel.state.value).isInstanceOf(TimerRingUiState.Ringing::class.java)
|
||||
|
||||
harness.wallClock.advance(TimerRing.AUTO_SILENCE_AFTER + 1.seconds)
|
||||
harness.wallClock.advance(ClockDefaults().timerSilenceAfter + 1.seconds)
|
||||
harness.ticker.tick()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(harness.stored(1L)!!.state).isEqualTo(TimerState.EXPIRED)
|
||||
assertThat(viewModel.state.value).isEqualTo(TimerRingUiState.Finished)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the screen follows the timer's own silence-after setting`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = timerEngineHarness(tempDir)
|
||||
harness.settings.setTimerSilenceAfterMinutes(1)
|
||||
harness.given(expiredTimer(id = 1L))
|
||||
harness.ringState.set(T0)
|
||||
val viewModel = viewModel(harness)
|
||||
advanceUntilIdle()
|
||||
assertThat(viewModel.state.value).isInstanceOf(TimerRingUiState.Ringing::class.java)
|
||||
|
||||
harness.wallClock.advance(1.minutes + 1.seconds)
|
||||
harness.ticker.tick()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(viewModel.state.value).isEqualTo(TimerRingUiState.Finished)
|
||||
}
|
||||
}
|
||||
+45
-12
@@ -1050,14 +1050,14 @@ M10 adds no `<uses-permission>`.
|
||||
| Not here | Milestone |
|
||||
|---|---|
|
||||
| Reordering alarms by hand. The list is ordered by time of day (§13) and `alarms` has no `sort_order` column, so this would be a schema change for a preference the time order already answers | not planned for v1 |
|
||||
| A per-alarm auto-silence duration. Every other per-alarm setting became an override picker in M5; this one is still global and still `AlarmRing.AUTO_SILENCE_AFTER` | not planned for v1 |
|
||||
| A per-alarm auto-silence duration. Every other per-alarm setting became an override picker in M5; "Silence after" (#17) is an app-wide setting only, because an override would need a new `alarms` column, a migration and an editor row for a value almost nobody sets per alarm | not planned |
|
||||
| Voice-interaction follow-on flows: `Activity.isVoiceInteraction`, `VoiceInteractor.CompleteVoiceRequest` (publishing a deeplink to the alarm just created) and `PickOptionRequest` (disambiguating by voice). `android.intent.category.VOICE` is therefore not declared: advertising it while answering with a touch dialog would be a promise the app does not keep. Clockula *accepts* a `clockula://` deeplink and publishes none | not planned for v1 |
|
||||
| A "most closely matched" time search for `DISMISS_ALARM`. `ALARM_SEARCH_MODE_TIME` matches **exactly**: dismissing an alarm the caller did not name is a missed alarm, and there is no distance at which that becomes a good trade | not planned for v1 |
|
||||
| A toast or any other confirmation on a `SKIP_UI` write. `SKIP_UI` means "bypass any intermediate UI", a voice assistant speaks its own confirmation, and the status-bar alarm icon `setAlarmClock` lights is the platform's own receipt (§17) | not planned for v1 |
|
||||
| Surfacing `delete_after_use` in either editor. It is a property the contract sets, not a setting; an alarm the user edits keeps it, and that consequence is documented (§17) rather than papered over with a checkbox | not planned for v1 |
|
||||
| Carrying `EXTRA_MESSAGE` into the timer setup panel when no length was given. There is no row to carry it on and the panel has no label field | not planned for v1 |
|
||||
| Verifying a ringtone URI at the intent boundary. M5 locked "an unreadable sound is reported, never rewritten"; an unmounted card comes back, and the boundary opens no `ContentResolver` | not planned for v1 |
|
||||
| A user-configurable auto-silence duration, an unlimited-snooze option, per-alarm auto-silence | not planned for v1 |
|
||||
| An unlimited-snooze option, per-alarm auto-silence | not planned for v1 |
|
||||
| Reordering timers by hand. `sort_order` and `TimerRepository.reorder` exist and stay caller-less: the list is in storage order, M5 gave the same answer for alarms, and a drag surface would also mean abandoning the `LazyColumn` | not planned for v1 |
|
||||
| Per-timer vibrate, volume-ramp or dismiss-challenge overrides. `timers` has exactly one nullable settings column, its ringtone; four more would be a schema change for settings the roadmap does not ask for | not planned for v1 |
|
||||
| A "stop all" for several expired timers, and undo after a timer delete. Each expired timer is acknowledged on its own, and the delete confirmation asks before rather than after | not planned for v1 |
|
||||
@@ -1142,7 +1142,7 @@ repair, resume and reschedule one by one, so its whole pass is inside the lock.
|
||||
|
||||
1. **Disabled** ⇒ clear the snooze, the ring and the skip watermark; keep
|
||||
`handled_occurrence`, because re-enabling must not un-suppress a dismissal.
|
||||
2. **Ringing** within `AUTO_SILENCE_AFTER` ⇒ `RESUMED_RING`, nothing scheduled:
|
||||
2. **Ringing** within the "Silence after" window ⇒ `RESUMED_RING`, nothing scheduled:
|
||||
it is not an alarm to come, it is one that is happening. A clock moved
|
||||
*backwards* under a ringing alarm reads as a negative age, which is inside the
|
||||
window, so it keeps ringing. Beyond the window it is auto-dismissed as
|
||||
@@ -1157,8 +1157,32 @@ repair, resume and reschedule one by one, so its whole pass is inside the lock.
|
||||
### The ring cycle
|
||||
|
||||
A cycle opens when the alarm fires and closes on dismiss, on the snooze limit
|
||||
being reached, or on auto-silence after `AUTO_SILENCE_AFTER` = **10 minutes**
|
||||
(AOSP DeskClock's default; not user-configurable in v1). Snoozing keeps it open.
|
||||
being reached, or on auto-silence after the **"Silence after"** window. That is
|
||||
a setting since #17 (Settings › Alarms: 1, 5, 10, 15, 20 or 25 minutes, or
|
||||
Never), **10 minutes** by default, AOSP DeskClock's default and v1's fixed
|
||||
value, so an upgrade changes nothing. Snoozing keeps it open.
|
||||
|
||||
The window is read from `ClockDefaults.alarmSilenceAfter` everywhere a ring is
|
||||
measured: the resolver's ringing and late-snooze checks, the backstop `onFire`
|
||||
arms, how late a snooze fire may arrive, and whether a ring interrupted by a
|
||||
reboot is resumed. `AlarmResolver.resolve` takes it without a default, so no
|
||||
caller can measure against a window the user did not choose.
|
||||
|
||||
**"Never" is an hour.** The backstop is also what stops a phone left at home
|
||||
from ringing until its battery is flat, what bounds the ring services' wake
|
||||
locks, and what keeps a reboot hours later from resuming a ring nobody was
|
||||
there for. So "Never" maps to `SilenceAfter.HARD_STOP` (60 minutes), longer
|
||||
than any finite choice, and the settings hint says so in words. Stored values
|
||||
are clamped to 0..60 on read, on write and from a backup, so no value can make
|
||||
a ring outlast it.
|
||||
|
||||
A ring started under one window keeps the backstop it was armed with. When the
|
||||
window is shortened mid-ring, the next re-resolution closes the ring once it is
|
||||
past the new window, and `persist` then stops the ring service and cancels the
|
||||
backstop with it: storage says nothing rings, so nothing may. The same applies
|
||||
to any ring the resolver closes (a clock jumped forward, a ringing alarm
|
||||
switched off), which earlier only cleared `ringingSince` and left the sound to
|
||||
the backstop.
|
||||
|
||||
A **non-repeating alarm is disabled when its cycle closes, never when it opens**.
|
||||
Disabling it at the start would clear its own snooze and the alarm would vanish
|
||||
@@ -1284,7 +1308,7 @@ timer too. What the alarm **keeps to itself** is the full-screen intent, the
|
||||
ring activity, the dismiss challenge and the snooze; what the timer
|
||||
parameterises is its own ringtone, a ramp of **zero seconds** (a timer is set by
|
||||
someone awake, and a gentle start is just a quiet start) and its own
|
||||
auto-silence constant. §14 has the rest, including which of the two wins the
|
||||
"Silence after" setting. §14 has the rest, including which of the two wins the
|
||||
one audio stream.
|
||||
|
||||
---
|
||||
@@ -1433,7 +1457,7 @@ whichever comes first, for every challenge kind, asserted as a parameterised
|
||||
invariant over `DismissChallenge`, not as three happy paths.
|
||||
|
||||
Three more guarantees hold alongside it. Snooze is never gated: the gate protects
|
||||
dismissal only. Auto-silence still ends the ring at ten minutes regardless of the
|
||||
dismissal only. Auto-silence still ends the ring at its "Silence after" window regardless of the
|
||||
gate, because that is the engine's, and the screen only follows the session to
|
||||
`Finished`. And a wrong answer carries no lockout and no penalty timer; it
|
||||
replaces the problem and that is all.
|
||||
@@ -1693,6 +1717,12 @@ the engine's own mutex, so it cannot race the fire it is asking about, and it is
|
||||
deliberately **conditional**: dismissing unconditionally would also drop a
|
||||
merely *snoozed* alarm's pending snooze, which is wrong for a label edit.
|
||||
|
||||
Since #17 the engine's `persist` also stops the ring service and cancels the
|
||||
backstop whenever the resolver closes a ring (§11), so the disabled branch no
|
||||
longer leaves a ring sounding on its own. The dismiss-first rule stays: it is
|
||||
what closes the cycle *as a dismissal* (a one-shot alarm disabled, the snooze
|
||||
notification cleared), and a deleted row never reaches the resolver at all.
|
||||
|
||||
### Immediate apply
|
||||
|
||||
There is no Save button, no dirty state and no discard dialog: `PLAN.md` §11
|
||||
@@ -1877,8 +1907,8 @@ sound.
|
||||
- **A session is one window.** It opens when the audio *actually starts
|
||||
sounding* and closes when the expired set returns to empty. A second timer
|
||||
expiring mid-session inherits the session's remaining window rather than
|
||||
buying a fresh ten minutes. That is the conservative direction, and the same rule as
|
||||
§11's "a reboot does not buy the alarm another ten minutes". The anchor is the
|
||||
buying a fresh window. That is the conservative direction, and the same rule as
|
||||
§11's "a reboot does not buy the alarm a fresh window". The anchor is the
|
||||
instant the audio *opened* rather than the instant of expiry, so a long alarm ring in
|
||||
front of it does not eat the timer's whole window.
|
||||
- **A lapsed window does not silence the next timer.** A window that has run out
|
||||
@@ -1889,9 +1919,12 @@ sound.
|
||||
opens a fresh window with its own heads-up. Inheriting stays the rule for a
|
||||
timer expiring *inside* a live window.
|
||||
|
||||
`TimerRing.AUTO_SILENCE_AFTER` is **ten minutes**: its own constant with the
|
||||
same value as the alarm's, free to diverge, because coupling the two would make
|
||||
one number answer two questions. Unlike an alarm's, a timer's
|
||||
The window is the timer's own **"Silence after"** setting
|
||||
(`ClockDefaults.timerSilenceAfter`, Settings › Timers, #17), ten minutes by
|
||||
default: the same choices as the alarm's and the same one-hour "Never", but a
|
||||
separate value, because coupling the two would make one number answer two
|
||||
questions. `TimerRingPolicy` reads it from the defaults it already takes, so a
|
||||
change applies on the service's next pass. Unlike an alarm's, a timer's
|
||||
**auto-silence stops the noise and leaves the row `EXPIRED`.** An auto-silenced
|
||||
alarm is a *missed* alarm and its cycle closes; an auto-silenced timer has still
|
||||
finished, and the user coming back must be able to see that and add a minute
|
||||
|
||||
+11
-2
@@ -136,6 +136,8 @@ device or after a reboot (`docs/PLAN.md` §5). Every imported timer lands
|
||||
"default_timer_ringtone_uri": null,
|
||||
"default_dismiss_challenge": "NONE",
|
||||
"home_zone_id": null,
|
||||
"default_alarm_silence_after_minutes": 10,
|
||||
"default_timer_silence_after_minutes": 10,
|
||||
"timer_presets": [60000, 120000, 180000, 300000, 600000, 900000, 1800000, 3600000]
|
||||
}
|
||||
```
|
||||
@@ -143,8 +145,15 @@ device or after a reboot (`docs/PLAN.md` §5). Every imported timer lands
|
||||
Field names mirror `ClockPrefs`'/`SettingsPrefs`' own on-disk keys. Older
|
||||
files may also carry `default_timer_duration_millis`, from a default timer
|
||||
length setting that has since been removed; it is ignored on import like any
|
||||
other unknown field. Every
|
||||
numeric/duration value is clamped on *apply* using the same ranges and encoding
|
||||
other unknown field.
|
||||
|
||||
`default_alarm_silence_after_minutes` and `default_timer_silence_after_minutes`
|
||||
are how long a ringing alarm or a finished timer keeps going before it stops on
|
||||
its own: whole minutes, with `0` meaning "Never" (which still stops after an
|
||||
hour). A file without them, from before the setting existed, imports as `10`,
|
||||
the value it was made with. Out-of-range values clamp to 0..60.
|
||||
|
||||
Every numeric/duration value is clamped on *apply* using the same ranges and encoding
|
||||
as the existing preference setters, never on parse.
|
||||
|
||||
## Ringtone URIs
|
||||
|
||||
Reference in new issue
Block a user