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:
@@ -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}]",
|
||||
|
||||
-10
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user