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:
makiolaj committed 2026-10-21 18:52:25 +02:00
1 parent 3f431a7f32
commit 412e54db09
33 files changed
+680 -80

No files matched your search

+4
View File
@@ -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 },
)
}
}
+10
View File
@@ -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
View File
@@ -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
View File
@@ -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