fix: clamp an alarm's own snooze and ramp values when they are read

Backup import clamped the app-wide defaults but passed an alarm's own
snooze length, snooze limit and volume ramp through unchanged, so a
snooze of 0 re-rang at once and a negative limit turned snoozing off.
Both import and the database read now clamp them to the ranges the
pickers offer, and imported timer lengths to the setup range.
This commit is contained in:
makiolaj committed 2026-10-21 17:25:55 +02:00
1 parent 96fc9c19cf
commit 99b357f4fb
6 files changed
+54 -11

No files matched your search

@@ -2,6 +2,7 @@ package de.jeanlucmakiola.clockula.data.alarms
import de.jeanlucmakiola.clockula.domain.Alarm
import de.jeanlucmakiola.clockula.domain.AlarmDraft
import de.jeanlucmakiola.clockula.domain.ClockLimits
import de.jeanlucmakiola.clockula.domain.DismissChallenge
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.TimeOfDay
@@ -19,9 +20,9 @@ object AlarmMapper {
skipNextOccurrence = entity.skipNextOccurrence,
ringtoneUri = entity.ringtoneUri?.takeIf { it.isNotBlank() },
vibrate = entity.vibrate,
snoozeMinutes = entity.snoozeMinutes,
snoozeLimit = entity.snoozeLimit,
volumeRampSeconds = entity.volumeRampSeconds,
snoozeMinutes = entity.snoozeMinutes?.coerceIn(ClockLimits.SNOOZE_MINUTES),
snoozeLimit = entity.snoozeLimit?.coerceIn(ClockLimits.SNOOZE_LIMIT),
volumeRampSeconds = entity.volumeRampSeconds?.coerceIn(ClockLimits.VOLUME_RAMP_SECONDS),
// An absent challenge is an *override that isn't set*, so it stays null;
// a stored name the app no longer knows degrades to NONE.
dismissChallenge = entity.dismissChallenge?.toEnum(DismissChallenge.NONE),
@@ -1,6 +1,7 @@
package de.jeanlucmakiola.clockula.data.backup
import de.jeanlucmakiola.clockula.domain.ClockDefaults
import de.jeanlucmakiola.clockula.domain.ClockLimits
import de.jeanlucmakiola.clockula.domain.DismissChallenge
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.TimeOfDay
@@ -10,6 +11,7 @@ import de.jeanlucmakiola.clockula.domain.backup.BackupContent
import de.jeanlucmakiola.clockula.domain.backup.BackupSettings
import de.jeanlucmakiola.clockula.domain.backup.BackupTimer
import de.jeanlucmakiola.clockula.domain.backup.BackupWorldClock
import de.jeanlucmakiola.clockula.domain.timer.TimerPresets
import de.jeanlucmakiola.floret.prefs.ThemeMode
import de.jeanlucmakiola.floret.prefs.toEnum
import kotlinx.serialization.SerializationException
@@ -142,9 +144,11 @@ object BackupCodec {
skipNextOccurrence = dto.skipNextOccurrence,
ringtoneUri = dto.ringtoneUri?.takeIf { it.isNotBlank() },
vibrate = dto.vibrate,
snoozeMinutes = dto.snoozeMinutes,
snoozeLimit = dto.snoozeLimit,
volumeRampSeconds = dto.volumeRampSeconds,
// The same clamps the defaults pass through: an alarm's own
// override is no freer than the setting it overrides.
snoozeMinutes = dto.snoozeMinutes?.coerceIn(ClockLimits.SNOOZE_MINUTES),
snoozeLimit = dto.snoozeLimit?.coerceIn(ClockLimits.SNOOZE_LIMIT),
volumeRampSeconds = dto.volumeRampSeconds?.coerceIn(ClockLimits.VOLUME_RAMP_SECONDS),
dismissChallenge = dto.dismissChallenge?.toEnum(DismissChallenge.NONE),
deleteAfterUse = dto.deleteAfterUse,
)
@@ -160,7 +164,7 @@ object BackupCodec {
}
timers += BackupTimer(
label = dto.label,
duration = dto.durationMillis.milliseconds,
duration = dto.durationMillis.milliseconds.coerceIn(TimerPresets.MIN, TimerPresets.MAX),
ringtoneUri = dto.ringtoneUri?.takeIf { it.isNotBlank() },
sortOrder = dto.sortOrder,
deleteAfterUse = dto.deleteAfterUse,
@@ -1,5 +1,6 @@
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.floret.prefs.Pref
@@ -15,13 +16,13 @@ import de.jeanlucmakiola.floret.prefs.nullableStringPref
*/
object ClockPrefs {
val snoozeMinutes: Pref<Int> = intPref("default_snooze_minutes", 10).clampedTo(1..60)
val snoozeMinutes: Pref<Int> = intPref("default_snooze_minutes", 10).clampedTo(ClockLimits.SNOOZE_MINUTES)
val snoozeLimit: Pref<Int> = intPref("default_snooze_limit", 3).clampedTo(0..10)
val snoozeLimit: Pref<Int> = intPref("default_snooze_limit", 3).clampedTo(ClockLimits.SNOOZE_LIMIT)
val vibrate: Pref<Boolean> = booleanPref("default_vibrate", true)
val volumeRampSeconds: Pref<Int> = intPref("default_volume_ramp_seconds", 15).clampedTo(0..60)
val volumeRampSeconds: Pref<Int> = intPref("default_volume_ramp_seconds", 15).clampedTo(ClockLimits.VOLUME_RAMP_SECONDS)
val alarmRingtoneUri: Pref<String?> = nullableStringPref("default_alarm_ringtone_uri").blankAsAbsent()
@@ -14,3 +14,14 @@ data class ClockDefaults(
val dismissChallenge: DismissChallenge = DismissChallenge.NONE,
val homeZoneId: String? = null,
)
/**
* The ranges a user can choose from, for the defaults and for an alarm's own
* overrides alike. Anything read back from disk or a backup is clamped into
* them, so a value no picker offers never reaches the engine.
*/
object ClockLimits {
val SNOOZE_MINUTES: IntRange = 1..60
val SNOOZE_LIMIT: IntRange = 0..10
val VOLUME_RAMP_SECONDS: IntRange = 0..60
}
@@ -1,5 +1,7 @@
package de.jeanlucmakiola.clockula.domain.interop
import de.jeanlucmakiola.clockula.domain.ClockLimits
/** The bounds an extra from another process is read against (M9 D5, D19). */
object InteropLimits {
const val MAX_LABEL_LENGTH: Int = 256
@@ -12,7 +14,7 @@ object InteropLimits {
val TIMER_SECONDS: IntRange = 1..86_400
/** `ClockPrefs`' own clamp: an intent cannot ask for a snooze the user could not choose. */
val SNOOZE_MINUTES: IntRange = 1..60
val SNOOZE_MINUTES: IntRange = ClockLimits.SNOOZE_MINUTES
val RINGTONE_SCHEMES: List<String> = listOf("content://", "android.resource://")
}
@@ -10,6 +10,7 @@ import de.jeanlucmakiola.clockula.domain.backup.BackupContent
import de.jeanlucmakiola.clockula.domain.backup.BackupSettings
import de.jeanlucmakiola.clockula.domain.backup.BackupTimer
import de.jeanlucmakiola.clockula.domain.backup.BackupWorldClock
import de.jeanlucmakiola.clockula.domain.timer.TimerPresets
import de.jeanlucmakiola.floret.prefs.ThemeMode
import org.junit.jupiter.api.Test
import kotlin.time.Duration.Companion.minutes
@@ -233,6 +234,29 @@ class BackupCodecTest {
assertThat(content.alarms.single().dismissChallenge).isEqualTo(DismissChallenge.NONE)
}
@Test
fun `out-of-range alarm overrides are clamped to what a picker offers`() {
val json = minimalDocument().replace(
"\"alarms\": []",
"\"alarms\": [{\"hour\":7,\"minute\":0,\"snooze_minutes\":0," +
"\"snooze_limit\":-5,\"volume_ramp_seconds\":100000}]",
)
val alarm = (BackupCodec.decode(json) as BackupResult.Success).content.alarms.single()
assertThat(alarm.snoozeMinutes).isEqualTo(1)
assertThat(alarm.snoozeLimit).isEqualTo(0)
assertThat(alarm.volumeRampSeconds).isEqualTo(60)
}
@Test
fun `timer durations are clamped to the setup range`() {
val json = minimalDocument().replace(
"\"timers\": []",
"\"timers\": [{\"duration_millis\":0},{\"duration_millis\":${Long.MAX_VALUE}}]",
)
val timers = (BackupCodec.decode(json) as BackupResult.Success).content.timers
assertThat(timers.map { it.duration }).containsExactly(TimerPresets.MIN, TimerPresets.MAX).inOrder()
}
@Test
fun `garbage text fails rather than throwing`() {
assertThat(BackupCodec.decode("not json at all")).isInstanceOf(BackupResult.Failure::class.java)