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:
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user