feat(reminders): work out reminder times ourselves (#75)

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) <noreply@anthropic.com>
This commit is contained in:
2026-07-26 18:48:10 +02:00
parent 1d07b64a28
commit 0676a58d1a
2 changed files with 449 additions and 0 deletions

View File

@@ -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<PlannedReminder>,
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<ReminderEventInstance>,
minutesByEvent: Map<Long, List<Int>>,
): List<PlannedReminder> = 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<PlannedReminder>,
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)

View File

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