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:
2026-10-22 11:04:39 +02:00
parent 3f431a7f32
commit 94f09e9e5a
22 changed files with 598 additions and 20 deletions
+5
View File
@@ -32,6 +32,11 @@ The first release of Clockula: alarms, timers, a stopwatch and a world clock.
- A ring screen over the lock screen with snooze and dismiss. The dismiss
challenge always gives you a way out, works with a screen reader, and Back
can't silence an alarm.
- Choose what the volume buttons do while an alarm rings: change the volume
(as before), snooze, dismiss or nothing. Dismiss still respects the dismiss
challenge by snoozing instead, and a snooze press with no snoozes left
changes the volume rather than ending the alarm. It works while the ring
screen is showing.
- **Timers.** Run several at once, each with its own label, length and sound,
set from a keypad with saved presets. Pause, resume, reset and +1 min work
from the timer, from the live pill and from the notification. A finished
@@ -87,9 +87,18 @@ class AlarmEngine @Inject constructor(
closeCycleLocked(alarmId, state)
}
/** [minutesOverride] is a one-off, already clamped; it is never persisted anywhere (M9 D15). */
suspend fun snooze(alarmId: Long, minutesOverride: Int? = null): SnoozeOutcome =
lock.withLock { snoozeLocked(alarmId, minutesOverride) }
/**
* [minutesOverride] is a one-off, already clamped; it is never persisted anywhere (M9 D15).
*
* A snooze past the limit dismisses, unless [dismissIfRefused] is false:
* then nothing changes and the alarm keeps ringing. A volume key passes
* false (#18), since the press is blind and must not end the alarm.
*/
suspend fun snooze(
alarmId: Long,
minutesOverride: Int? = null,
dismissIfRefused: Boolean = true,
): SnoozeOutcome = lock.withLock { snoozeLocked(alarmId, minutesOverride, dismissIfRefused) }
/**
* The `AlarmClock` contract's dismissal, and the chooser dialog's (M9 D14).
@@ -158,12 +167,14 @@ class AlarmEngine @Inject constructor(
val since = state.ringingSince ?: return@withLock null
val alarm = alarms.find(state.alarmId) ?: return@withLock null
val resolved: AlarmSettings = alarm.resolveSettings(settings.currentDefaults())
val defaults = settings.currentDefaults()
val resolved: AlarmSettings = alarm.resolveSettings(defaults)
RingSession(
alarm = alarm,
settings = resolved,
startedAt = since,
canSnooze = state.snoozeCount < resolved.snoozeLimit,
volumeButtons = defaults.volumeButtons,
)
}
@@ -274,7 +285,11 @@ class AlarmEngine @Inject constructor(
return FireOutcome.Ringing(alarmId)
}
private suspend fun snoozeLocked(alarmId: Long, minutesOverride: Int? = null): SnoozeOutcome {
private suspend fun snoozeLocked(
alarmId: Long,
minutesOverride: Int?,
dismissIfRefused: Boolean,
): SnoozeOutcome {
val state = states.state(alarmId)
if (state.ringingSince == null) return SnoozeOutcome.NotRinging
val alarm = alarms.find(alarmId) ?: return SnoozeOutcome.NotRinging
@@ -282,7 +297,11 @@ class AlarmEngine @Inject constructor(
val resolved = alarm.resolveSettings(settings.currentDefaults())
// A limit of 0 means snoozing is off, not unlimited — and a refused
// snooze dismisses, so the dismiss path can never strand the user.
// The one exception is a caller that asked to keep ringing (a blind
// volume-key press, #18): it is told so and must keep the alarm's own
// way out on screen.
if (state.snoozeCount >= resolved.snoozeLimit) {
if (!dismissIfRefused) return SnoozeOutcome.Refused(alarmId)
closeCycleLocked(alarmId, state)
return SnoozeOutcome.LimitReached(alarmId)
}
@@ -6,6 +6,7 @@ import de.jeanlucmakiola.clockula.domain.DismissChallenge
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import de.jeanlucmakiola.clockula.domain.Zones
import de.jeanlucmakiola.clockula.domain.alarm.VolumeButtonAction
import de.jeanlucmakiola.clockula.domain.backup.BackupAlarm
import de.jeanlucmakiola.clockula.domain.backup.BackupContent
import de.jeanlucmakiola.clockula.domain.backup.BackupSettings
@@ -127,6 +128,7 @@ object BackupCodec {
timerRingtoneUri = defaults.timerRingtoneUri,
dismissChallenge = defaults.dismissChallenge.name,
homeZoneId = defaults.homeZoneId,
volumeButtons = defaults.volumeButtons.name,
timerPresets = timerPresets.map { it.inWholeMilliseconds },
)
@@ -233,6 +235,7 @@ object BackupCodec {
timerRingtoneUri = settingsDto.timerRingtoneUri?.takeIf { it.isNotBlank() },
dismissChallenge = settingsDto.dismissChallenge.toEnum(DismissChallenge.NONE),
homeZoneId = settingsDto.homeZoneId?.let(Zones::normalise),
volumeButtons = settingsDto.volumeButtons.toEnum(VolumeButtonAction.CONTROL_VOLUME),
),
timerPresets = settingsDto.timerPresets?.map { it.milliseconds } ?: TimerPresets.BUILT_IN,
)
@@ -71,6 +71,7 @@ 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,
@SerialName("volume_buttons_while_ringing") val volumeButtons: String = "CONTROL_VOLUME",
/**
* 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.alarm.VolumeButtonAction
import de.jeanlucmakiola.floret.prefs.Pref
import de.jeanlucmakiola.floret.prefs.booleanPref
import de.jeanlucmakiola.floret.prefs.enumPref
@@ -31,6 +32,10 @@ object ClockPrefs {
val dismissChallenge: Pref<DismissChallenge> =
enumPref("default_dismiss_challenge", DismissChallenge.NONE)
/** No `default_` prefix: no alarm overrides it. */
val volumeButtons: Pref<VolumeButtonAction> =
enumPref("volume_buttons_while_ringing", VolumeButtonAction.CONTROL_VOLUME)
/** 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)
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.clockula.data.prefs
import de.jeanlucmakiola.clockula.domain.ClockDefaults
import de.jeanlucmakiola.clockula.domain.DismissChallenge
import de.jeanlucmakiola.clockula.domain.alarm.VolumeButtonAction
import de.jeanlucmakiola.clockula.domain.backup.BackupSettings
import de.jeanlucmakiola.clockula.domain.timer.TimerPresets
import de.jeanlucmakiola.floret.prefs.Appearance
@@ -65,11 +66,13 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
store.flow(ClockPrefs.alarmRingtoneUri),
store.flow(ClockPrefs.timerRingtoneUri),
store.flow(ClockPrefs.homeZoneId),
) { partial, alarmRingtoneUri, timerRingtoneUri, homeZoneId ->
store.flow(ClockPrefs.volumeButtons),
) { partial, alarmRingtoneUri, timerRingtoneUri, homeZoneId, volumeButtons ->
partial.copy(
alarmRingtoneUri = alarmRingtoneUri,
timerRingtoneUri = timerRingtoneUri,
homeZoneId = homeZoneId,
volumeButtons = volumeButtons,
)
}.distinctUntilChanged()
@@ -92,6 +95,7 @@ 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.volumeButtons] = backup.defaults.volumeButtons
prefs[TimerPrefs.presets] = backup.timerPresets
}
}
@@ -111,6 +115,8 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
suspend fun setDismissChallenge(challenge: DismissChallenge) =
store.set(ClockPrefs.dismissChallenge, challenge)
suspend fun setVolumeButtons(action: VolumeButtonAction) = store.set(ClockPrefs.volumeButtons, action)
suspend fun setHomeZoneId(zoneId: String?) = store.set(ClockPrefs.homeZoneId, zoneId)
// --- timer presets (M6) ---
@@ -1,8 +1,12 @@
package de.jeanlucmakiola.clockula.domain
import de.jeanlucmakiola.clockula.domain.alarm.VolumeButtonAction
/**
* The app-wide defaults an alarm or timer inherits when it has no override.
* Backed by DataStore, edited in M10's settings screen.
* Backed by DataStore, edited in M10's settings screen. [volumeButtons] has no
* per-alarm override; it lives here because it is edited and backed up with
* the rest of the alarm settings.
*/
data class ClockDefaults(
val snoozeMinutes: Int = 10,
@@ -13,6 +17,7 @@ data class ClockDefaults(
val timerRingtoneUri: String? = null,
val dismissChallenge: DismissChallenge = DismissChallenge.NONE,
val homeZoneId: String? = null,
val volumeButtons: VolumeButtonAction = VolumeButtonAction.CONTROL_VOLUME,
)
/**
@@ -23,6 +23,8 @@ data class RingSession(
val settings: AlarmSettings,
val startedAt: Instant,
val canSnooze: Boolean,
/** The app-wide volume-button setting, read with the rest of the session. */
val volumeButtons: VolumeButtonAction = VolumeButtonAction.CONTROL_VOLUME,
)
sealed interface FireOutcome {
@@ -50,5 +52,9 @@ sealed interface SnoozeOutcome {
/** The limit was already reached; the alarm was dismissed instead. */
data class LimitReached(val alarmId: Long) : SnoozeOutcome
/** The limit was already reached and the caller asked for the alarm to keep ringing. */
data class Refused(val alarmId: Long) : SnoozeOutcome
data object NotRinging : SnoozeOutcome
}
@@ -0,0 +1,62 @@
package de.jeanlucmakiola.clockula.domain.alarm
import de.jeanlucmakiola.clockula.domain.DismissChallenge
/**
* What the user asked the volume buttons to do while an alarm rings (#18).
* App-wide, not per alarm: it is a habit of the hand, not a property of one
* alarm. [CONTROL_VOLUME] is the default and is what Clockula did before the
* setting existed.
*/
enum class VolumeButtonAction { CONTROL_VOLUME, SNOOZE, DISMISS, NOTHING }
/** What one press actually does, once the ring's own rules have had their say. */
enum class VolumeKeyEffect {
/** Not ours: the system changes the volume as it would anyway. */
PASS_THROUGH,
/** Swallowed, so the volume stays where it is and the alarm keeps ringing ("Do nothing" only). */
IGNORE,
SNOOZE,
DISMISS,
}
/**
* Turns the setting into an effect for one ringing alarm. The press is blind
* (the point is not having to look at the screen), so it must never end the
* alarm more finally than the ring screen itself would:
*
* - **Dismiss** dismisses only when the alarm has no dismiss challenge. A
* button that skipped the sum or the hold would make the challenge
* pointless, so with one set it snoozes instead, and the challenge waits for
* the next ring.
* - **Snooze** with no snoozes left (and Dismiss behind a challenge with none
* left) falls back to changing the volume. The engine answers a refused
* snooze from the Snooze button by dismissing, which is right for a button
* the user can see is gone, and wrong for a press made with eyes closed: the
* alarm would end for good without the user knowing. Swallowing the key
* instead would take away the one thing it could still do. The caller also
* asks the engine to keep ringing (`AlarmEngine.snooze`'s
* `dismissIfRefused`), so a budget that ran out after this was decided does
* not end the alarm either.
*/
object VolumeButtonPolicy {
fun effect(
action: VolumeButtonAction,
canSnooze: Boolean,
challenge: DismissChallenge,
): VolumeKeyEffect = when (action) {
VolumeButtonAction.CONTROL_VOLUME -> VolumeKeyEffect.PASS_THROUGH
VolumeButtonAction.NOTHING -> VolumeKeyEffect.IGNORE
VolumeButtonAction.SNOOZE -> snoozeOrVolume(canSnooze)
VolumeButtonAction.DISMISS ->
if (challenge == DismissChallenge.NONE) VolumeKeyEffect.DISMISS else snoozeOrVolume(canSnooze)
}
fun effect(session: RingSession): VolumeKeyEffect =
effect(session.volumeButtons, session.canSnooze, session.settings.dismissChallenge)
private fun snoozeOrVolume(canSnooze: Boolean): VolumeKeyEffect =
if (canSnooze) VolumeKeyEffect.SNOOZE else VolumeKeyEffect.PASS_THROUGH
}
@@ -1,6 +1,7 @@
package de.jeanlucmakiola.clockula.ui.ring
import android.os.Bundle
import android.view.KeyEvent
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
@@ -14,6 +15,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.clockula.domain.alarm.VolumeKeyEffect
import de.jeanlucmakiola.clockula.ui.theme.ClockulaTheme
/**
@@ -71,6 +73,39 @@ class AlarmRingActivity : ComponentActivity() {
}
}
/** The volume keys whose press this screen claimed on the way down. */
private val claimedKeys = mutableSetOf<Int>()
/**
* The volume keys, when the user chose for them to snooze, dismiss or do
* nothing (#18). Both halves of the press are consumed so the volume does
* not move, and the action runs once, on release, however long the key is
* held. Whether a press is ours is decided on the way down and kept for the
* release, so a press that already moved the volume (the session was still
* loading) does not also snooze. "Change the volume" falls through to the
* system untouched.
*/
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
if (keyCode !in VOLUME_KEYS) return super.onKeyDown(keyCode, event)
if (event.repeatCount == 0) {
val ours = viewModel.volumeKeyEffect() != VolumeKeyEffect.PASS_THROUGH
if (ours) claimedKeys += keyCode else claimedKeys -= keyCode
}
return if (keyCode in claimedKeys) true else super.onKeyDown(keyCode, event)
}
/** A release that lands in another window must not leave its key claimed here. */
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (!hasFocus) claimedKeys.clear()
}
override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean {
if (!claimedKeys.remove(keyCode)) return super.onKeyUp(keyCode, event)
if (!event.isCanceled) viewModel.onVolumeKey()
return true
}
/**
* Shown over the lock screen with the display turned on: an alarm the user
* has to unlock to silence is not an alarm. `minSdk` is 29, so the
@@ -85,4 +120,12 @@ class AlarmRingActivity : ComponentActivity() {
setTurnScreenOn(true)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
private companion object {
val VOLUME_KEYS = setOf(
KeyEvent.KEYCODE_VOLUME_UP,
KeyEvent.KEYCODE_VOLUME_DOWN,
KeyEvent.KEYCODE_VOLUME_MUTE,
)
}
}
@@ -10,6 +10,9 @@ import de.jeanlucmakiola.clockula.domain.alarm.ChallengeGate
import de.jeanlucmakiola.clockula.domain.alarm.MathProblem
import de.jeanlucmakiola.clockula.domain.alarm.MathProblems
import de.jeanlucmakiola.clockula.domain.alarm.RingSession
import de.jeanlucmakiola.clockula.domain.alarm.SnoozeOutcome
import de.jeanlucmakiola.clockula.domain.alarm.VolumeButtonPolicy
import de.jeanlucmakiola.clockula.domain.alarm.VolumeKeyEffect
import de.jeanlucmakiola.clockula.domain.time.Ticker
import de.jeanlucmakiola.clockula.domain.time.WallClock
import kotlinx.coroutines.flow.MutableStateFlow
@@ -40,6 +43,10 @@ class AlarmRingViewModel @Inject constructor(
private var typed: String = ""
private var showedWrong: Boolean = false
// A snooze or dismissal is on its way; a second press must not start another.
private var snoozing: Boolean = false
private var dismissing: Boolean = false
init {
viewModelScope.launch {
val loaded = engine.ringSession()
@@ -121,18 +128,73 @@ class AlarmRingViewModel @Inject constructor(
}
/** Never gated by the challenge. */
fun onSnooze() {
val ringing = session ?: return
viewModelScope.launch {
// A refused snooze dismisses inside the engine rather than leaving
// the alarm sounding, so the screen follows it either way.
engine.snooze(ringing.alarm.id)
_state.value = RingUiState.Finished
fun onSnooze() = snooze(dismissIfRefused = true)
/**
* What a volume key does right now. [VolumeKeyEffect.PASS_THROUGH] until the
* session is read, once the ring is over, and when the coordinator says
* another alarm has taken over the ring, so the screen never swallows a key
* it cannot act on and never acts on an alarm that is no longer ringing.
*/
fun volumeKeyEffect(): VolumeKeyEffect {
val ringing = session ?: return VolumeKeyEffect.PASS_THROUGH
if (_state.value !is RingUiState.Ringing) return VolumeKeyEffect.PASS_THROUGH
// Null is not "another alarm": the coordinator is seeded null in a fresh process.
val current = ring.ringing.value
if (current != null && current != ringing.alarm.id) return VolumeKeyEffect.PASS_THROUGH
return VolumeButtonPolicy.effect(ringing)
}
/** A volume key was pressed and released: the setting's action, through the ring's own rules (#18). */
fun onVolumeKey() {
// A press while the screen is already on its way out (a tapped Snooze
// still being written, say) must not turn that into something else.
if (snoozing || dismissing) return
when (volumeKeyEffect()) {
// A blind press must not be what ends the alarm, and the budget can
// run out between the session read and the press.
VolumeKeyEffect.SNOOZE -> snooze(dismissIfRefused = false)
// The policy only dismisses an alarm without a challenge, whose gate
// is open from the start; checked anyway, so a key can never get
// round a challenge.
VolumeKeyEffect.DISMISS -> if (gate.open) dismiss()
VolumeKeyEffect.PASS_THROUGH, VolumeKeyEffect.IGNORE -> Unit
}
}
/**
* A refused snooze dismisses inside the engine rather than leaving the
* alarm sounding, so the screen follows it either way — unless the caller
* asked the engine to keep ringing, and the screen then shows that
* snoozing is gone.
*/
private fun snooze(dismissIfRefused: Boolean) {
val ringing = session ?: return
if (snoozing || dismissing) return
snoozing = true
viewModelScope.launch {
val outcome = try {
engine.snooze(ringing.alarm.id, dismissIfRefused = dismissIfRefused)
} finally {
snoozing = false
}
if (outcome is SnoozeOutcome.Refused) {
session = ringing.copy(canSnooze = false)
render()
} else {
_state.value = RingUiState.Finished
}
}
}
/**
* Not held back by a snooze in flight: a solved challenge is a dismissal
* the user earned, and the engine runs the two one after the other.
*/
private fun dismiss() {
val ringing = session ?: return
if (dismissing) return
dismissing = true
viewModelScope.launch {
engine.dismiss(ringing.alarm.id)
_state.value = RingUiState.Finished
@@ -11,6 +11,7 @@ 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.domain.alarm.VolumeButtonAction
import de.jeanlucmakiola.clockula.ui.alarms.DismissChallengePicker
import de.jeanlucmakiola.clockula.ui.alarms.SnoozeLengthPicker
import de.jeanlucmakiola.clockula.ui.alarms.SnoozeLimitPicker
@@ -23,10 +24,11 @@ import de.jeanlucmakiola.clockula.ui.alarms.vibrateText
import de.jeanlucmakiola.clockula.ui.alarms.volumeRampText
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.OptionPicker
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, VOLUME_BUTTONS }
/**
* What every new alarm starts with: how it sounds, how it snoozes, and how it
@@ -85,6 +87,17 @@ internal fun AlarmDefaultsScreen(
position = Position.Alone,
onClick = { openPicker = OpenAlarmPicker.CHALLENGE },
)
// Not a default: no alarm overrides it, so it sits apart from the
// groups above and says so in its own hint.
Spacer(Modifier.height(24.dp))
GroupedRow(
title = stringResource(R.string.settings_volume_buttons),
summary = volumeButtonsText(defaults.volumeButtons),
position = Position.Alone,
onClick = { openPicker = OpenAlarmPicker.VOLUME_BUTTONS },
)
SettingsHint(stringResource(R.string.settings_volume_buttons_hint))
}
when (openPicker) {
@@ -124,5 +137,23 @@ internal fun AlarmDefaultsScreen(
onSelect = { viewModel.onDismissChallengeChanged(it ?: defaults.dismissChallenge) },
onDismiss = { openPicker = null },
)
OpenAlarmPicker.VOLUME_BUTTONS -> OptionPicker(
title = stringResource(R.string.settings_volume_buttons),
options = VolumeButtonAction.entries,
selected = defaults.volumeButtons,
label = { volumeButtonsText(it) },
onSelect = viewModel::onVolumeButtonsChanged,
onDismiss = { openPicker = null },
)
}
}
@Composable
private fun volumeButtonsText(action: VolumeButtonAction): String = stringResource(
when (action) {
VolumeButtonAction.CONTROL_VOLUME -> R.string.volume_buttons_control_volume
VolumeButtonAction.SNOOZE -> R.string.volume_buttons_snooze
VolumeButtonAction.DISMISS -> R.string.volume_buttons_dismiss
VolumeButtonAction.NOTHING -> R.string.volume_buttons_nothing
},
)
@@ -13,6 +13,7 @@ import de.jeanlucmakiola.clockula.domain.ClockDefaults
import de.jeanlucmakiola.clockula.domain.DismissChallenge
import de.jeanlucmakiola.clockula.domain.Ringtones
import de.jeanlucmakiola.clockula.domain.Zones
import de.jeanlucmakiola.clockula.domain.alarm.VolumeButtonAction
import de.jeanlucmakiola.clockula.domain.selfcheck.CheckStatus
import de.jeanlucmakiola.clockula.domain.selfcheck.SelfCheck
import de.jeanlucmakiola.clockula.ui.common.RingtonePicker
@@ -142,6 +143,9 @@ class SettingsViewModel @Inject internal constructor(
fun onDismissChallengeChanged(value: DismissChallenge) =
viewModelScope.launch { settings.setDismissChallenge(value) }
fun onVolumeButtonsChanged(value: VolumeButtonAction) =
viewModelScope.launch { settings.setVolumeButtons(value) }
// --- the default ringtone pickers ---
internal fun onOpenRingtonePicker(kind: DefaultRingtoneKind) {
+7
View File
@@ -297,6 +297,13 @@
<string name="settings_section_alarms">Alarms</string>
<string name="settings_alarms_subtitle">Sound, snooze and dismiss for new alarms</string>
<string name="settings_alarm_defaults_hint">New alarms start with these. Each alarm can still change them in its editor.</string>
<!-- #18: what the volume buttons do while an alarm rings -->
<string name="settings_volume_buttons">Volume buttons while ringing</string>
<string name="settings_volume_buttons_hint">Applies to every alarm while its ring screen is showing. With a dismiss challenge set, Dismiss snoozes instead, so the challenge still has to be done. When a snooze is called for but none are left, the buttons change the volume instead and the alarm keeps ringing.</string>
<string name="volume_buttons_control_volume">Change the volume</string>
<string name="volume_buttons_snooze">Snooze</string>
<string name="volume_buttons_dismiss">Dismiss</string>
<string name="volume_buttons_nothing">Do nothing</string>
<string name="settings_section_timers">Timers</string>
<string name="settings_timers_sound_subtitle">Sound for new timers</string>
<string name="settings_timer_sound_hint">New timers ring with this sound. Each timer can still change it in its editor.</string>
@@ -515,6 +515,20 @@ class AlarmEngineTest {
assertThat(scheduler.next?.fireAt).isEqualTo(sevenTomorrow)
}
@Test
fun `a refused snooze can keep the alarm ringing instead of dismissing it`(@TempDir dir: Path) = runTest {
givenAlarm(repeatDays = RepeatDays.EVERY_DAY, snoozeLimit = 0)
wallClock.instant = sevenToday
val engine = engine(dir)
engine.onFire(alarmId = 1L, scheduledFor = sevenToday)
val outcome = engine.snooze(1L, dismissIfRefused = false)
assertThat(outcome).isEqualTo(SnoozeOutcome.Refused(1L))
assertThat(ring.ringing.value).isEqualTo(1L)
assertThat(states.state(1L).ringingSince).isEqualTo(sevenToday)
}
@Test
fun `a snooze limit of zero refuses the very first snooze`(@TempDir dir: Path) = runTest {
givenAlarm(repeatDays = RepeatDays.EVERY_DAY, snoozeLimit = 0)
@@ -5,6 +5,7 @@ import de.jeanlucmakiola.clockula.domain.ClockDefaults
import de.jeanlucmakiola.clockula.domain.DismissChallenge
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import de.jeanlucmakiola.clockula.domain.alarm.VolumeButtonAction
import de.jeanlucmakiola.clockula.domain.backup.BackupAlarm
import de.jeanlucmakiola.clockula.domain.backup.BackupContent
import de.jeanlucmakiola.clockula.domain.backup.BackupSettings
@@ -60,6 +61,7 @@ class BackupCodecTest {
timerRingtoneUri = "content://media/2",
dismissChallenge = DismissChallenge.HOLD,
homeZoneId = "America/New_York",
volumeButtons = VolumeButtonAction.SNOOZE,
),
timerPresets = listOf(1.minutes, 2.minutes, 10.minutes),
),
@@ -234,6 +236,24 @@ class BackupCodecTest {
assertThat(content.alarms.single().dismissChallenge).isEqualTo(DismissChallenge.NONE)
}
@Test
fun `a file without the volume-button setting keeps the volume keys as they were`() {
val result = BackupCodec.decode(minimalDocument())
val defaults = (result as BackupResult.Success).content.settings.defaults
assertThat(defaults.volumeButtons).isEqualTo(VolumeButtonAction.CONTROL_VOLUME)
}
@Test
fun `an unrecognised volume-button setting degrades rather than rejecting`() {
val json = minimalDocument().replace(
"\"settings\": {}",
"\"settings\": {\"volume_buttons_while_ringing\": \"SOME_FUTURE_ACTION\"}",
)
val result = BackupCodec.decode(json)
val defaults = (result as BackupResult.Success).content.settings.defaults
assertThat(defaults.volumeButtons).isEqualTo(VolumeButtonAction.CONTROL_VOLUME)
}
@Test
fun `out-of-range alarm overrides are clamped to what a picker offers`() {
val json = minimalDocument().replace(
@@ -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.alarm.VolumeButtonAction
import de.jeanlucmakiola.floret.prefs.PrefStore
import de.jeanlucmakiola.floret.prefs.ThemeMode
import kotlinx.coroutines.CoroutineScope
@@ -53,6 +54,7 @@ class ClockPrefsTest {
prefs.setTimerRingtoneUri("content://t")
prefs.setDismissChallenge(DismissChallenge.HOLD)
prefs.setHomeZoneId("Europe/Berlin")
prefs.setVolumeButtons(VolumeButtonAction.DISMISS)
assertThat(prefs.defaults.first()).isEqualTo(
ClockDefaults(
@@ -64,6 +66,7 @@ class ClockPrefsTest {
timerRingtoneUri = "content://t",
dismissChallenge = DismissChallenge.HOLD,
homeZoneId = "Europe/Berlin",
volumeButtons = VolumeButtonAction.DISMISS,
),
)
}
@@ -118,6 +121,19 @@ class ClockPrefsTest {
assertThat(prefs.defaults.first().dismissChallenge).isEqualTo(DismissChallenge.NONE)
}
@Test
fun `a corrupt volume-button setting degrades to changing the volume`(@TempDir tempDir: Path) = runTest {
val dataStore = newDataStore(tempDir)
val prefs = SettingsPrefs(PrefStore(dataStore))
dataStore.updateData { stored ->
stored.toMutablePreferences()
.apply { this[stringPreferencesKey("volume_buttons_while_ringing")] = "LAUNCH_ROCKET" }
}
assertThat(prefs.defaults.first().volumeButtons).isEqualTo(VolumeButtonAction.CONTROL_VOLUME)
}
@Test
fun `a home zone the device no longer knows reads back as absent`(@TempDir tempDir: Path) = runTest {
val dataStore = newDataStore(tempDir)
@@ -7,6 +7,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.ClockDefaults
import de.jeanlucmakiola.clockula.domain.alarm.VolumeButtonAction
import de.jeanlucmakiola.clockula.domain.backup.BackupSettings
import de.jeanlucmakiola.floret.prefs.Appearance
import de.jeanlucmakiola.floret.prefs.PrefStore
@@ -74,7 +75,12 @@ class SettingsPrefsTest {
BackupSettings(
themeMode = ThemeMode.DARK,
dynamicColor = false,
defaults = ClockDefaults(snoozeMinutes = 99, volumeRampSeconds = 99, alarmRingtoneUri = " "),
defaults = ClockDefaults(
snoozeMinutes = 99,
volumeRampSeconds = 99,
alarmRingtoneUri = " ",
volumeButtons = VolumeButtonAction.NOTHING,
),
timerPresets = listOf(5.minutes, 1.minutes, 5.minutes),
),
)
@@ -84,6 +90,7 @@ class SettingsPrefsTest {
assertThat(prefs.currentDefaults().snoozeMinutes).isEqualTo(60)
assertThat(prefs.currentDefaults().volumeRampSeconds).isEqualTo(60)
assertThat(prefs.currentDefaults().alarmRingtoneUri).isNull()
assertThat(prefs.currentDefaults().volumeButtons).isEqualTo(VolumeButtonAction.NOTHING)
assertThat(prefs.timerPresets.first()).containsExactly(1.minutes, 5.minutes).inOrder()
}
@@ -0,0 +1,133 @@
package de.jeanlucmakiola.clockula.domain.alarm
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.Alarm
import de.jeanlucmakiola.clockula.domain.AlarmSettings
import de.jeanlucmakiola.clockula.domain.DismissChallenge
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import org.junit.jupiter.api.Test
import kotlin.time.Instant
/**
* A volume key is pressed blind, so what is asserted over the whole table is
* that it never dismisses past a challenge and never asks for a snooze that
* would be refused (and so turned into a dismissal).
*/
class VolumeButtonPolicyTest {
private val everyCase: List<Triple<VolumeButtonAction, Boolean, DismissChallenge>> =
VolumeButtonAction.entries.flatMap { action ->
listOf(true, false).flatMap { canSnooze ->
DismissChallenge.entries.map { challenge -> Triple(action, canSnooze, challenge) }
}
}
@Test
fun `change the volume leaves every key to the system`() {
for (canSnooze in listOf(true, false)) {
for (challenge in DismissChallenge.entries) {
assertThat(VolumeButtonPolicy.effect(VolumeButtonAction.CONTROL_VOLUME, canSnooze, challenge))
.isEqualTo(VolumeKeyEffect.PASS_THROUGH)
}
}
}
@Test
fun `do nothing swallows the key whatever the alarm`() {
for (canSnooze in listOf(true, false)) {
for (challenge in DismissChallenge.entries) {
assertThat(VolumeButtonPolicy.effect(VolumeButtonAction.NOTHING, canSnooze, challenge))
.isEqualTo(VolumeKeyEffect.IGNORE)
}
}
}
@Test
fun `snooze snoozes while snoozes are left`() {
assertThat(VolumeButtonPolicy.effect(VolumeButtonAction.SNOOZE, canSnooze = true, DismissChallenge.MATH))
.isEqualTo(VolumeKeyEffect.SNOOZE)
}
@Test
fun `snooze with none left goes back to changing the volume`() {
assertThat(VolumeButtonPolicy.effect(VolumeButtonAction.SNOOZE, canSnooze = false, DismissChallenge.NONE))
.isEqualTo(VolumeKeyEffect.PASS_THROUGH)
}
@Test
fun `dismiss dismisses an alarm without a challenge`() {
assertThat(VolumeButtonPolicy.effect(VolumeButtonAction.DISMISS, canSnooze = false, DismissChallenge.NONE))
.isEqualTo(VolumeKeyEffect.DISMISS)
}
@Test
fun `dismiss snoozes an alarm with a challenge instead`() {
assertThat(VolumeButtonPolicy.effect(VolumeButtonAction.DISMISS, canSnooze = true, DismissChallenge.HOLD))
.isEqualTo(VolumeKeyEffect.SNOOZE)
}
@Test
fun `dismiss goes back to changing the volume when a challenge stands and no snoozes are left`() {
assertThat(VolumeButtonPolicy.effect(VolumeButtonAction.DISMISS, canSnooze = false, DismissChallenge.MATH))
.isEqualTo(VolumeKeyEffect.PASS_THROUGH)
}
@Test
fun `a key never dismisses past a challenge`() {
for ((action, canSnooze, challenge) in everyCase) {
if (challenge == DismissChallenge.NONE) continue
assertThat(VolumeButtonPolicy.effect(action, canSnooze, challenge))
.isNotEqualTo(VolumeKeyEffect.DISMISS)
}
}
@Test
fun `a key never snoozes an alarm with no snoozes left`() {
for ((action, canSnooze, challenge) in everyCase) {
if (canSnooze) continue
assertThat(VolumeButtonPolicy.effect(action, canSnooze, challenge))
.isNotEqualTo(VolumeKeyEffect.SNOOZE)
}
}
@Test
fun `the session form reads the setting, the snooze budget and the challenge`() {
val session = RingSession(
alarm = Alarm(
id = 1L,
time = TimeOfDay(7, 0),
label = "",
enabled = true,
repeatDays = RepeatDays.NONE,
skipNextOccurrence = false,
ringtoneUri = null,
vibrate = null,
snoozeMinutes = null,
snoozeLimit = null,
volumeRampSeconds = null,
dismissChallenge = null,
createdAt = Instant.fromEpochMilliseconds(0L),
updatedAt = Instant.fromEpochMilliseconds(0L),
deleteAfterUse = false,
),
settings = AlarmSettings(
ringtoneUri = null,
vibrate = true,
snoozeMinutes = 10,
snoozeLimit = 3,
volumeRampSeconds = 15,
dismissChallenge = DismissChallenge.MATH,
),
startedAt = Instant.fromEpochMilliseconds(0L),
canSnooze = true,
volumeButtons = VolumeButtonAction.DISMISS,
)
assertThat(VolumeButtonPolicy.effect(session)).isEqualTo(VolumeKeyEffect.SNOOZE)
assertThat(VolumeButtonPolicy.effect(session.copy(canSnooze = false)))
.isEqualTo(VolumeKeyEffect.PASS_THROUGH)
assertThat(VolumeButtonPolicy.effect(session.copy(volumeButtons = VolumeButtonAction.CONTROL_VOLUME)))
.isEqualTo(VolumeKeyEffect.PASS_THROUGH)
}
}
@@ -10,6 +10,8 @@ import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.clockula.domain.DismissChallenge
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import de.jeanlucmakiola.clockula.domain.alarm.ChallengeGate
import de.jeanlucmakiola.clockula.domain.alarm.VolumeButtonAction
import de.jeanlucmakiola.clockula.domain.alarm.VolumeKeyEffect
import de.jeanlucmakiola.clockula.testing.BERLIN
import de.jeanlucmakiola.clockula.testing.FakeAlarmDao
import de.jeanlucmakiola.clockula.testing.FakeAlarmNotifier
@@ -66,6 +68,8 @@ class AlarmRingViewModelTest {
private val sevenToday = offsetInstant("2026-06-10T07:00+02:00")
private lateinit var settings: SettingsPrefs
private fun TestScope.engine(tempDir: Path): AlarmEngine = AlarmEngine(
alarms = alarms,
states = states,
@@ -76,7 +80,7 @@ class AlarmRingViewModelTest {
produceFile = { tempDir.resolve("clockula_prefs_test.preferences_pb").toFile() },
),
),
),
).also { settings = it },
scheduler = scheduler,
ring = ring,
notifier = notifier,
@@ -479,6 +483,126 @@ class AlarmRingViewModelTest {
assertThat(states.state(1L).ringingSince).isNotNull()
}
@Test
fun `volume keys change the volume by default`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
val engine = engine(tempDir)
givenRinging(engine)
val viewModel = viewModel(engine)
advanceUntilIdle()
assertThat(viewModel.volumeKeyEffect()).isEqualTo(VolumeKeyEffect.PASS_THROUGH)
}
@Test
fun `a volume key snoozes when asked to`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
val engine = engine(tempDir)
settings.setVolumeButtons(VolumeButtonAction.SNOOZE)
givenRinging(engine)
val viewModel = viewModel(engine)
advanceUntilIdle()
viewModel.onVolumeKey()
advanceUntilIdle()
assertThat(states.state(1L).snoozedUntil).isNotNull()
assertThat(viewModel.state.value).isEqualTo(RingUiState.Finished)
}
@Test
fun `a volume key dismisses an alarm without a challenge`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val engine = engine(tempDir)
settings.setVolumeButtons(VolumeButtonAction.DISMISS)
givenRinging(engine, challenge = DismissChallenge.NONE)
val viewModel = viewModel(engine)
advanceUntilIdle()
viewModel.onVolumeKey()
advanceUntilIdle()
val state = states.state(1L)
assertThat(listOf(state.ringingSince, state.snoozedUntil, viewModel.state.value))
.containsExactly(null, null, RingUiState.Finished)
.inOrder()
}
@Test
fun `a volume key set to dismiss snoozes past a challenge instead`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val engine = engine(tempDir)
settings.setVolumeButtons(VolumeButtonAction.DISMISS)
givenRinging(engine, challenge = DismissChallenge.MATH)
val viewModel = viewModel(engine)
advanceUntilIdle()
viewModel.onVolumeKey()
advanceUntilIdle()
assertThat(states.state(1L).snoozedUntil).isNotNull()
}
@Test
fun `a volume key never dismisses past a challenge with no snoozes left`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val engine = engine(tempDir)
settings.setVolumeButtons(VolumeButtonAction.DISMISS)
givenRinging(engine, challenge = DismissChallenge.HOLD, snoozeLimit = 0)
val viewModel = viewModel(engine)
advanceUntilIdle()
viewModel.onVolumeKey()
advanceUntilIdle()
assertThat(states.state(1L).ringingSince).isNotNull()
assertThat(viewModel.state.value).isInstanceOf(RingUiState.Ringing::class.java)
}
@Test
fun `a volume key snooze past a limit lowered mid-ring keeps the alarm ringing`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val engine = engine(tempDir)
settings.setVolumeButtons(VolumeButtonAction.SNOOZE)
givenRinging(engine)
val viewModel = viewModel(engine)
advanceUntilIdle()
check(viewModel.ringing().canSnooze)
settings.setSnoozeLimit(0)
viewModel.onVolumeKey()
advanceUntilIdle()
assertThat(states.state(1L).ringingSince).isNotNull()
assertThat(viewModel.ringing().canSnooze).isFalse()
}
@Test
fun `volume keys are left alone once another alarm has taken over the ring`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val engine = engine(tempDir)
settings.setVolumeButtons(VolumeButtonAction.DISMISS)
givenRinging(engine)
val viewModel = viewModel(engine)
advanceUntilIdle()
ring.startRinging(2L)
assertThat(viewModel.volumeKeyEffect()).isEqualTo(VolumeKeyEffect.PASS_THROUGH)
}
@Test
fun `volume keys are left alone until the session has loaded`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val engine = engine(tempDir)
settings.setVolumeButtons(VolumeButtonAction.DISMISS)
givenRinging(engine)
advanceUntilIdle()
val viewModel = viewModel(engine)
check(viewModel.state.value == RingUiState.Loading)
assertThat(viewModel.volumeKeyEffect()).isEqualTo(VolumeKeyEffect.PASS_THROUGH)
}
/** The prompt is `"47 + 38"`; the answer is what it says. */
private fun answerTo(prompt: String): String =
prompt.split("+").sumOf { it.trim().toInt() }.toString()
+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 |
+5 -1
View File
@@ -136,6 +136,7 @@ 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,
"volume_buttons_while_ringing": "CONTROL_VOLUME",
"timer_presets": [60000, 120000, 180000, 300000, 600000, 900000, 1800000, 3600000]
}
```
@@ -143,7 +144,10 @@ 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
other unknown field. `volume_buttons_while_ringing` is one of
`CONTROL_VOLUME`/`SNOOZE`/`DISMISS`/`NOTHING`; a file without it (any backup made
before the setting existed) or with a name this version does not know restores
`CONTROL_VOLUME`, the behaviour that file's device had. Every
numeric/duration value is clamped on *apply* using the same ranges and encoding
as the existing preference setters, never on parse.