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) + } +}