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

+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