fix(alarms): spend a date on the ring that used it, and harden date input

A dated one-shot whose time was moved later while it was snoozed rang a
second time that day, because closing the cycle only asked whether the
date was still ahead. It now keeps the alarm on only for a date after the
day that rang, and a one-shot switched off as its cycle closes lets its
date go.

The date pickers accept every year a stored date can have, so a far date
from a backup can no longer crash the editor. The widget formats its date
with the list's own formatter, and a backup's leftover date on the wrong
kind of alarm is ignored instead of rejecting the file.
This commit is contained in:
2026-10-22 08:11:35 +02:00
parent 301ce2fec9
commit c5de9becdf
9 changed files with 90 additions and 46 deletions
@@ -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()
}
}
@@ -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 {
@@ -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,
)
}
}
@@ -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,
@@ -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 -> ""
}
@@ -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()
}
}
@@ -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}]",
@@ -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)
+1 -1
View File
@@ -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,