Merge branch 'feat/18-volume-buttons' into release/v1.0.0

# Conflicts:
#	app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmEngine.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefs.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/domain/ClockDefaults.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/AlarmDefaultsScreen.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModel.kt
#	app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt
#	app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefsTest.kt
#	app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt
#	docs/ARCHITECTURE.md
#	docs/BACKUP.md
This commit is contained in:
makiolaj committed 2026-10-26 20:49:43 +01:00
commit d81cd04b60
22 files changed
+592 -22

No files matched your search

+5
View File
@@ -37,6 +37,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
@@ -120,9 +120,18 @@ class AlarmEngine @Inject constructor(
if (!rearmIfInsideWindowLocked(state)) closeCycleLocked(state.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).
@@ -221,12 +230,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,
)
}
@@ -417,7 +428,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
@@ -425,7 +440,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
@@ -132,6 +133,7 @@ object BackupCodec {
homeZoneId = defaults.homeZoneId,
alarmSilenceAfterMinutes = defaults.alarmSilenceAfterMinutes,
timerSilenceAfterMinutes = defaults.timerSilenceAfterMinutes,
volumeButtons = defaults.volumeButtons.name,
timerPresets = timerPresets.map { it.inWholeMilliseconds },
bedtime = bedtime?.toDto(),
)
@@ -277,6 +279,7 @@ object BackupCodec {
// Clamped on apply, by the preference, like every other default.
alarmSilenceAfterMinutes = settingsDto.alarmSilenceAfterMinutes,
timerSilenceAfterMinutes = settingsDto.timerSilenceAfterMinutes,
volumeButtons = settingsDto.volumeButtons.toEnum(VolumeButtonAction.CONTROL_VOLUME),
),
timerPresets = settingsDto.timerPresets?.map { it.milliseconds } ?: TimerPresets.BUILT_IN,
bedtime = settingsDto.bedtime?.toBedtime(),
@@ -73,6 +73,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",
/**
* Minutes, or 0 for "Never". Absent in a file from before the setting
* existed, which is v1's fixed ten minutes.
@@ -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.clockula.domain.ring.SilenceAfter
import de.jeanlucmakiola.floret.prefs.Pref
import de.jeanlucmakiola.floret.prefs.booleanPref
@@ -46,6 +47,10 @@ object ClockPrefs {
intPref("default_timer_silence_after_minutes", SilenceAfter.DEFAULT_MINUTES)
.map(decode = SilenceAfter::sanitise, encode = SilenceAfter::sanitise)
/** 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)
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.clockula.data.prefs
import androidx.datastore.preferences.core.MutablePreferences
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.bedtime.BedtimeEffects
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
@@ -68,17 +69,19 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
combine(
store.flow(ClockPrefs.alarmSilenceAfterMinutes),
store.flow(ClockPrefs.timerSilenceAfterMinutes),
) { alarmMinutes, timerMinutes -> alarmMinutes to timerMinutes },
store.flow(ClockPrefs.volumeButtons),
) { alarmMinutes, timerMinutes, volumeButtons -> Triple(alarmMinutes, timerMinutes, volumeButtons) },
store.flow(ClockPrefs.alarmRingtoneUri),
store.flow(ClockPrefs.timerRingtoneUri),
store.flow(ClockPrefs.homeZoneId),
) { partial, silenceAfter, alarmRingtoneUri, timerRingtoneUri, homeZoneId ->
) { partial, (alarmSilenceAfter, timerSilenceAfter, volumeButtons), alarmRingtoneUri, timerRingtoneUri, homeZoneId ->
partial.copy(
alarmRingtoneUri = alarmRingtoneUri,
timerRingtoneUri = timerRingtoneUri,
homeZoneId = homeZoneId,
alarmSilenceAfterMinutes = silenceAfter.first,
timerSilenceAfterMinutes = silenceAfter.second,
alarmSilenceAfterMinutes = alarmSilenceAfter,
timerSilenceAfterMinutes = timerSilenceAfter,
volumeButtons = volumeButtons,
)
}.distinctUntilChanged()
@@ -103,6 +106,7 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
prefs[ClockPrefs.homeZoneId] = backup.defaults.homeZoneId
prefs[ClockPrefs.alarmSilenceAfterMinutes] = backup.defaults.alarmSilenceAfterMinutes
prefs[ClockPrefs.timerSilenceAfterMinutes] = backup.defaults.timerSilenceAfterMinutes
prefs[ClockPrefs.volumeButtons] = backup.defaults.volumeButtons
prefs[TimerPrefs.presets] = backup.timerPresets
// Absent from a file written before bedtime existed: keep it.
backup.bedtime?.let { prefs.putBedtime(it) }
@@ -130,6 +134,8 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
suspend fun setTimerSilenceAfterMinutes(minutes: Int) =
store.set(ClockPrefs.timerSilenceAfterMinutes, minutes)
suspend fun setVolumeButtons(action: VolumeButtonAction) = store.set(ClockPrefs.volumeButtons, action)
suspend fun setHomeZoneId(zoneId: String?) = store.set(ClockPrefs.homeZoneId, zoneId)
// --- timer presets (M6) ---
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.clockula.domain
import de.jeanlucmakiola.clockula.domain.alarm.VolumeButtonAction
import de.jeanlucmakiola.clockula.domain.ring.SilenceAfter
import kotlin.time.Duration
@@ -9,7 +10,9 @@ import kotlin.time.Duration
*
* The two silence-after values are app-wide only: no alarm or timer overrides
* them (#17). They are stored as [SilenceAfter] minutes; read them as a window
* through [alarmSilenceAfter] and [timerSilenceAfter].
* through [alarmSilenceAfter] and [timerSilenceAfter]. [volumeButtons] has no
* per-alarm override either (#18); it lives here because it is edited and
* backed up with the rest of the alarm settings.
*/
data class ClockDefaults(
val snoozeMinutes: Int = 10,
@@ -22,6 +25,7 @@ data class ClockDefaults(
val homeZoneId: String? = null,
val alarmSilenceAfterMinutes: Int = SilenceAfter.DEFAULT_MINUTES,
val timerSilenceAfterMinutes: Int = SilenceAfter.DEFAULT_MINUTES,
val volumeButtons: VolumeButtonAction = VolumeButtonAction.CONTROL_VOLUME,
) {
/** How long a ringing alarm rings before it is silenced as missed. */
val alarmSilenceAfter: Duration get() = SilenceAfter.window(alarmSilenceAfterMinutes)
@@ -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 {
@@ -56,5 +58,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, SILENCE_AFTER }
private enum class OpenAlarmPicker { VIBRATE, VOLUME_RAMP, SNOOZE_LENGTH, SNOOZE_LIMIT, CHALLENGE, SILENCE_AFTER, VOLUME_BUTTONS }
/**
* What every new alarm starts with: how it sounds, how it snoozes, and how it
@@ -97,6 +99,14 @@ internal fun AlarmDefaultsScreen(
onClick = { openPicker = OpenAlarmPicker.SILENCE_AFTER },
)
SettingsHint(stringResource(R.string.settings_alarm_silence_after_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) {
@@ -141,5 +151,23 @@ internal fun AlarmDefaultsScreen(
onSelect = { viewModel.onAlarmSilenceAfterChanged(it) },
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
},
)
@@ -15,6 +15,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
@@ -154,6 +155,9 @@ class SettingsViewModel @Inject internal constructor(
fun onTimerSilenceAfterChanged(minutes: Int) =
viewModelScope.launch { timerEngine.setTimerSilenceAfter(minutes) }
fun onVolumeButtonsChanged(value: VolumeButtonAction) =
viewModelScope.launch { settings.setVolumeButtons(value) }
// --- the default ringtone pickers ---
internal fun onOpenRingtonePicker(kind: DefaultRingtoneKind) {
+7
View File
@@ -312,6 +312,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>
@@ -517,6 +517,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
@@ -65,6 +66,7 @@ class BackupCodecTest {
homeZoneId = "America/New_York",
alarmSilenceAfterMinutes = 25,
timerSilenceAfterMinutes = SilenceAfter.NEVER,
volumeButtons = VolumeButtonAction.SNOOZE,
),
timerPresets = listOf(1.minutes, 2.minutes, 10.minutes),
bedtime = BedtimeSettings(
@@ -252,6 +254,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.clockula.domain.ring.SilenceAfter
import de.jeanlucmakiola.floret.prefs.PrefStore
import de.jeanlucmakiola.floret.prefs.ThemeMode
@@ -56,6 +57,7 @@ class ClockPrefsTest {
prefs.setHomeZoneId("Europe/Berlin")
prefs.setAlarmSilenceAfterMinutes(25)
prefs.setTimerSilenceAfterMinutes(SilenceAfter.NEVER)
prefs.setVolumeButtons(VolumeButtonAction.DISMISS)
assertThat(prefs.defaults.first()).isEqualTo(
ClockDefaults(
@@ -69,6 +71,7 @@ class ClockPrefsTest {
homeZoneId = "Europe/Berlin",
alarmSilenceAfterMinutes = 25,
timerSilenceAfterMinutes = SilenceAfter.NEVER,
volumeButtons = VolumeButtonAction.DISMISS,
),
)
}
@@ -123,6 +126,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)
@@ -11,6 +11,7 @@ import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.ClockDefaults
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import de.jeanlucmakiola.clockula.domain.alarm.VolumeButtonAction
import de.jeanlucmakiola.clockula.domain.ring.SilenceAfter
import de.jeanlucmakiola.clockula.domain.backup.BackupSettings
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeEffects
@@ -87,6 +88,7 @@ class SettingsPrefsTest {
alarmRingtoneUri = " ",
alarmSilenceAfterMinutes = 25,
timerSilenceAfterMinutes = 999,
volumeButtons = VolumeButtonAction.NOTHING,
),
timerPresets = listOf(5.minutes, 1.minutes, 5.minutes),
),
@@ -99,6 +101,7 @@ class SettingsPrefsTest {
assertThat(prefs.currentDefaults().alarmRingtoneUri).isNull()
assertThat(prefs.currentDefaults().alarmSilenceAfterMinutes).isEqualTo(25)
assertThat(prefs.currentDefaults().timerSilenceAfterMinutes).isEqualTo(SilenceAfter.NEVER)
assertThat(prefs.currentDefaults().volumeButtons).isEqualTo(VolumeButtonAction.NOTHING)
assertThat(prefs.timerPresets.first()).containsExactly(1.minutes, 5.minutes).inOrder()
}
@@ -0,0 +1,133 @@
package de.jeanlucmakiola.clockula.domain.alarm
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.Alarm
import de.jeanlucmakiola.clockula.domain.AlarmSettings
import de.jeanlucmakiola.clockula.domain.DismissChallenge
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import org.junit.jupiter.api.Test
import kotlin.time.Instant
/**
* A volume key is pressed blind, so what is asserted over the whole table is
* that it never dismisses past a challenge and never asks for a snooze that
* would be refused (and so turned into a dismissal).
*/
class VolumeButtonPolicyTest {
private val everyCase: List<Triple<VolumeButtonAction, Boolean, DismissChallenge>> =
VolumeButtonAction.entries.flatMap { action ->
listOf(true, false).flatMap { canSnooze ->
DismissChallenge.entries.map { challenge -> Triple(action, canSnooze, challenge) }
}
}
@Test
fun `change the volume leaves every key to the system`() {
for (canSnooze in listOf(true, false)) {
for (challenge in DismissChallenge.entries) {
assertThat(VolumeButtonPolicy.effect(VolumeButtonAction.CONTROL_VOLUME, canSnooze, challenge))
.isEqualTo(VolumeKeyEffect.PASS_THROUGH)
}
}
}
@Test
fun `do nothing swallows the key whatever the alarm`() {
for (canSnooze in listOf(true, false)) {
for (challenge in DismissChallenge.entries) {
assertThat(VolumeButtonPolicy.effect(VolumeButtonAction.NOTHING, canSnooze, challenge))
.isEqualTo(VolumeKeyEffect.IGNORE)
}
}
}
@Test
fun `snooze snoozes while snoozes are left`() {
assertThat(VolumeButtonPolicy.effect(VolumeButtonAction.SNOOZE, canSnooze = true, DismissChallenge.MATH))
.isEqualTo(VolumeKeyEffect.SNOOZE)
}
@Test
fun `snooze with none left goes back to changing the volume`() {
assertThat(VolumeButtonPolicy.effect(VolumeButtonAction.SNOOZE, canSnooze = false, DismissChallenge.NONE))
.isEqualTo(VolumeKeyEffect.PASS_THROUGH)
}
@Test
fun `dismiss dismisses an alarm without a challenge`() {
assertThat(VolumeButtonPolicy.effect(VolumeButtonAction.DISMISS, canSnooze = false, DismissChallenge.NONE))
.isEqualTo(VolumeKeyEffect.DISMISS)
}
@Test
fun `dismiss snoozes an alarm with a challenge instead`() {
assertThat(VolumeButtonPolicy.effect(VolumeButtonAction.DISMISS, canSnooze = true, DismissChallenge.HOLD))
.isEqualTo(VolumeKeyEffect.SNOOZE)
}
@Test
fun `dismiss goes back to changing the volume when a challenge stands and no snoozes are left`() {
assertThat(VolumeButtonPolicy.effect(VolumeButtonAction.DISMISS, canSnooze = false, DismissChallenge.MATH))
.isEqualTo(VolumeKeyEffect.PASS_THROUGH)
}
@Test
fun `a key never dismisses past a challenge`() {
for ((action, canSnooze, challenge) in everyCase) {
if (challenge == DismissChallenge.NONE) continue
assertThat(VolumeButtonPolicy.effect(action, canSnooze, challenge))
.isNotEqualTo(VolumeKeyEffect.DISMISS)
}
}
@Test
fun `a key never snoozes an alarm with no snoozes left`() {
for ((action, canSnooze, challenge) in everyCase) {
if (canSnooze) continue
assertThat(VolumeButtonPolicy.effect(action, canSnooze, challenge))
.isNotEqualTo(VolumeKeyEffect.SNOOZE)
}
}
@Test
fun `the session form reads the setting, the snooze budget and the challenge`() {
val session = RingSession(
alarm = Alarm(
id = 1L,
time = TimeOfDay(7, 0),
label = "",
enabled = true,
repeatDays = RepeatDays.NONE,
skipNextOccurrence = false,
ringtoneUri = null,
vibrate = null,
snoozeMinutes = null,
snoozeLimit = null,
volumeRampSeconds = null,
dismissChallenge = null,
createdAt = Instant.fromEpochMilliseconds(0L),
updatedAt = Instant.fromEpochMilliseconds(0L),
deleteAfterUse = false,
),
settings = AlarmSettings(
ringtoneUri = null,
vibrate = true,
snoozeMinutes = 10,
snoozeLimit = 3,
volumeRampSeconds = 15,
dismissChallenge = DismissChallenge.MATH,
),
startedAt = Instant.fromEpochMilliseconds(0L),
canSnooze = true,
volumeButtons = VolumeButtonAction.DISMISS,
)
assertThat(VolumeButtonPolicy.effect(session)).isEqualTo(VolumeKeyEffect.SNOOZE)
assertThat(VolumeButtonPolicy.effect(session.copy(canSnooze = false)))
.isEqualTo(VolumeKeyEffect.PASS_THROUGH)
assertThat(VolumeButtonPolicy.effect(session.copy(volumeButtons = VolumeButtonAction.CONTROL_VOLUME)))
.isEqualTo(VolumeKeyEffect.PASS_THROUGH)
}
}
@@ -10,6 +10,8 @@ import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.clockula.domain.DismissChallenge
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import de.jeanlucmakiola.clockula.domain.alarm.ChallengeGate
import de.jeanlucmakiola.clockula.domain.alarm.VolumeButtonAction
import de.jeanlucmakiola.clockula.domain.alarm.VolumeKeyEffect
import de.jeanlucmakiola.clockula.testing.BERLIN
import de.jeanlucmakiola.clockula.testing.FakeAlarmDao
import de.jeanlucmakiola.clockula.testing.FakeAlarmNotifier
@@ -66,6 +68,8 @@ class AlarmRingViewModelTest {
private val sevenToday = offsetInstant("2026-06-10T07:00+02:00")
private lateinit var settings: SettingsPrefs
private fun TestScope.engine(tempDir: Path): AlarmEngine = AlarmEngine(
alarms = alarms,
states = states,
@@ -76,7 +80,7 @@ class AlarmRingViewModelTest {
produceFile = { tempDir.resolve("clockula_prefs_test.preferences_pb").toFile() },
),
),
),
).also { settings = it },
scheduler = scheduler,
ring = ring,
notifier = notifier,
@@ -479,6 +483,126 @@ class AlarmRingViewModelTest {
assertThat(states.state(1L).ringingSince).isNotNull()
}
@Test
fun `volume keys change the volume by default`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
val engine = engine(tempDir)
givenRinging(engine)
val viewModel = viewModel(engine)
advanceUntilIdle()
assertThat(viewModel.volumeKeyEffect()).isEqualTo(VolumeKeyEffect.PASS_THROUGH)
}
@Test
fun `a volume key snoozes when asked to`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
val engine = engine(tempDir)
settings.setVolumeButtons(VolumeButtonAction.SNOOZE)
givenRinging(engine)
val viewModel = viewModel(engine)
advanceUntilIdle()
viewModel.onVolumeKey()
advanceUntilIdle()
assertThat(states.state(1L).snoozedUntil).isNotNull()
assertThat(viewModel.state.value).isEqualTo(RingUiState.Finished)
}
@Test
fun `a volume key dismisses an alarm without a challenge`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val engine = engine(tempDir)
settings.setVolumeButtons(VolumeButtonAction.DISMISS)
givenRinging(engine, challenge = DismissChallenge.NONE)
val viewModel = viewModel(engine)
advanceUntilIdle()
viewModel.onVolumeKey()
advanceUntilIdle()
val state = states.state(1L)
assertThat(listOf(state.ringingSince, state.snoozedUntil, viewModel.state.value))
.containsExactly(null, null, RingUiState.Finished)
.inOrder()
}
@Test
fun `a volume key set to dismiss snoozes past a challenge instead`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val engine = engine(tempDir)
settings.setVolumeButtons(VolumeButtonAction.DISMISS)
givenRinging(engine, challenge = DismissChallenge.MATH)
val viewModel = viewModel(engine)
advanceUntilIdle()
viewModel.onVolumeKey()
advanceUntilIdle()
assertThat(states.state(1L).snoozedUntil).isNotNull()
}
@Test
fun `a volume key never dismisses past a challenge with no snoozes left`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val engine = engine(tempDir)
settings.setVolumeButtons(VolumeButtonAction.DISMISS)
givenRinging(engine, challenge = DismissChallenge.HOLD, snoozeLimit = 0)
val viewModel = viewModel(engine)
advanceUntilIdle()
viewModel.onVolumeKey()
advanceUntilIdle()
assertThat(states.state(1L).ringingSince).isNotNull()
assertThat(viewModel.state.value).isInstanceOf(RingUiState.Ringing::class.java)
}
@Test
fun `a volume key snooze past a limit lowered mid-ring keeps the alarm ringing`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val engine = engine(tempDir)
settings.setVolumeButtons(VolumeButtonAction.SNOOZE)
givenRinging(engine)
val viewModel = viewModel(engine)
advanceUntilIdle()
check(viewModel.ringing().canSnooze)
settings.setSnoozeLimit(0)
viewModel.onVolumeKey()
advanceUntilIdle()
assertThat(states.state(1L).ringingSince).isNotNull()
assertThat(viewModel.ringing().canSnooze).isFalse()
}
@Test
fun `volume keys are left alone once another alarm has taken over the ring`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val engine = engine(tempDir)
settings.setVolumeButtons(VolumeButtonAction.DISMISS)
givenRinging(engine)
val viewModel = viewModel(engine)
advanceUntilIdle()
ring.startRinging(2L)
assertThat(viewModel.volumeKeyEffect()).isEqualTo(VolumeKeyEffect.PASS_THROUGH)
}
@Test
fun `volume keys are left alone until the session has loaded`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val engine = engine(tempDir)
settings.setVolumeButtons(VolumeButtonAction.DISMISS)
givenRinging(engine)
advanceUntilIdle()
val viewModel = viewModel(engine)
check(viewModel.state.value == RingUiState.Loading)
assertThat(viewModel.volumeKeyEffect()).isEqualTo(VolumeKeyEffect.PASS_THROUGH)
}
/** The prompt is `"47 + 38"`; the answer is what it says. */
private fun answerTo(prompt: String): String =
prompt.split("+").sumOf { it.trim().toInt() }.toString()
+3 -2
View File
@@ -86,7 +86,7 @@ kit module: the kit's roadmap keeps schedulers app-local.
| Package | Holds |
|---|---|
| `domain/` | `Alarm.kt`, `Timer.kt`, `WorldClock.kt`, `Stopwatch.kt`, `ClockDefaults.kt`, `Ringtone.kt` (the silent and bundled-tone sentinels), `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` |
@@ -599,7 +599,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` |
@@ -1065,6 +1065,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 |
| An unlimited-snooze option, per-alarm auto-silence | not planned for v1 |
| The volume-button setting (#18) while the alarm rings only as a heads-up over another app, or with the screen turned off (the system keeps volume keys from a non-interactive device's windows). A background app sees no key events; the one route, a remote-playback `MediaSession` whose `VolumeProvider` takes the keys, is refused by `MediaSessionRecord.canHandleVolumeKey` on Android 13+ for an app with no MediaRouter2 routing session, loses to whichever app is in front or played last, and shows a cast-volume slider and a phantom media player while it works. AOSP DeskClock and BlackyHawky's Clock handle the keys in their alarm activity only, as `AlarmRingActivity` does | not planned |
| Reordering timers by hand. `sort_order` and `TimerRepository.reorder` exist and stay caller-less: the list is in storage order, M5 gave the same answer for alarms, and a drag surface would also mean abandoning the `LazyColumn` | not planned for v1 |
| Per-timer vibrate, volume-ramp or dismiss-challenge overrides. `timers` has exactly one nullable settings column, its ringtone; four more would be a schema change for settings the roadmap does not ask for | not planned for v1 |
| A "stop all" for several expired timers, and undo after a timer delete. Each expired timer is acknowledged on its own, and the delete confirmation asks before rather than after | not planned for v1 |
+5 -1
View File
@@ -138,6 +138,7 @@ device or after a reboot (`docs/PLAN.md` §5). Every imported timer lands
"home_zone_id": null,
"default_alarm_silence_after_minutes": 10,
"default_timer_silence_after_minutes": 10,
"volume_buttons_while_ringing": "CONTROL_VOLUME",
"timer_presets": [60000, 120000, 180000, 300000, 600000, 900000, 1800000, 3600000],
"bedtime": {
"enabled": false,
@@ -166,7 +167,10 @@ the importing device creates its own (`docs/ARCHITECTURE.md` §18).
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.
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.
`default_alarm_silence_after_minutes` and `default_timer_silence_after_minutes`
are how long a ringing alarm or a finished timer keeps going before it stops on