diff --git a/CHANGELOG.md b/CHANGELOG.md index 76d4d0d..636812f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmEngine.kt b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmEngine.kt index d9ff0a9..3f18a84 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmEngine.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmEngine.kt @@ -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) } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt index c6daf96..16d8a70 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt @@ -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, ) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt index 118e99e..3d8afdb 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefs.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefs.kt index 0cb43c1..1478608 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefs.kt @@ -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 = enumPref("default_dismiss_challenge", DismissChallenge.NONE) + /** No `default_` prefix: no alarm overrides it. */ + val volumeButtons: Pref = + 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 = nullableStringPref("home_zone_id") .map(decode = Zones::normalise, encode = Zones::normalise) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt index b76785b..bc54405 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt @@ -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) --- diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/ClockDefaults.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/ClockDefaults.kt index 73499b3..7355a66 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/ClockDefaults.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/ClockDefaults.kt @@ -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, ) /** diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/UpcomingAlarm.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/UpcomingAlarm.kt index 581ee36..5ec33de 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/UpcomingAlarm.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/UpcomingAlarm.kt @@ -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 } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/VolumeButtons.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/VolumeButtons.kt new file mode 100644 index 0000000..d2bf062 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/VolumeButtons.kt @@ -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 +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/AlarmRingActivity.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/AlarmRingActivity.kt index d2fec8e..cda956f 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/AlarmRingActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/AlarmRingActivity.kt @@ -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() + + /** + * 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, + ) + } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/AlarmRingViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/AlarmRingViewModel.kt index 4079115..910b2fd 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/AlarmRingViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/AlarmRingViewModel.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/AlarmDefaultsScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/AlarmDefaultsScreen.kt index 4e4075d..d91be6b 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/AlarmDefaultsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/AlarmDefaultsScreen.kt @@ -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 + }, +) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModel.kt index 7d90968..a8a9ad5 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModel.kt @@ -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) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ad0a66c..852667d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -297,6 +297,13 @@ Alarms Sound, snooze and dismiss for new alarms New alarms start with these. Each alarm can still change them in its editor. + + Volume buttons while ringing + 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. + Change the volume + Snooze + Dismiss + Do nothing Timers Sound for new timers New timers ring with this sound. Each timer can still change it in its editor. diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/alarm/AlarmEngineTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/alarm/AlarmEngineTest.kt index 0a0c53c..2a390f2 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/alarm/AlarmEngineTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/alarm/AlarmEngineTest.kt @@ -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) diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt index db57734..0c01f9e 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt @@ -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( diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefsTest.kt index 869cca3..1f15752 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefsTest.kt @@ -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) diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt index 71ead3e..d0ef8fe 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt @@ -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() } diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/VolumeButtonPolicyTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/VolumeButtonPolicyTest.kt new file mode 100644 index 0000000..3265ff9 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/VolumeButtonPolicyTest.kt @@ -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> = + 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) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/ring/AlarmRingViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/ring/AlarmRingViewModelTest.kt index b455a06..07f1c3e 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/ui/ring/AlarmRingViewModelTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/ring/AlarmRingViewModelTest.kt @@ -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() diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 97ef4e8..00f3393 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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` | +| 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` | | 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 ``. | 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 | diff --git a/docs/BACKUP.md b/docs/BACKUP.md index e8536de..afe50f0 100644 --- a/docs/BACKUP.md +++ b/docs/BACKUP.md @@ -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.