From 29857495be30cd5b725823053271ff926d5319b4 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 26 Jul 2026 18:48:10 +0200 Subject: [PATCH] feat(reminders): work out reminder times ourselves (#75) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reporter's silent events sit in a calendar Calendula created itself, so `Calendars.VISIBLE` is 1 and the visibility fix cannot be what they hit. What is left is the class AOSP's own unbundled calendar already carries three workarounds for: OEM providers that retarget the `EVENT_REMINDER` broadcast, or that only write the `CalendarAlerts` row at alert time. An app that can only react to that broadcast cannot tell "no reminder was due" from "the broadcast never came", so reacting has to stop being the whole design. This is the decision layer, kept pure and off Android: reminder offsets and occurrences in, fire instants out, plus which of them a given scan owes. The watermark is what replaces the provider's `STATE_FIRED` bookkeeping. Due means the fire instant falls in `(lastFired, now]` — half-open, so a scan that runs twice cannot post the same reminder twice, while a scan that runs late still posts everything the missed alarm would have. A reboot or an app update that drops our alarm therefore costs nothing. All-day offsets are measured from the raw `begin` with no timezone correction, because `AllDayReminderEncoding` already folded the wanted wall-clock time into the stored offset measured from exactly that UTC midnight. The query horizon stretches past the longest offset any reminder row carries, so a "two weeks before" reminder is planned before it comes due rather than firing late — the limitation Etar's equivalent documents and lives with. Co-Authored-By: Claude Opus 5 (1M context) --- .../domain/reminders/ReminderPlan.kt | 141 ++++++++ .../domain/reminders/ReminderPlanTest.kt | 308 ++++++++++++++++++ 2 files changed, 449 insertions(+) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/domain/reminders/ReminderPlan.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/domain/reminders/ReminderPlanTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/reminders/ReminderPlan.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/reminders/ReminderPlan.kt new file mode 100644 index 0000000..9eedc95 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/reminders/ReminderPlan.kt @@ -0,0 +1,141 @@ +package de.jeanlucmakiola.calendula.domain.reminders + +/** + * Works out *when* each reminder has to fire and *which* ones are due, with no + * provider and no clock of its own — the whole decision layer of in-house + * reminder delivery (#75). + * + * Calendula used to leave both halves to the calendar provider: it scheduled the + * alarms, wrote the `CalendarAlerts` rows, and broadcast `EVENT_REMINDER` at the + * right moment. That chain is intact on stock Android but demonstrably not on + * every device — AOSP's own unbundled calendar carries three separate + * workarounds for OEMs that retarget the broadcast, or that only write the alert + * row at alert time. An app that can only *react* to that broadcast has no way + * to notice it never came. + * + * So the offsets in `CalendarContract.Reminders` are now read as data and turned + * into alarms we own. Everything here is pure: instances and reminder offsets in, + * fire instants out. + */ + +/** An occurrence that reminders can hang off, flattened out of `Instances`. */ +data class ReminderEventInstance( + val eventId: Long, + val calendarId: Long, + val beginMillis: Long, + val endMillis: Long, + val title: String, + val location: String?, + val isAllDay: Boolean, +) + +/** + * One occurrence paired with one of its reminder offsets, and the instant that + * pairing has to fire at. + */ +data class PlannedReminder( + val instance: ReminderEventInstance, + val minutes: Int, + val alarmMillis: Long, +) { + /** + * Stable identity of this reminder, derived from what defines it rather + * than from a provider row id (there is none any more). It keys the + * notification tag and the snooze/dismiss `PendingIntent`s, so it has to + * survive a reboot, a re-scan and a reinstall — the same reminder must land + * on the same notification instead of stacking a second one. + */ + val key: Long = key(instance.eventId, instance.beginMillis, minutes) + + private companion object { + fun key(eventId: Long, beginMillis: Long, minutes: Int): Long { + var h = eventId * 1_000_003L + h = (h xor beginMillis) * 31L + return h + minutes + } + } +} + +/** What one scan concluded: post these now, and wake up again at [nextAlarmMillis]. */ +data class ReminderSchedule( + val due: List, + val nextAlarmMillis: Long, +) + +private const val MILLIS_PER_MINUTE = 60_000L + +/** + * Pair every instance with each of its event's reminder offsets. + * + * The fire instant is `begin − minutes` for timed **and** all-day occurrences + * alike. That is not an oversight: an all-day instance's `begin` is UTC midnight, + * and `AllDayReminderEncoding` deliberately encodes the wanted wall-clock time + * *into* the stored offset measured from exactly that point. Applying a second, + * timezone-dependent correction here would undo it. + * + * [minutesByEvent] may hold duplicate offsets (two identical reminder rows on one + * event); they collapse, because they would otherwise fight over one notification. + */ +fun planReminders( + instances: List, + minutesByEvent: Map>, +): List = instances.flatMap { instance -> + minutesByEvent[instance.eventId].orEmpty().distinct().map { minutes -> + PlannedReminder( + instance = instance, + minutes = minutes, + alarmMillis = instance.beginMillis - minutes * MILLIS_PER_MINUTE, + ) + } +} + +/** + * Split [planned] into what is due now and when to wake up next. + * + * Due means the fire instant falls in `(lastFiredMillis, nowMillis]` — a + * half-open watermark, so a scan triggered twice cannot post the same reminder + * twice, while a scan that runs late still catches everything the missed alarm + * would have posted. That catch-up is the point: an alarm dropped by a reboot, + * an app update or a doze window is recovered by the next scan rather than lost. + * + * A reminder whose event has already ended is dropped rather than posted late — + * see [isStillRelevant]. + * + * [nextAlarmMillis] is capped at [horizonMillis] even when nothing is pending, so + * the scan re-runs at least that often and the lookahead window rolls forward. + */ +fun scheduleReminders( + planned: List, + lastFiredMillis: Long, + nowMillis: Long, + horizonMillis: Long, +): ReminderSchedule { + val due = planned + .filter { it.alarmMillis in (lastFiredMillis + 1)..nowMillis } + .filter { it.instance.isStillRelevant(nowMillis) } + .distinctBy { it.key } + .sortedWith(compareBy({ it.instance.beginMillis }, { it.key })) + val nextPending = planned + .filter { it.alarmMillis > nowMillis } + .minOfOrNull { it.alarmMillis } + return ReminderSchedule( + due = due, + nextAlarmMillis = minOf(nextPending ?: horizonMillis, horizonMillis), + ) +} + +/** + * Still worth showing while the occurrence has not ended. Falls back to the + * begin time when the end is unknown (0L). + */ +fun ReminderEventInstance.isStillRelevant(nowMillis: Long): Boolean = + (endMillis.takeIf { it > 0L } ?: beginMillis) >= nowMillis + +/** + * How far ahead instances must be queried for [scheduleReminders] to see every + * reminder in time: the plain lookahead plus the longest offset any reminder row + * carries, so a "two weeks before" reminder is planned before it comes due + * instead of firing late (the limitation Etar's equivalent documents). + */ +fun reminderQueryHorizon(lookaheadMillis: Long, maxReminderMinutes: Int): Long = + lookaheadMillis + maxOf(0L, maxReminderMinutes * MILLIS_PER_MINUTE) diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/reminders/ReminderPlanTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/reminders/ReminderPlanTest.kt new file mode 100644 index 0000000..c59e4ad --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/reminders/ReminderPlanTest.kt @@ -0,0 +1,308 @@ +package de.jeanlucmakiola.calendula.domain.reminders + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +/** + * The decision layer of in-house reminder delivery (#75). The watermark rules are + * the load-bearing part: they are what makes a dropped alarm recoverable and a + * double scan harmless, now that no provider row records "already fired". + */ +class ReminderPlanTest { + + private val now = 1_700_000_000_000L + private val minute = 60_000L + private val day = 24 * 60 * minute + + private fun instance( + eventId: Long, + beginMillis: Long = now + 30 * minute, + endMillis: Long = now + 90 * minute, + calendarId: Long = 7L, + isAllDay: Boolean = false, + ) = ReminderEventInstance( + eventId = eventId, + calendarId = calendarId, + beginMillis = beginMillis, + endMillis = endMillis, + title = "Event $eventId", + location = null, + isAllDay = isAllDay, + ) + + @Test + fun `a reminder fires its offset before the occurrence begins`() { + val begin = now + 30 * minute + val planned = planReminders( + instances = listOf(instance(1L, beginMillis = begin)), + minutesByEvent = mapOf(1L to listOf(10)), + ) + + assertThat(planned.map { it.alarmMillis }).containsExactly(begin - 10 * minute) + } + + @Test + fun `an all-day offset is measured from the raw begin, not corrected again`() { + // An all-day instance begins at UTC midnight and AllDayReminderEncoding + // already folded the wanted wall-clock time into the stored offset, so a + // second timezone correction here would move the reminder off it. + val utcMidnight = 1_700_006_400_000L + val planned = planReminders( + instances = listOf(instance(1L, beginMillis = utcMidnight, isAllDay = true)), + minutesByEvent = mapOf(1L to listOf(1_020)), + ) + + assertThat(planned.single().alarmMillis).isEqualTo(utcMidnight - 1_020 * minute) + } + + @Test + fun `a negative offset fires after the occurrence begins`() { + // "At time of event" on an all-day event encodes to a negative offset. + val begin = now + 30 * minute + val planned = planReminders( + instances = listOf(instance(1L, beginMillis = begin, isAllDay = true)), + minutesByEvent = mapOf(1L to listOf(-420)), + ) + + assertThat(planned.single().alarmMillis).isEqualTo(begin + 420 * minute) + } + + @Test + fun `every occurrence of a series gets its own reminder`() { + val planned = planReminders( + instances = listOf( + instance(1L, beginMillis = now + day), + instance(1L, beginMillis = now + 2 * day), + ), + minutesByEvent = mapOf(1L to listOf(15)), + ) + + assertThat(planned.map { it.alarmMillis }) + .containsExactly(now + day - 15 * minute, now + 2 * day - 15 * minute) + assertThat(planned.map { it.key }.toSet()).hasSize(2) + } + + @Test + fun `duplicate reminder rows collapse to one`() { + val planned = planReminders( + instances = listOf(instance(1L)), + minutesByEvent = mapOf(1L to listOf(10, 10)), + ) + + assertThat(planned).hasSize(1) + } + + @Test + fun `an event with no reminders plans nothing`() { + val planned = planReminders( + instances = listOf(instance(1L)), + minutesByEvent = emptyMap(), + ) + + assertThat(planned).isEmpty() + } + + @Test + fun `the same reminder keeps its key across scans`() { + val plan = { + planReminders(listOf(instance(1L)), mapOf(1L to listOf(10))).single().key + } + + assertThat(plan()).isEqualTo(plan()) + } + + @Test + fun `occurrences of one series get different keys`() { + val planned = planReminders( + instances = listOf( + instance(1L, beginMillis = now + day), + instance(1L, beginMillis = now + 2 * day), + ), + minutesByEvent = mapOf(1L to listOf(15)), + ) + + assertThat(planned[0].key).isNotEqualTo(planned[1].key) + } + + @Test + fun `two reminders on one occurrence get different keys`() { + val planned = planReminders( + instances = listOf(instance(1L)), + minutesByEvent = mapOf(1L to listOf(10, 30)), + ) + + assertThat(planned[0].key).isNotEqualTo(planned[1].key) + } + + @Test + fun `a reminder whose moment has passed since the last scan is due`() { + val planned = planReminders( + instances = listOf(instance(1L, beginMillis = now + 5 * minute)), + minutesByEvent = mapOf(1L to listOf(10)), + ) + + val schedule = scheduleReminders( + planned, lastFiredMillis = now - 10 * minute, nowMillis = now, + horizonMillis = now + day, + ) + + assertThat(schedule.due).hasSize(1) + } + + @Test + fun `a reminder already covered by the watermark does not fire twice`() { + // The scan runs again (a provider change, a reboot) after the alarm that + // already posted this one. Nothing records "fired" but the watermark. + val planned = planReminders( + instances = listOf(instance(1L, beginMillis = now + 5 * minute)), + minutesByEvent = mapOf(1L to listOf(10)), + ) + + val schedule = scheduleReminders( + planned, lastFiredMillis = now - 4 * minute, nowMillis = now, + horizonMillis = now + day, + ) + + assertThat(schedule.due).isEmpty() + } + + @Test + fun `a reminder exactly on the watermark does not fire again`() { + val begin = now + 5 * minute + val planned = planReminders( + instances = listOf(instance(1L, beginMillis = begin)), + minutesByEvent = mapOf(1L to listOf(10)), + ) + val alarm = planned.single().alarmMillis + + val schedule = scheduleReminders( + planned, lastFiredMillis = alarm, nowMillis = now, horizonMillis = now + day, + ) + + assertThat(schedule.due).isEmpty() + } + + @Test + fun `a missed alarm is caught up by a much later scan`() { + // The device was off over the reminder; the scan on boot must still post it + // while the event is ahead. This is what the provider path could never do. + val planned = planReminders( + instances = listOf(instance(1L, beginMillis = now + 5 * minute)), + minutesByEvent = mapOf(1L to listOf(60)), + ) + + val schedule = scheduleReminders( + planned, lastFiredMillis = now - 5 * day, nowMillis = now, + horizonMillis = now + day, + ) + + assertThat(schedule.due).hasSize(1) + } + + @Test + fun `a reminder for an occurrence that already ended is dropped`() { + val planned = planReminders( + instances = listOf( + instance(1L, beginMillis = now - 3 * 60 * minute, endMillis = now - 2 * 60 * minute), + ), + minutesByEvent = mapOf(1L to listOf(10)), + ) + + val schedule = scheduleReminders( + planned, lastFiredMillis = now - 5 * day, nowMillis = now, + horizonMillis = now + day, + ) + + assertThat(schedule.due).isEmpty() + } + + @Test + fun `an occurrence with no end falls back to its begin for relevance`() { + val planned = planReminders( + instances = listOf( + instance(1L, beginMillis = now - minute, endMillis = 0L), + ), + minutesByEvent = mapOf(1L to listOf(10)), + ) + + val schedule = scheduleReminders( + planned, lastFiredMillis = now - day, nowMillis = now, horizonMillis = now + day, + ) + + assertThat(schedule.due).isEmpty() + } + + @Test + fun `due reminders come out in occurrence order`() { + val planned = planReminders( + instances = listOf( + instance(1L, beginMillis = now + 20 * minute), + instance(2L, beginMillis = now + 5 * minute), + ), + minutesByEvent = mapOf(1L to listOf(30), 2L to listOf(30)), + ) + + val schedule = scheduleReminders( + planned, lastFiredMillis = now - day, nowMillis = now, horizonMillis = now + day, + ) + + assertThat(schedule.due.map { it.instance.eventId }).containsExactly(2L, 1L).inOrder() + } + + @Test + fun `the next wake-up is the earliest reminder still ahead`() { + val planned = planReminders( + instances = listOf( + instance(1L, beginMillis = now + 20 * minute), + instance(2L, beginMillis = now + 90 * minute), + ), + minutesByEvent = mapOf(1L to listOf(5), 2L to listOf(5)), + ) + + val schedule = scheduleReminders( + planned, lastFiredMillis = now, nowMillis = now, horizonMillis = now + day, + ) + + assertThat(schedule.nextAlarmMillis).isEqualTo(now + 15 * minute) + } + + @Test + fun `with nothing pending the scan still re-runs at the horizon`() { + val schedule = scheduleReminders( + planned = emptyList(), lastFiredMillis = now, nowMillis = now, + horizonMillis = now + day, + ) + + assertThat(schedule.nextAlarmMillis).isEqualTo(now + day) + } + + @Test + fun `a reminder beyond the horizon waits for the next scan`() { + // Capping keeps the rolling window honest: the far-off reminder is picked + // up by a later scan rather than pinned to an alarm we may never re-check. + val planned = planReminders( + instances = listOf(instance(1L, beginMillis = now + 30 * day)), + minutesByEvent = mapOf(1L to listOf(5)), + ) + + val schedule = scheduleReminders( + planned, lastFiredMillis = now, nowMillis = now, horizonMillis = now + day, + ) + + assertThat(schedule.nextAlarmMillis).isEqualTo(now + day) + } + + @Test + fun `the query horizon stretches past the longest reminder offset`() { + // A "2 weeks before" reminder has to be planned while its event is still + // outside the plain lookahead, or it fires late. + assertThat(reminderQueryHorizon(lookaheadMillis = 7 * day, maxReminderMinutes = 20_160)) + .isEqualTo(7 * day + 14 * day) + } + + @Test + fun `a negative longest offset does not shrink the query horizon`() { + assertThat(reminderQueryHorizon(lookaheadMillis = 7 * day, maxReminderMinutes = -420)) + .isEqualTo(7 * day) + } +}