feat(alarms): choose what the volume buttons do while ringing

A new setting in Settings > Alarms: while the ring screen shows, the
volume buttons change the volume (the default, and what they did
before), snooze, dismiss, or do nothing. It is app-wide, stored next to
the clock defaults, and included in the backup; a backup without it
restores "change the volume".

A press is made without looking, so VolumeButtonPolicy keeps it from
ending an alarm more finally than the ring screen would. Dismiss with a
dismiss challenge set snoozes instead, so the challenge still stands.
With no snoozes left, Snooze (and Dismiss behind a challenge) falls
back to changing the volume, never to the engine's refused-snooze path,
which dismisses. The key's snooze also asks the engine to keep ringing
if the limit turns out to be reached after all (AlarmEngine.snooze's new
dismissIfRefused). The ring screen decides whether a press is its own
on key down and keeps that for the release, leaves the keys alone once
another alarm has taken over the ring, and ignores a second snooze or
dismiss while the first is still running.

Only the ring screen handles the keys. While the ring is a heads-up over
another app, a background app gets no key events, and the one way
around that does not hold up: a remote-playback MediaSession whose
VolumeProvider takes the keys. On Android 13 and later
MediaSessionRecord.canHandleVolumeKey refuses such a session from an app
without a MediaRouter2 routing session, which Clockula's would be, so on
current Pixels the keys never reach it. Where it does work, it loses to
whatever app is in front or played last, shows a cast-volume slider and
a phantom media player, and takes media buttons while the alarm rings.
AOSP DeskClock and BlackyHawky's Clock also handle the keys in their
alarm activity only. ARCHITECTURE.md records the limitation.

Fixes #18
This commit is contained in:
makiolaj committed 2026-10-22 11:04:39 +02:00
1 parent 3f431a7f32
commit 94f09e9e5a
22 files changed
+598 -20

No files matched your search

+3 -2
View File
@@ -86,7 +86,7 @@ kit module: the kit's roadmap keeps schedulers app-local.
| Package | Holds |
|---|---|
| `domain/` | `Alarm.kt`, `Timer.kt`, `WorldClock.kt`, `Stopwatch.kt`, `ClockDefaults.kt`, `Ringtone.kt` (the silent sentinel), `RepeatSummary.kt`, `AlarmDefaults.kt`. Plain Kotlin, no Android |
| `domain/alarm/` | the alarm engine's pure half: occurrences, the resolver, the ring state, the presentation and scheduling policies, the challenge gate. The *shared* ring vocabulary moved out to `domain/ring/` in M6 |
| `domain/alarm/` | the alarm engine's pure half: occurrences, the resolver, the ring state, the presentation and scheduling policies, the challenge gate, `VolumeButtonPolicy` (#18). The *shared* ring vocabulary moved out to `domain/ring/` in M6 |
| `domain/ring/` | what both ring paths share: `RingAudio` (the ramp tick, the volume floor), `VolumeRamp`, `AudioSourcePolicy`/`RingtoneSourceKind`, `RingFallbackPolicy`, `VibrationPattern` |
| `domain/stopwatch/` | the stopwatch path's pure half: `StopwatchReadings` (the one reading the pill, the shade and the tab share), `LapStats` (best and worst), `StopwatchLaps` (the lap cap), `StopwatchNotificationPolicy` |
| `domain/timer/` | the timer path's pure half: `TimerReadings` (the app's one timer precedence), `TimerExpiry` (the sweep and the slot's value), `TimerRingPolicy`, `TimerNotificationPolicy`, `TimerPresets`, `TimerDurationEntry`, `TimerSettings`, `TimerRing` |
@@ -595,7 +595,7 @@ Four slices:
| Slice | Keys | Owner |
|---|---|---|
| Appearance (M1) | `theme_mode`, `dynamic_color` | `AppearancePrefs` in `core-prefs`, the family's shared key names |
| Clock defaults (M2) | `default_snooze_minutes`, `default_snooze_limit`, `default_vibrate`, `default_volume_ramp_seconds`, `default_alarm_ringtone_uri`, `default_timer_ringtone_uri`, `default_dismiss_challenge`, `home_zone_id` | `ClockPrefs`, surfaced as `SettingsPrefs.defaults: Flow<ClockDefaults>` |
| Clock defaults (M2) | `default_snooze_minutes`, `default_snooze_limit`, `default_vibrate`, `default_volume_ramp_seconds`, `default_alarm_ringtone_uri`, `default_timer_ringtone_uri`, `default_dismiss_challenge`, `home_zone_id`, `volume_buttons_while_ringing` (#18; app-wide, no per-alarm override) | `ClockPrefs`, surfaced as `SettingsPrefs.defaults: Flow<ClockDefaults>` |
| Stopwatch run record (M2) | `stopwatch_state`, `stopwatch_started_elapsed_millis`, `stopwatch_accumulated_millis`, `stopwatch_last_lap_cumulative_millis` (M7 is the first caller of the last, for the in-progress lap's split) | `StopwatchPrefs`, behind `StopwatchStateStore` |
| System facts (M3) | `last_boot_id` | `SystemPrefs`, behind `BootStateStore`, the persisted half of the reboot gate |
| Timers (M6) | `timer_presets`, `timer_ring_sounding_since_millis` | `TimerPrefs`: the presets surfaced as `SettingsPrefs.timerPresets`, the ring session's anchor behind `TimerRingStateStore` |
@@ -1058,6 +1058,7 @@ M10 adds no `<uses-permission>`.
| 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 |
| The volume-button setting (#18) while the alarm rings only as a heads-up over another app, or with the screen turned off (the system keeps volume keys from a non-interactive device's windows). A background app sees no key events; the one route, a remote-playback `MediaSession` whose `VolumeProvider` takes the keys, is refused by `MediaSessionRecord.canHandleVolumeKey` on Android 13+ for an app with no MediaRouter2 routing session, loses to whichever app is in front or played last, and shows a cast-volume slider and a phantom media player while it works. AOSP DeskClock and BlackyHawky's Clock handle the keys in their alarm activity only, as `AlarmRingActivity` does | not planned |
| 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 |