diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmEngine.kt b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmEngine.kt index da3b040..df59d36 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmEngine.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmEngine.kt @@ -380,10 +380,21 @@ class AlarmEngine @Inject constructor( if (closed != state) states.save(closed) val alarm = alarms.find(alarmId) - // A one-shot given a date while it was snoozed still has that date to - // ring on, so this cycle closing does not switch it off (#21). - val dateAhead = alarm != null && AlarmSchedule.upcomingDate(alarm, wallClock.now(), zones.current()) != null - if (alarm != null && !alarm.repeatDays.isRepeating && !dateAhead) disableOrDeleteLocked(alarm) + if (alarm != null && !alarm.repeatDays.isRepeating) { + val zone = zones.current() + // A one-shot given a *later* date while it was snoozed still has + // that date to ring on, so this cycle closing does not switch it + // off (#21). A date on the day that just rang is the one this + // cycle used, whatever its time now says. + val ahead = AlarmSchedule.upcomingDate(alarm, wallClock.now(), zone) + val rangOn = state.handledOccurrence?.let { AlarmSchedule.today(it, zone) } + if (ahead == null || (rangOn != null && !ahead.isAfter(rangOn))) { + disableOrDeleteLocked(alarm) + // The date was spent on this ring: an alarm switched back on + // later is a plain one-shot, not "Scheduled for" a past day. + alarm.scheduledDate?.let { alarms.clearScheduledDate(alarm.id, it) } + } + } rescheduleLocked() } } 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 f9cbe1b..7fa0e7e 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 @@ -165,12 +165,12 @@ object BackupCodec { "alarms[$index]: label or ringtone URI is too long", ) } - val dates = dto.dates() + val repeatDays = RepeatDays.fromMask(dto.repeatDays) + val dates = dto.dates(repeatDays.isRepeating) ?: return BackupResult.Failure( BackupProblem.INVALID_DATA, "alarms[$index]: scheduled_date or pause is not a valid date range", ) - val repeatDays = RepeatDays.fromMask(dto.repeatDays) alarms += BackupAlarm( time = TimeOfDay(dto.hour, dto.minute), label = dto.label, @@ -186,10 +186,8 @@ object BackupCodec { volumeRampSeconds = dto.volumeRampSeconds?.coerceIn(ClockLimits.VOLUME_RAMP_SECONDS), dismissChallenge = dto.dismissChallenge?.toEnum(DismissChallenge.NONE), deleteAfterUse = dto.deleteAfterUse, - // The same exclusivity the mapper reads with: a date belongs - // to a one-shot alarm, a pause to a repeating one. - scheduledDate = dates.scheduledDate.takeUnless { repeatDays.isRepeating }, - pause = dates.pause.takeIf { repeatDays.isRepeating }, + scheduledDate = dates.scheduledDate, + pause = dates.pause, ) } @@ -271,20 +269,24 @@ object BackupCodec { * Null when a date is present but malformed, out of [AlarmDates]' range, or * a pause has only one end or ends before it starts. Strict, like the * alarm's own hour and minute: a date is the alarm's meaning, not a - * preference that can degrade to a default. + * preference that can degrade to a default. Only the fields the alarm can + * carry are read — a date belongs to a one-shot, a pause to a repeating + * alarm, as the mapper reads them — so a leftover on the other kind is + * dropped unread rather than able to reject the file. */ - private fun AlarmDto.dates(): AlarmDatesContent? { - val scheduled = scheduledDate?.let { parseDate(it) ?: return null } - if ((pauseStart == null) != (pauseEnd == null)) return null - val start = pauseStart?.let { parseDate(it) ?: return null } - val end = pauseEnd?.let { parseDate(it) ?: return null } - val pause = if (start != null && end != null) { - if (end.isBefore(start)) return null - PauseRange(start, end) - } else { - null + private fun AlarmDto.dates(repeating: Boolean): AlarmDatesContent? { + if (!repeating) { + val scheduled = scheduledDate?.let { parseDate(it) ?: return null } + return AlarmDatesContent(scheduled, null) } - return AlarmDatesContent(scheduled, pause) + val startText = pauseStart + val endText = pauseEnd + if (startText == null && endText == null) return AlarmDatesContent(null, null) + if (startText == null || endText == null) return null + val start = parseDate(startText) ?: return null + val end = parseDate(endText) ?: return null + if (end.isBefore(start)) return null + return AlarmDatesContent(null, PauseRange(start, end)) } private fun parseDate(text: String): LocalDate? = try { diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/NextAlarmWidgetModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/NextAlarmWidgetModel.kt index 1d3891b..1b95451 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/NextAlarmWidgetModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/NextAlarmWidgetModel.kt @@ -26,8 +26,6 @@ data class NextAlarmWidgetModel( * the coming week, where "Thursday" can only mean one day. */ val date: LocalDate? = null, - /** [date] falls in another year than today, so it needs the year to be read right. */ - val dateNeedsYear: Boolean = false, ) object NextAlarmWidgetPresenter { @@ -50,7 +48,6 @@ object NextAlarmWidgetPresenter { label = next.alarm.label, date = zonedFire.toLocalDate() .takeUnless { it.isBefore(zonedNow.toLocalDate().plusDays(DAYS_A_WEEKDAY_NAMES)) }, - dateNeedsYear = zonedFire.year != zonedNow.year, ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmDatePickers.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmDatePickers.kt index ba184af..7187fd2 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmDatePickers.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmDatePickers.kt @@ -1,10 +1,10 @@ package de.jeanlucmakiola.clockula.ui.alarms +import android.content.Context import android.text.format.DateUtils import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.material3.DatePicker -import androidx.compose.material3.DatePickerDefaults import androidx.compose.material3.DatePickerDialog import androidx.compose.material3.DateRangePicker import androidx.compose.material3.ExperimentalMaterial3Api @@ -50,8 +50,12 @@ internal object PickerDates { override fun isSelectableYear(year: Int): Boolean = year >= earliest.year } - fun yearRange(earliest: LocalDate): IntRange = - earliest.year..maxOf(earliest.year, DatePickerDefaults.YearRange.last) + /** + * Up to [AlarmDates.MAX], not the picker's default 2100: an imported date + * past the default would be an initial selection outside the range, + * which the picker state refuses by throwing. + */ + fun yearRange(earliest: LocalDate): IntRange = earliest.year..AlarmDates.MAX.year } /** @@ -182,12 +186,23 @@ private fun formatDates(start: LocalDate, end: LocalDate, extraFlags: Int): Stri val context = LocalContext.current val configuration = LocalConfiguration.current return remember(start, end, extraFlags, configuration) { + AlarmDateText.format(context, Locale.getDefault(), start, end, extraFlags) + } +} + +/** The platform's localised dates, for the list, the editor and the widget alike. */ +internal object AlarmDateText { + /** "Wed, Oct 28"; the year only when it is not this one. */ + fun weekdayDate(context: Context, locale: Locale, date: LocalDate): String = + format(context, locale, date, date, DateUtils.FORMAT_SHOW_WEEKDAY or DateUtils.FORMAT_ABBREV_WEEKDAY) + + fun format(context: Context, locale: Locale, start: LocalDate, end: LocalDate, extraFlags: Int): String { // Noon, not midnight: an end at exactly midnight reads to the platform // as "up to the start of that day" and would lose the range's last day. val noon = DateUtils.HOUR_IN_MILLIS * 12 - DateUtils.formatDateRange( + return DateUtils.formatDateRange( context, - Formatter(StringBuilder(), Locale.getDefault()), + Formatter(StringBuilder(), locale), PickerDates.toMillis(start) + noon, PickerDates.toMillis(end) + noon, DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_ABBREV_MONTH or extraFlags, diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/NextAlarmWidgetContent.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/NextAlarmWidgetContent.kt index eb05606..fb9eb69 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/NextAlarmWidgetContent.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/NextAlarmWidgetContent.kt @@ -21,6 +21,7 @@ import androidx.glance.text.Text import androidx.glance.text.TextStyle import de.jeanlucmakiola.clockula.R import de.jeanlucmakiola.clockula.alarm.AlarmIntents +import de.jeanlucmakiola.clockula.ui.alarms.AlarmDateText import de.jeanlucmakiola.clockula.domain.widget.NextAlarmWidgetModel import java.time.LocalTime import java.time.format.DateTimeFormatter @@ -139,11 +140,9 @@ private fun timeLabel(context: Context, model: NextAlarmWidgetModel): String { private fun dayLabel(context: Context, model: NextAlarmWidgetModel): String = when { model.isToday -> context.getString(R.string.widget_next_alarm_today) - model.date != null -> currentLocale(context).let { locale -> - // The year only when it is not this one, as the alarm list does. - val skeleton = if (model.dateNeedsYear) "yEEEMMMd" else "EEEMMMd" - DateTimeFormatter.ofPattern(DateFormat.getBestDateTimePattern(locale, skeleton), locale).format(model.date) - } + // The alarm list's own formatter, so both read alike — the year included + // only when it is not this one. + model.date != null -> AlarmDateText.weekdayDate(context, currentLocale(context), model.date) model.dayOfWeek != null -> model.dayOfWeek.getDisplayName(JavaTextStyle.FULL, Locale.getDefault()) else -> "" } diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/alarm/AlarmEngineScheduleTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/alarm/AlarmEngineScheduleTest.kt index 030a2fe..5de2f56 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/alarm/AlarmEngineScheduleTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/alarm/AlarmEngineScheduleTest.kt @@ -137,4 +137,25 @@ class AlarmEngineScheduleTest { assertThat(harness.alarms.find(1L)!!.enabled).isTrue() assertThat(harness.scheduler.next).isEqualTo(Registration(offsetInstant("2026-06-18T07:00+02:00"), 1L)) } + + @Test + fun `moving the time of a snoozed dated alarm later that day does not make it ring twice`( + @TempDir tempDir: Path, + ) = runTest { + val harness = alarmEngineHarness(tempDir) + harness.alarmDao.insert( + AlarmMapper.toEntity(alarmAt(hour = 7).copy(scheduledDate = LocalDate.of(2026, 6, 10))), + ) + harness.engine.reschedule() + harness.givenRinging(alarmId = 1L, at = offsetInstant("2026-06-10T07:00+02:00")) + harness.engine.snooze(1L) + harness.alarms.edit(1L) { it.copy(time = de.jeanlucmakiola.clockula.domain.TimeOfDay(9, 0)) } + + harness.engine.dismiss(1L) + + val alarm = harness.alarms.find(1L)!! + assertThat(alarm.enabled).isFalse() + assertThat(alarm.scheduledDate).isNull() + assertThat(harness.scheduler.next).isNull() + } } 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 44bc8cf..fea605f 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 @@ -230,6 +230,15 @@ class BackupCodecTest { assertThat(pausedOneShot.content.alarms.single().pause).isNull() } + @Test + fun `a malformed leftover on the wrong kind of alarm is dropped, not fatal`() { + val repeating = BackupCodec.decode(alarmDocument("\"repeat_days\":31,\"scheduled_date\":\"2026-13-01\"")) + val oneShot = BackupCodec.decode(alarmDocument("\"pause_start\":\"2026-10-26\"")) + + assertThat(repeating).isInstanceOf(BackupResult.Success::class.java) + assertThat(oneShot).isInstanceOf(BackupResult.Success::class.java) + } + private fun alarmDocument(fields: String): String = minimalDocument(version = 2).replace( "\"alarms\": []", "\"alarms\": [{\"hour\":7,\"minute\":0,$fields}]", diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/widget/NextAlarmWidgetModelTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/widget/NextAlarmWidgetModelTest.kt index d58d234..bb4e756 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/domain/widget/NextAlarmWidgetModelTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/widget/NextAlarmWidgetModelTest.kt @@ -95,18 +95,8 @@ class NextAlarmWidgetModelTest { val model = NextAlarmWidgetPresenter.present(rows, T0, zone) assertThat(model.date).isEqualTo(fireAt.toJavaTime(zone).toLocalDate()) - assertThat(model.dateNeedsYear).isFalse() } - @Test - fun `a date in another year says so`() { - // T0 is 2023-11-14; seven weeks on is 2024. - val rows = listOf(upcomingAlarm(nextFire = T0 + 49.days)) - - val model = NextAlarmWidgetPresenter.present(rows, T0, zone) - - assertThat(model.dateNeedsYear).isTrue() - } private fun Instant.toJavaTime(zone: ZoneId): ZonedDateTime = ZonedDateTime.ofInstant(toJavaInstant(), zone) diff --git a/docs/BACKUP.md b/docs/BACKUP.md index 1ef338c..3ae0c50 100644 --- a/docs/BACKUP.md +++ b/docs/BACKUP.md @@ -105,7 +105,7 @@ One object per row of `alarms`, config only: A date that does not parse, lies outside 2000-01-01..9999-12-31, a pause with only one end, or a pause ending before it starts rejects the whole file, like an out-of-range `hour`. A date on a repeating alarm or a pause on a one-shot is -dropped rather than rejected, which is what the editor would do with it. +ignored unread, valid or not, which is what the editor would do with it. **Not exported:** the `alarm_states` ring-state table (snooze/ringing/handled- occurrence bookkeeping). It is volatile, per-device state, not configuration,