Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/105
This commit is contained in:
@@ -1,11 +1,10 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
import java.time.Instant
|
import de.jeanlucmakiola.calendula.domain.reminders.allDayLeadDays
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.LocalTime
|
import java.time.LocalTime
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.ZoneOffset
|
import java.time.ZoneOffset
|
||||||
import java.time.temporal.ChronoUnit
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Translates an all-day reminder between the **semantic** lead time the UI
|
* Translates an all-day reminder between the **semantic** lead time the UI
|
||||||
@@ -73,19 +72,14 @@ internal fun nextYearlyOccurrence(month: Int, day: Int, today: LocalDate): Local
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Recover the semantic whole-day lead time from a raw all-day reminder
|
* Recover the semantic whole-day lead time from a raw all-day reminder
|
||||||
* [rawMinutes]. Keys off the **local date** of the encoded fire instant, so it
|
* [rawMinutes] — the inverse of [toProviderAllDayMinutes], for the form and the
|
||||||
* returns the right day count regardless of which [timeOfDayMinutes] wrote the
|
* detail screen. Delegates to [allDayLeadDays] so what is displayed is the day
|
||||||
* row — including pre-feature rows (raw multiples of 1440, fired at UTC midnight)
|
* the reminder actually fires on; see there for how an encoded row is told from
|
||||||
* and rows written under a different timezone. A negative [rawMinutes] (fire
|
* a plain one written by another calendar app.
|
||||||
* after DTSTART) folds to day 0.
|
|
||||||
*/
|
*/
|
||||||
internal fun fromProviderAllDayMinutes(
|
internal fun fromProviderAllDayMinutes(
|
||||||
rawMinutes: Int,
|
rawMinutes: Int,
|
||||||
startDate: LocalDate,
|
startDate: LocalDate,
|
||||||
zone: ZoneId,
|
zone: ZoneId,
|
||||||
): Int {
|
timeOfDayMinutes: Int,
|
||||||
val utcMidnight = startDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
): Int = allDayLeadDays(rawMinutes, startDate, zone, timeOfDayMinutes).toInt() * MINUTES_PER_DAY
|
||||||
val fireLocalDate = Instant.ofEpochMilli(utcMidnight - rawMinutes * MILLIS_PER_MINUTE)
|
|
||||||
.atZone(zone).toLocalDate()
|
|
||||||
return ChronoUnit.DAYS.between(fireLocalDate, startDate).toInt() * MINUTES_PER_DAY
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -54,7 +54,12 @@ import javax.inject.Singleton
|
|||||||
interface CalendarDataSource {
|
interface CalendarDataSource {
|
||||||
fun calendars(): List<CalendarSource>
|
fun calendars(): List<CalendarSource>
|
||||||
fun instances(beginMillis: Long, endMillis: Long): List<EventInstance>
|
fun instances(beginMillis: Long, endMillis: Long): List<EventInstance>
|
||||||
fun eventDetail(eventId: Long): EventDetail?
|
/**
|
||||||
|
* [allDayReminderTimeMinutes]: the hour all-day reminders fire at, needed to
|
||||||
|
* decode their stored offsets back to whole-day lead times (see
|
||||||
|
* [fromProviderAllDayMinutes]).
|
||||||
|
*/
|
||||||
|
fun eventDetail(eventId: Long, allDayReminderTimeMinutes: Int): EventDetail?
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Master/one-off events whose title, description or location contains
|
* Master/one-off events whose title, description or location contains
|
||||||
@@ -670,7 +675,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun eventDetail(eventId: Long): EventDetail? {
|
override fun eventDetail(eventId: Long, allDayReminderTimeMinutes: Int): EventDetail? {
|
||||||
val attendees = queryAttendees(eventId)
|
val attendees = queryAttendees(eventId)
|
||||||
val reminders = queryReminders(eventId)
|
val reminders = queryReminders(eventId)
|
||||||
return resolver.query(
|
return resolver.query(
|
||||||
@@ -679,7 +684,9 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
null, null, null,
|
null, null, null,
|
||||||
)?.use { c ->
|
)?.use { c ->
|
||||||
if (!c.moveToFirst()) null
|
if (!c.moveToFirst()) null
|
||||||
else CursorColumnReader(c).toEventDetailCore(attendees, reminders)
|
else CursorColumnReader(c).toEventDetailCore(
|
||||||
|
attendees, reminders, allDayReminderTimeMinutes,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -179,7 +179,8 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) {
|
override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) {
|
||||||
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId)
|
dataSource.eventDetail(eventId, allDayReminderTimeMinutes())
|
||||||
|
?: throw NoSuchEventException(eventId)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun searchEvents(query: String): List<EventInstance> = withContext(io) {
|
override suspend fun searchEvents(query: String): List<EventInstance> = withContext(io) {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ private const val TAG = "EventDetailMapper"
|
|||||||
internal fun ColumnReader.toEventDetailCore(
|
internal fun ColumnReader.toEventDetailCore(
|
||||||
attendees: List<Attendee>,
|
attendees: List<Attendee>,
|
||||||
reminders: List<Reminder>,
|
reminders: List<Reminder>,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
): EventDetail? {
|
): EventDetail? {
|
||||||
// DTSTART is epoch millis in UTC, so a series anchored before 1970 (common
|
// DTSTART is epoch millis in UTC, so a series anchored before 1970 (common
|
||||||
// for yearly birthdays/anniversaries synced over CalDAV) is legitimately
|
// for yearly birthdays/anniversaries synced over CalDAV) is legitimately
|
||||||
@@ -89,7 +90,13 @@ internal fun ColumnReader.toEventDetailCore(
|
|||||||
val displayReminders = if (isAllDay) {
|
val displayReminders = if (isAllDay) {
|
||||||
val startDate = Instant.ofEpochMilli(begin).atZone(ZoneOffset.UTC).toLocalDate()
|
val startDate = Instant.ofEpochMilli(begin).atZone(ZoneOffset.UTC).toLocalDate()
|
||||||
val zone = ZoneId.systemDefault()
|
val zone = ZoneId.systemDefault()
|
||||||
reminders.map { it.copy(minutes = fromProviderAllDayMinutes(it.minutes, startDate, zone)) }
|
reminders.map {
|
||||||
|
it.copy(
|
||||||
|
minutes = fromProviderAllDayMinutes(
|
||||||
|
it.minutes, startDate, zone, allDayReminderTimeMinutes,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
reminders
|
reminders
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.data.reminders
|
|||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import androidx.core.net.toUri
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -91,10 +92,19 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
|||||||
private const val EXTRA_LOCATION = "location"
|
private const val EXTRA_LOCATION = "location"
|
||||||
private const val EXTRA_ALL_DAY = "all_day"
|
private const val EXTRA_ALL_DAY = "all_day"
|
||||||
|
|
||||||
/** An explicit intent to this receiver carrying [alert] as extras. */
|
/**
|
||||||
|
* An explicit intent to this receiver carrying [alert] as extras.
|
||||||
|
*
|
||||||
|
* The data URI names the reminder and carries no information the extras
|
||||||
|
* don't. It is what actually keeps two reminders' `PendingIntent`s
|
||||||
|
* apart: `filterEquals` compares action, component, data, type and
|
||||||
|
* categories — never extras — so without it a request-code collision
|
||||||
|
* would have them share one alarm and one payload.
|
||||||
|
*/
|
||||||
fun intent(context: Context, action: String, alert: ReminderAlert): Intent =
|
fun intent(context: Context, action: String, alert: ReminderAlert): Intent =
|
||||||
Intent(context, ReminderActionReceiver::class.java).apply {
|
Intent(context, ReminderActionReceiver::class.java).apply {
|
||||||
this.action = action
|
this.action = action
|
||||||
|
data = "calendula://reminder/${alert.key}".toUri()
|
||||||
putExtra(EXTRA_ALERT_KEY, alert.key)
|
putExtra(EXTRA_ALERT_KEY, alert.key)
|
||||||
putExtra(EXTRA_EVENT_ID, alert.eventId)
|
putExtra(EXTRA_EVENT_ID, alert.eventId)
|
||||||
putExtra(EXTRA_CALENDAR_ID, alert.calendarId)
|
putExtra(EXTRA_CALENDAR_ID, alert.calendarId)
|
||||||
@@ -110,9 +120,9 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
|||||||
* PendingIntents stay distinct and don't clobber each other.
|
* PendingIntents stay distinct and don't clobber each other.
|
||||||
*
|
*
|
||||||
* The key is a hash now rather than a small row id, so the shift is what
|
* The key is a hash now rather than a small row id, so the shift is what
|
||||||
* keeps the action slot intact; the top three bits it drops only make two
|
* keeps the action slot intact; the top three bits it drops can make two
|
||||||
* *different* reminders collide, which the intent extras then separate
|
* *different* reminders share a code, which the per-reminder data URI in
|
||||||
* (PendingIntents compare their intents too, not just the request code).
|
* [intent] separates.
|
||||||
*/
|
*/
|
||||||
fun requestCode(alert: ReminderAlert, action: String): Int {
|
fun requestCode(alert: ReminderAlert, action: String): Int {
|
||||||
val actionOffset = when (action) {
|
val actionOffset = when (action) {
|
||||||
|
|||||||
@@ -124,30 +124,59 @@ private fun allDayDate(beginMillis: Long): LocalDate =
|
|||||||
/**
|
/**
|
||||||
* How many whole days before its occurrence a raw all-day offset means.
|
* How many whole days before its occurrence a raw all-day offset means.
|
||||||
*
|
*
|
||||||
* A plain multiple of 1440 is read at face value. That covers rows from other
|
* Normally the local date of the encoded fire instant answers it: our own rows
|
||||||
* calendar apps, which carry no encoded hour — and it stays right for our own
|
* fold the wanted hour into the offset, so that date *is* the day the reminder
|
||||||
* rows that happen to land on a multiple, because those encode a wall-clock hour
|
* belongs to, whatever the offset was when it was written.
|
||||||
* equal to the sampled UTC offset, so the day count is the same either way.
|
|
||||||
*
|
*
|
||||||
* Anything else is one of ours, with an hour folded in: recover the day count the
|
* A plain multiple of 1440 is the exception. Those come from calendar apps that
|
||||||
* way [de.jeanlucmakiola.calendula.data.calendar.fromProviderAllDayMinutes]
|
* store a bare lead time against UTC midnight, and east of UTC both readings
|
||||||
* does for display, by asking which local date the encoded instant falls on.
|
* agree anyway — but west of UTC the instant falls on the previous local date,
|
||||||
* Keeping the two in step is what makes the notification arrive on the day the
|
* so the day count has to be taken at face value or it comes out one too many.
|
||||||
* event screen says it will.
|
*
|
||||||
|
* Except when the row is one of ours after all: our offset lands on a multiple
|
||||||
|
* whenever the all-day hour equals the zone's UTC offset (20:00 in New York,
|
||||||
|
* 19:00 an hour further west, and so on), and reading those at face value fired
|
||||||
|
* them a day late — a "1 day before" arriving on the morning of the event. So
|
||||||
|
* the hour decides, not the shape of the number: an instant landing on the hour
|
||||||
|
* the setting names is ours. Within an hour or so of it counts, because a fixed
|
||||||
|
* offset written in one DST phase drifts by the offset delta in the other.
|
||||||
|
*
|
||||||
|
* The two are genuinely indistinguishable in that band — the encodings collide
|
||||||
|
* exactly there — so the tie goes to the reading that honours the lead time the
|
||||||
|
* user chose in this app.
|
||||||
|
*
|
||||||
|
* [de.jeanlucmakiola.calendula.data.calendar.fromProviderAllDayMinutes] calls
|
||||||
|
* this for display, so the notification arrives on the day the event screen says
|
||||||
|
* it will.
|
||||||
*/
|
*/
|
||||||
internal fun allDayLeadDays(rawMinutes: Int, beginMillis: Long, zone: ZoneId): Long {
|
internal fun allDayLeadDays(
|
||||||
if (rawMinutes % MINUTES_PER_DAY == 0) return (rawMinutes / MINUTES_PER_DAY).toLong()
|
rawMinutes: Int,
|
||||||
val encoded = Instant.ofEpochMilli(beginMillis - rawMinutes * MILLIS_PER_MINUTE)
|
startDate: LocalDate,
|
||||||
return ChronoUnit.DAYS.between(encoded.atZone(zone).toLocalDate(), allDayDate(beginMillis))
|
zone: ZoneId,
|
||||||
|
allDayTimeMinutes: Int,
|
||||||
|
): Long {
|
||||||
|
val utcMidnight = startDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
||||||
|
val encoded = Instant.ofEpochMilli(utcMidnight - rawMinutes * MILLIS_PER_MINUTE).atZone(zone)
|
||||||
|
val minutesFromNamedHour = encoded.toLocalTime().let {
|
||||||
|
val delta = (it.hour * 60 + it.minute - allDayTimeMinutes).mod(MINUTES_PER_DAY)
|
||||||
|
minOf(delta, MINUTES_PER_DAY - delta)
|
||||||
|
}
|
||||||
|
if (rawMinutes % MINUTES_PER_DAY == 0 && minutesFromNamedHour > NAMED_HOUR_TOLERANCE_MINUTES) {
|
||||||
|
return (rawMinutes / MINUTES_PER_DAY).toLong()
|
||||||
|
}
|
||||||
|
return ChronoUnit.DAYS.between(encoded.toLocalDate(), startDate)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** DST drift a row written in the other phase carries, rounded up past Lord Howe's half hour. */
|
||||||
|
private const val NAMED_HOUR_TOLERANCE_MINUTES = 90
|
||||||
|
|
||||||
private fun allDayAlarmMillis(
|
private fun allDayAlarmMillis(
|
||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
rawMinutes: Int,
|
rawMinutes: Int,
|
||||||
zone: ZoneId,
|
zone: ZoneId,
|
||||||
allDayTimeMinutes: Int,
|
allDayTimeMinutes: Int,
|
||||||
): Long = allDayDate(beginMillis)
|
): Long = allDayDate(beginMillis)
|
||||||
.minusDays(allDayLeadDays(rawMinutes, beginMillis, zone))
|
.minusDays(allDayLeadDays(rawMinutes, allDayDate(beginMillis), zone, allDayTimeMinutes))
|
||||||
.atTime(LocalTime.of(allDayTimeMinutes / 60, allDayTimeMinutes % 60))
|
.atTime(LocalTime.of(allDayTimeMinutes / 60, allDayTimeMinutes % 60))
|
||||||
.atZone(zone)
|
.atZone(zone)
|
||||||
.toInstant()
|
.toInstant()
|
||||||
@@ -214,6 +243,16 @@ fun reminderWatermark(lastScanMillis: Long?, nowMillis: Long): Long =
|
|||||||
* reminder in time: the plain lookahead plus the longest offset any reminder row
|
* 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
|
* carries, so a "two weeks before" reminder is planned before it comes due
|
||||||
* instead of firing late (the limitation Etar's equivalent documents).
|
* instead of firing late (the limitation Etar's equivalent documents).
|
||||||
|
*
|
||||||
|
* The stretch is capped at [MAX_REMINDER_LEAD_MILLIS]. `maxReminderMinutes` is
|
||||||
|
* whatever the largest row in the whole provider says, across every calendar and
|
||||||
|
* whoever wrote it — an imported `TRIGGER:-P100W`, or a sync adapter writing
|
||||||
|
* nonsense, would otherwise make every scan (launch, every provider change, every
|
||||||
|
* alarm) expand every recurring series over years. A lead beyond the cap is not
|
||||||
|
* delivered; a year is far past anything the picker composes.
|
||||||
*/
|
*/
|
||||||
fun reminderQueryHorizon(lookaheadMillis: Long, maxReminderMinutes: Int): Long =
|
fun reminderQueryHorizon(lookaheadMillis: Long, maxReminderMinutes: Int): Long =
|
||||||
lookaheadMillis + maxOf(0L, maxReminderMinutes * MILLIS_PER_MINUTE)
|
lookaheadMillis + (maxReminderMinutes * MILLIS_PER_MINUTE).coerceIn(0L, MAX_REMINDER_LEAD_MILLIS)
|
||||||
|
|
||||||
|
/** Longest reminder offset a scan stretches its query window for — one year. */
|
||||||
|
const val MAX_REMINDER_LEAD_MILLIS = 365L * 24 * 60 * 60 * 1000
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ fun RootScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Whether the app came up already holding it — a launch scan has covered
|
||||||
|
// that case, so only a grant made during this session owes a re-scan.
|
||||||
|
val grantedAtLaunch = remember { hasPermission }
|
||||||
|
|
||||||
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||||
DisposableEffect(lifecycle) {
|
DisposableEffect(lifecycle) {
|
||||||
val obs = LifecycleEventObserver { _, event ->
|
val obs = LifecycleEventObserver { _, event ->
|
||||||
@@ -88,7 +92,10 @@ fun RootScreen(
|
|||||||
// Runs on entry however the permission was granted — including from
|
// Runs on entry however the permission was granted — including from
|
||||||
// Android's app-settings screen, which only comes back through the
|
// Android's app-settings screen, which only comes back through the
|
||||||
// ON_RESUME check above. Cheap once there is nothing left to do.
|
// ON_RESUME check above. Cheap once there is nothing left to do.
|
||||||
LaunchedEffect(Unit) { visibilityNotice.reconcile() }
|
LaunchedEffect(Unit) {
|
||||||
|
visibilityNotice.reconcile()
|
||||||
|
if (!grantedAtLaunch) reminderOnboarding.rearmAfterGrant()
|
||||||
|
}
|
||||||
if (onboardingDone == true && noticePending) {
|
if (onboardingDone == true && noticePending) {
|
||||||
CalendarVisibilityNoticeDialog(onDismiss = visibilityNotice::dismiss)
|
CalendarVisibilityNoticeDialog(onDismiss = visibilityNotice::dismiss)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,9 +153,12 @@ class CalendarsViewModel @Inject constructor(
|
|||||||
* reminders. Nothing is patched by hand — the provider notifies and the
|
* reminders. Nothing is patched by hand — the provider notifies and the
|
||||||
* observer re-queries.
|
* observer re-queries.
|
||||||
*
|
*
|
||||||
* Nothing has to be re-posted on the way back on: reminder delivery plans
|
* A reminder that came due while the calendar was off stays gone when it is
|
||||||
* from `Instances` and `Reminders` on every scan (#75), and the provider
|
* switched back on. Delivery plans from `Instances` and `Reminders` on every
|
||||||
* change this write makes triggers one.
|
* scan (#75), but the watermark has already moved past that moment, so no
|
||||||
|
* scan returns it again — and on a read-only install the switch never
|
||||||
|
* reaches the provider at all, so nothing even triggers one. Deliberate: the
|
||||||
|
* reminder was silenced on purpose, and the event itself is back in view.
|
||||||
*/
|
*/
|
||||||
fun setCalendarVisible(id: Long, visible: Boolean) = write {
|
fun setCalendarVisible(id: Long, visible: Boolean) = write {
|
||||||
repository.setCalendarsVisible(listOf(id), visible)
|
repository.setCalendarsVisible(listOf(id), visible)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
@@ -19,6 +20,7 @@ import javax.inject.Inject
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class ReminderOnboardingViewModel @Inject constructor(
|
class ReminderOnboardingViewModel @Inject constructor(
|
||||||
private val prefs: SettingsPrefs,
|
private val prefs: SettingsPrefs,
|
||||||
|
private val scanner: ReminderScanner,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
val onboardingDone: StateFlow<Boolean?> = prefs.reminderOnboardingDone
|
val onboardingDone: StateFlow<Boolean?> = prefs.reminderOnboardingDone
|
||||||
@@ -34,6 +36,19 @@ class ReminderOnboardingViewModel @Inject constructor(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
prefs.setRemindersEnabled(remindersEnabled)
|
prefs.setRemindersEnabled(remindersEnabled)
|
||||||
prefs.setReminderOnboardingDone()
|
prefs.setReminderOnboardingDone()
|
||||||
|
// Nothing else re-arms the scan: turning reminders off cancels the
|
||||||
|
// alarm, so a later "on" would sit without one until a provider
|
||||||
|
// change or the daily worker happened to run (#75).
|
||||||
|
scanner.scan()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-scan after the calendar permission is granted. The launch scan runs
|
||||||
|
* before the grant and bails out without arming anything, so without this
|
||||||
|
* the first alarm waits on the daily worker.
|
||||||
|
*/
|
||||||
|
fun rearmAfterGrant() {
|
||||||
|
scanner.scanInBackground()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import de.jeanlucmakiola.calendula.data.prefs.SpecialDatesStalledReason
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||||
|
import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||||
@@ -68,6 +69,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
private val specialDatesEngine: SpecialDatesSyncEngine,
|
private val specialDatesEngine: SpecialDatesSyncEngine,
|
||||||
specialDatesSpec: SpecialDatesCalendarSpec,
|
specialDatesSpec: SpecialDatesCalendarSpec,
|
||||||
private val launcherNameManager: LauncherNameManager,
|
private val launcherNameManager: LauncherNameManager,
|
||||||
|
private val reminderScanner: ReminderScanner,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
@ApplicationContext private val appContext: Context,
|
@ApplicationContext private val appContext: Context,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
@@ -582,8 +584,16 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setDrawerViewOrder(order) }
|
viewModelScope.launch { prefs.setDrawerViewOrder(order) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A scan follows the write, in that order. Switching reminders off cancels
|
||||||
|
* the scan alarm, so switching them back on has to arm a new one — nothing
|
||||||
|
* else would until a provider change or the daily worker came along (#75).
|
||||||
|
*/
|
||||||
fun setRemindersEnabled(enabled: Boolean) {
|
fun setRemindersEnabled(enabled: Boolean) {
|
||||||
viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
|
viewModelScope.launch {
|
||||||
|
prefs.setRemindersEnabled(enabled)
|
||||||
|
reminderScanner.scan()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setAutofocusEventTitle(enabled: Boolean) {
|
fun setAutofocusEventTitle(enabled: Boolean) {
|
||||||
@@ -598,8 +608,12 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setDefaultAllDayReminderMinutes(minutes) }
|
viewModelScope.launch { prefs.setDefaultAllDayReminderMinutes(minutes) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The armed alarm was planned for the old hour, so re-plan against the new one. */
|
||||||
fun setAllDayReminderTimeMinutes(minutesOfDay: Int) {
|
fun setAllDayReminderTimeMinutes(minutesOfDay: Int) {
|
||||||
viewModelScope.launch { prefs.setAllDayReminderTimeMinutes(minutesOfDay) }
|
viewModelScope.launch {
|
||||||
|
prefs.setAllDayReminderTimeMinutes(minutesOfDay)
|
||||||
|
reminderScanner.scan()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSnoozeMinutes(minutes: Int) {
|
fun setSnoozeMinutes(minutes: Int) {
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ class AllDayReminderEncodingTest {
|
|||||||
for (time in listOf(0, nineAm, 20 * 60)) {
|
for (time in listOf(0, nineAm, 20 * 60)) {
|
||||||
for (semantic in listOf(0, 1_440, 2_880, 10_080)) {
|
for (semantic in listOf(0, 1_440, 2_880, 10_080)) {
|
||||||
val raw = toProviderAllDayMinutes(semantic, date, berlin, time)
|
val raw = toProviderAllDayMinutes(semantic, date, berlin, time)
|
||||||
assertThat(fromProviderAllDayMinutes(raw, date, berlin)).isEqualTo(semantic)
|
assertThat(fromProviderAllDayMinutes(raw, date, berlin, time))
|
||||||
|
.isEqualTo(semantic)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,9 +65,31 @@ class AllDayReminderEncodingTest {
|
|||||||
fun `pre-feature rows (raw multiple of 1440) still decode to whole days`() {
|
fun `pre-feature rows (raw multiple of 1440) still decode to whole days`() {
|
||||||
// Reminders written before this feature stored raw N*1440 (fired at UTC
|
// Reminders written before this feature stored raw N*1440 (fired at UTC
|
||||||
// midnight). They must still read back as "N days before".
|
// midnight). They must still read back as "N days before".
|
||||||
assertThat(fromProviderAllDayMinutes(1_440, summer, berlin)).isEqualTo(1_440)
|
assertThat(fromProviderAllDayMinutes(1_440, summer, berlin, nineAm)).isEqualTo(1_440)
|
||||||
assertThat(fromProviderAllDayMinutes(1_440, winter, berlin)).isEqualTo(1_440)
|
assertThat(fromProviderAllDayMinutes(1_440, winter, berlin, nineAm)).isEqualTo(1_440)
|
||||||
assertThat(fromProviderAllDayMinutes(2_880, summer, berlin)).isEqualTo(2_880)
|
assertThat(fromProviderAllDayMinutes(2_880, summer, berlin, nineAm)).isEqualTo(2_880)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an offset that encodes to a multiple still decodes to its own lead time`() {
|
||||||
|
// New York at 20:00 is the collision: "1 day before" encodes to a plain 0,
|
||||||
|
// which read at face value would say "at time of event". The screen has to
|
||||||
|
// show what the reminder does, which is fire the evening before.
|
||||||
|
val newYork = ZoneId.of("America/New_York")
|
||||||
|
val eightPm = 20 * 60
|
||||||
|
val raw = toProviderAllDayMinutes(1_440, summer, newYork, eightPm)
|
||||||
|
|
||||||
|
assertThat(raw).isEqualTo(0)
|
||||||
|
assertThat(fromProviderAllDayMinutes(raw, summer, newYork, eightPm)).isEqualTo(1_440)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a foreign multiple west of UTC keeps its face-value lead time`() {
|
||||||
|
// No encoded hour in this row, and its instant lands on the evening two
|
||||||
|
// days out in New York — the local-date reading would say "2 days before".
|
||||||
|
val newYork = ZoneId.of("America/New_York")
|
||||||
|
|
||||||
|
assertThat(fromProviderAllDayMinutes(1_440, summer, newYork, nineAm)).isEqualTo(1_440)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -74,8 +97,8 @@ class AllDayReminderEncodingTest {
|
|||||||
val atNine = toProviderAllDayMinutes(1_440, summer, berlin, nineAm)
|
val atNine = toProviderAllDayMinutes(1_440, summer, berlin, nineAm)
|
||||||
val atEight = toProviderAllDayMinutes(1_440, summer, berlin, 8 * 60)
|
val atEight = toProviderAllDayMinutes(1_440, summer, berlin, 8 * 60)
|
||||||
assertThat(atNine).isNotEqualTo(atEight)
|
assertThat(atNine).isNotEqualTo(atEight)
|
||||||
assertThat(fromProviderAllDayMinutes(atNine, summer, berlin)).isEqualTo(1_440)
|
assertThat(fromProviderAllDayMinutes(atNine, summer, berlin, nineAm)).isEqualTo(1_440)
|
||||||
assertThat(fromProviderAllDayMinutes(atEight, summer, berlin)).isEqualTo(1_440)
|
assertThat(fromProviderAllDayMinutes(atEight, summer, berlin, nineAm)).isEqualTo(1_440)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -79,7 +79,8 @@ class EventDetailMapperTest {
|
|||||||
private fun MapColumnReader.toDetail(
|
private fun MapColumnReader.toDetail(
|
||||||
attendees: List<de.jeanlucmakiola.calendula.domain.Attendee> = emptyList(),
|
attendees: List<de.jeanlucmakiola.calendula.domain.Attendee> = emptyList(),
|
||||||
reminders: List<Reminder> = emptyList(),
|
reminders: List<Reminder> = emptyList(),
|
||||||
) = toEventDetailCore(attendees, reminders)
|
allDayReminderTimeMinutes: Int = 9 * 60,
|
||||||
|
) = toEventDetailCore(attendees, reminders, allDayReminderTimeMinutes)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `happy path detail maps all fields and embeds matching EventInstance`() {
|
fun `happy path detail maps all fields and embeds matching EventInstance`() {
|
||||||
|
|||||||
@@ -72,7 +72,8 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
|||||||
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> =
|
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> =
|
||||||
instancesResult(beginMillis, endMillis)
|
instancesResult(beginMillis, endMillis)
|
||||||
override fun searchEvents(query: String): List<EventInstance> = searchResult(query)
|
override fun searchEvents(query: String): List<EventInstance> = searchResult(query)
|
||||||
override fun eventDetail(eventId: Long): EventDetail? = eventDetailResult(eventId)
|
override fun eventDetail(eventId: Long, allDayReminderTimeMinutes: Int): EventDetail? =
|
||||||
|
eventDetailResult(eventId)
|
||||||
override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
||||||
eventColorPaletteResult(calendarId)
|
eventColorPaletteResult(calendarId)
|
||||||
override fun exportableEvents(calendarIds: Set<Long>?): List<IcsEvent> {
|
override fun exportableEvents(calendarIds: Set<Long>?): List<IcsEvent> {
|
||||||
|
|||||||
@@ -184,8 +184,58 @@ class ReminderPlanTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a zero-day row fires on the event's own date`() {
|
fun `a zero-day row fires on the event's own date`() {
|
||||||
assertThat(allDayLeadDays(rawMinutes = 0, beginMillis = allDayBegin("2026-07-15"), zone = berlin))
|
assertThat(
|
||||||
.isEqualTo(0L)
|
allDayLeadDays(
|
||||||
|
rawMinutes = 0,
|
||||||
|
startDate = LocalDate.parse("2026-07-15"),
|
||||||
|
zone = berlin,
|
||||||
|
allDayTimeMinutes = nineAm,
|
||||||
|
),
|
||||||
|
).isEqualTo(0L)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `our own row that encodes to a multiple is not read at face value`() {
|
||||||
|
// New York with the all-day hour at 20:00: "1 day before" encodes to a
|
||||||
|
// plain 0, because 20:00 EDT *is* UTC midnight. Read at face value it
|
||||||
|
// fired at 20:00 on the day of the event instead of the evening before.
|
||||||
|
val newYork = ZoneId.of("America/New_York")
|
||||||
|
val eightPm = 20 * 60
|
||||||
|
val row = encodedAllDayMinutes("2026-07-15", days = 1, timeOfDayMinutes = eightPm, zone = newYork)
|
||||||
|
assertThat(row % 1_440).isEqualTo(0)
|
||||||
|
|
||||||
|
val alarm = plan(
|
||||||
|
instances = listOf(
|
||||||
|
instance(1L, beginMillis = allDayBegin("2026-07-15"), isAllDay = true),
|
||||||
|
),
|
||||||
|
minutesByEvent = mapOf(1L to listOf(row)),
|
||||||
|
zone = newYork,
|
||||||
|
allDayTimeMinutes = eightPm,
|
||||||
|
).single().alarmMillis
|
||||||
|
|
||||||
|
assertThat(firedAt(alarm, newYork).toLocalDateTime())
|
||||||
|
.isEqualTo(LocalDate.parse("2026-07-14").atTime(20, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `such a row keeps its lead time across the DST boundary that wrote it`() {
|
||||||
|
// Same row, sampled in EDT, on a winter occurrence: its instant lands an
|
||||||
|
// hour off the named hour, which still has to read as "one of ours".
|
||||||
|
val newYork = ZoneId.of("America/New_York")
|
||||||
|
val eightPm = 20 * 60
|
||||||
|
val summerRow = encodedAllDayMinutes("2026-07-15", days = 1, timeOfDayMinutes = eightPm, zone = newYork)
|
||||||
|
|
||||||
|
val alarm = plan(
|
||||||
|
instances = listOf(
|
||||||
|
instance(1L, beginMillis = allDayBegin("2027-01-15"), isAllDay = true),
|
||||||
|
),
|
||||||
|
minutesByEvent = mapOf(1L to listOf(summerRow)),
|
||||||
|
zone = newYork,
|
||||||
|
allDayTimeMinutes = eightPm,
|
||||||
|
).single().alarmMillis
|
||||||
|
|
||||||
|
assertThat(firedAt(alarm, newYork).toLocalDateTime())
|
||||||
|
.isEqualTo(LocalDate.parse("2027-01-14").atTime(20, 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -437,6 +487,17 @@ class ReminderPlanTest {
|
|||||||
.isEqualTo(7 * day + 14 * day)
|
.isEqualTo(7 * day + 14 * day)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the query horizon is capped against an outlier row`() {
|
||||||
|
// The longest offset comes from any row in the provider — an imported
|
||||||
|
// TRIGGER:-P100W would otherwise expand every series over two years, on
|
||||||
|
// every scan.
|
||||||
|
val hundredWeeks = 100 * 7 * 24 * 60
|
||||||
|
|
||||||
|
assertThat(reminderQueryHorizon(lookaheadMillis = 7 * day, maxReminderMinutes = hundredWeeks))
|
||||||
|
.isEqualTo(7 * day + MAX_REMINDER_LEAD_MILLIS)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a first-ever scan starts from now, not from the epoch`() {
|
fun `a first-ever scan starts from now, not from the epoch`() {
|
||||||
// Otherwise an install — or the upgrade onto in-house delivery — treats
|
// Otherwise an install — or the upgrade onto in-house delivery — treats
|
||||||
|
|||||||
@@ -175,7 +175,12 @@ an app update or a doze window costs nothing. A first-ever scan claims the
|
|||||||
present rather than the epoch, and a watermark left in the future by a clock
|
present rather than the epoch, and a watermark left in the future by a clock
|
||||||
change is clamped. Every trigger runs the same idempotent `scan()`, so there is
|
change is clamped. Every trigger runs the same idempotent `scan()`, so there is
|
||||||
no ordering between them to get wrong; `BOOT_COMPLETED` and `MY_PACKAGE_REPLACED`
|
no ordering between them to get wrong; `BOOT_COMPLETED` and `MY_PACKAGE_REPLACED`
|
||||||
matter because both wipe pending alarms.
|
matter because both wipe pending alarms. Turning reminders off cancels the alarm
|
||||||
|
and granting the calendar permission arms none, so those transitions scan too —
|
||||||
|
without it, switching reminders back on would sit silent until the daily worker.
|
||||||
|
The window a scan reads is the 7-day lookahead plus the longest reminder offset
|
||||||
|
in the provider, capped at a year: that offset is whatever the largest row says,
|
||||||
|
including one imported from a stray `TRIGGER:-P100W`.
|
||||||
|
|
||||||
**All-day reminders fire at the hour the setting names.** The stored offset is
|
**All-day reminders fire at the hour the setting names.** The stored offset is
|
||||||
not a plain lead time — `AllDayReminderEncoding` folds a wall-clock hour into it,
|
not a plain lead time — `AllDayReminderEncoding` folds a wall-clock hour into it,
|
||||||
@@ -183,7 +188,13 @@ sampled against one date's UTC offset — so taking it at face value drifts by t
|
|||||||
offset delta across a DST boundary, and rows from other apps carry no hour at
|
offset delta across a DST boundary, and rows from other apps carry no hour at
|
||||||
all. The offset is therefore read only for *which day* it means; the hour comes
|
all. The offset is therefore read only for *which day* it means; the hour comes
|
||||||
from the global all-day reminder setting, recomposed against each occurrence's
|
from the global all-day reminder setting, recomposed against each occurrence's
|
||||||
own date. Timed reminders need none of this: `begin` is an absolute instant.
|
own date. Which day that is comes from the local date the encoded instant falls
|
||||||
|
on — except for a plain multiple of 1440, read at face value because a foreign
|
||||||
|
row means literal days from UTC midnight. The two collide where the all-day hour
|
||||||
|
equals the zone's UTC offset (20:00 in New York), and there the instant landing
|
||||||
|
on the named hour decides it is ours; the display path decodes through the same
|
||||||
|
function, so the screen and the notification agree. Timed reminders need none of
|
||||||
|
this: `begin` is an absolute instant.
|
||||||
|
|
||||||
**One visibility model.** The scan only plans occurrences of calendars with
|
**One visibility model.** The scan only plans occurrences of calendars with
|
||||||
`Calendars.VISIBLE = 1`, and that flag *is* the app's on/off switch: Settings →
|
`Calendars.VISIBLE = 1`, and that flag *is* the app's on/off switch: Settings →
|
||||||
|
|||||||
Reference in New Issue
Block a user