From 3003f753332d44b5f1764dc131261b2d22cf3758 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 21 Oct 2026 17:37:11 +0200 Subject: [PATCH] fix: keep the built-in timer presets when a backup has none A backup without the timer_presets field decoded it as an empty list, which means "the user removed them all", so restoring it wiped the built-in presets. A missing field now keeps them. BACKUP.md describes this and the per-alarm clamps. --- .../clockula/data/backup/BackupCodec.kt | 4 ++-- .../clockula/data/backup/BackupDto.kt | 7 ++++++- .../clockula/data/backup/BackupCodecTest.kt | 13 +++++++++++++ docs/BACKUP.md | 6 ++++++ 4 files changed, 27 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt index f0b0795..b1b2d60 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt @@ -189,7 +189,7 @@ object BackupCodec { val settingsDto = settings if ((settingsDto.alarmRingtoneUri?.length ?: 0) > MAX_URI_CHARS || (settingsDto.timerRingtoneUri?.length ?: 0) > MAX_URI_CHARS || - settingsDto.timerPresets.size > 1_000 + (settingsDto.timerPresets?.size ?: 0) > 1_000 ) { return BackupResult.Failure("settings contain oversized values") } @@ -206,7 +206,7 @@ object BackupCodec { dismissChallenge = settingsDto.dismissChallenge.toEnum(DismissChallenge.NONE), homeZoneId = settingsDto.homeZoneId?.let(Zones::normalise), ), - timerPresets = settingsDto.timerPresets.map { it.milliseconds }, + timerPresets = settingsDto.timerPresets?.map { it.milliseconds } ?: TimerPresets.BUILT_IN, ) return BackupResult.Success( diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt index 35c15c5..118e99e 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt @@ -71,5 +71,10 @@ 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("timer_presets") val timerPresets: List = emptyList(), + /** + * 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 + * built-in ones. + */ + @SerialName("timer_presets") val timerPresets: List? = null, ) diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt index 2f3f13b..db57734 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt @@ -257,6 +257,19 @@ class BackupCodecTest { assertThat(timers.map { it.duration }).containsExactly(TimerPresets.MIN, TimerPresets.MAX).inOrder() } + @Test + fun `a file without timer presets keeps the built-in ones`() { + val settings = (BackupCodec.decode(minimalDocument()) as BackupResult.Success).content.settings + assertThat(settings.timerPresets).isEqualTo(TimerPresets.BUILT_IN) + } + + @Test + fun `an empty preset list is honoured as all removed`() { + val json = minimalDocument().replace("\"settings\": {}", "\"settings\": {\"timer_presets\": []}") + val settings = (BackupCodec.decode(json) as BackupResult.Success).content.settings + assertThat(settings.timerPresets).isEmpty() + } + @Test fun `garbage text fails rather than throwing`() { assertThat(BackupCodec.decode("not json at all")).isInstanceOf(BackupResult.Failure::class.java) diff --git a/docs/BACKUP.md b/docs/BACKUP.md index 28dda94..e8536de 100644 --- a/docs/BACKUP.md +++ b/docs/BACKUP.md @@ -38,6 +38,12 @@ written here. `ClockPrefs`/`SettingsPrefs` already apply on every write, so a value from a future, more permissive version degrades to the nearest valid one instead of failing the import. +- An alarm's own `snooze_minutes`, `snooze_limit` and `volume_ramp_seconds` + are clamped at parse time to the same ranges as the defaults they + override, and a timer's `duration_millis` to the setup range (1 second to + 24 hours). Room has no clamp of its own, so this is where it happens. +- A missing `timer_presets` keeps the built-in presets. An empty list is a + user who removed them all, and is imported as such. - An enum field with a name this version does not recognise (a `dismiss_ challenge` or `theme_mode` from a future version, say) degrades to that field's default, the same forgiving-read posture `AlarmMapper` already uses