Merge remote-tracking branch 'origin/release/v2.17.0' into fix/widget-fixed-size-steps
# Conflicts: # CHANGELOG.md
This commit is contained in:
42
CHANGELOG.md
42
CHANGELOG.md
@@ -50,9 +50,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
either widget is measured any more: they draw at the size you pick under
|
either widget is measured any more: they draw at the size you pick under
|
||||||
Settings → Widgets. The month widget can no longer be resized narrower than its
|
Settings → Widgets. The month widget can no longer be resized narrower than its
|
||||||
grid needs, either ([#103], [#51]).
|
grid needs, either ([#103], [#51]).
|
||||||
|
- A month-grid widget stays a month grid. Since 2.16.0 a placed month widget
|
||||||
|
could redraw itself as the agenda widget a little after any change to your
|
||||||
|
events, because the release build merged the two widgets into a single class
|
||||||
|
and Android could no longer tell which of them a widget on your home screen
|
||||||
|
was ([#89]).
|
||||||
|
- The back gesture on **Settings → Views** returns to Settings instead of
|
||||||
|
leaving Settings altogether and dropping you on the calendar. Special dates
|
||||||
|
did the same ([#81]).
|
||||||
|
- The dots standing in for the events that didn't fit a day in the month view
|
||||||
|
now dim with everything else when **Dim completed events** is on. A past day
|
||||||
|
with four or more events kept its last events at full strength while the rest
|
||||||
|
faded ([#79]).
|
||||||
|
- Two accounts that happen to share a name — a Google account and a DAVx5
|
||||||
|
account for the same address, say — are no longer merged into one group.
|
||||||
|
They were listed together in Settings → Calendars and in the drawer's filter,
|
||||||
|
which also meant the group's source icon and its "manage in app" button could
|
||||||
|
send you to the wrong app, "toggle all" spanned both accounts at once, and
|
||||||
|
collapsing one collapsed the other. Where a name really is shared, each group
|
||||||
|
now names the app it comes from ([#77]).
|
||||||
- Search results now show an all-day event's real date. West of UTC — anywhere in
|
- Search results now show an all-day event's real date. West of UTC — anywhere in
|
||||||
the Americas, say — a search hit was dated one day early, disagreeing with the
|
the Americas, say — a search hit was dated one day early, disagreeing with the
|
||||||
day the month, week and agenda views file the same event under ([#82]).
|
day the month, week and agenda views file the same event under ([#82]).
|
||||||
|
- Reminders no longer depend on Android telling Calendula when they are due.
|
||||||
|
Calendula now works out each reminder's time itself and sets its own alarm for
|
||||||
|
it. On some phones — Samsung's among them — the system's calendar storage never
|
||||||
|
sends the signal a calendar app is meant to wake up on, and no amount of
|
||||||
|
battery or notification settings helps: the reminder is simply never announced.
|
||||||
|
None of that is visible from inside an app that waits to be told, which is why
|
||||||
|
it took a second pass to find ([#75]).
|
||||||
|
|
||||||
|
Reminders also survive things that used to lose them quietly. After a restart
|
||||||
|
or an app update Calendula re-arms its alarms, and a reminder whose moment
|
||||||
|
passed while the phone was off still arrives, as long as the event has not
|
||||||
|
ended yet.
|
||||||
|
|
||||||
|
- All-day reminders now arrive at the time you chose in **Settings →
|
||||||
|
Notifications**, on every occurrence. A yearly birthday could drift an hour
|
||||||
|
either way depending on daylight saving, and all-day reminders on calendars
|
||||||
|
from an account fired in the middle of the night instead of in the morning
|
||||||
|
([#75]).
|
||||||
|
|
||||||
- Reminders now arrive for every calendar you have switched on. A calendar that
|
- Reminders now arrive for every calendar you have switched on. A calendar that
|
||||||
was hidden at system level — switched off in another calendar app, or never
|
was hidden at system level — switched off in another calendar app, or never
|
||||||
switched on after being added — still showed its events and listed their
|
switched on after being added — still showed its events and listed their
|
||||||
@@ -1182,5 +1220,9 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#75]: https://codeberg.org/jlmakiola/calendula/issues/75
|
[#75]: https://codeberg.org/jlmakiola/calendula/issues/75
|
||||||
[#76]: https://codeberg.org/jlmakiola/calendula/issues/76
|
[#76]: https://codeberg.org/jlmakiola/calendula/issues/76
|
||||||
[#78]: https://codeberg.org/jlmakiola/calendula/issues/78
|
[#78]: https://codeberg.org/jlmakiola/calendula/issues/78
|
||||||
|
[#77]: https://codeberg.org/jlmakiola/calendula/issues/77
|
||||||
|
[#79]: https://codeberg.org/jlmakiola/calendula/issues/79
|
||||||
|
[#81]: https://codeberg.org/jlmakiola/calendula/issues/81
|
||||||
[#82]: https://codeberg.org/jlmakiola/calendula/issues/82
|
[#82]: https://codeberg.org/jlmakiola/calendula/issues/82
|
||||||
|
[#89]: https://codeberg.org/jlmakiola/calendula/issues/89
|
||||||
[#103]: https://codeberg.org/jlmakiola/calendula/issues/103
|
[#103]: https://codeberg.org/jlmakiola/calendula/issues/103
|
||||||
|
|||||||
12
app/proguard-rules.pro
vendored
12
app/proguard-rules.pro
vendored
@@ -38,3 +38,15 @@
|
|||||||
# SessionWorker never ran, and widgets were stuck on their loading layout
|
# SessionWorker never ran, and widgets were stuck on their loading layout
|
||||||
# (a blank spinner) in release builds. Keep every InputMerger's name + ctor.
|
# (a blank spinner) in release builds. Keep every InputMerger's name + ctor.
|
||||||
-keep class * extends androidx.work.InputMerger { <init>(...); }
|
-keep class * extends androidx.work.InputMerger { <init>(...); }
|
||||||
|
|
||||||
|
# Glance identifies a widget by its GlanceAppWidget subclass's *canonical name*:
|
||||||
|
# GlanceAppWidgetManager persists a providerName -> receivers map under that
|
||||||
|
# string, and `updateAll` looks the widget's app-widget ids up through it. Under
|
||||||
|
# R8 full mode (AGP 9 default) MonthWidget and AgendaWidget — same supertype,
|
||||||
|
# same overrides, no distinguishing members — were horizontally merged into one
|
||||||
|
# class, so both receivers registered under the *same* provider name and
|
||||||
|
# `AgendaWidget().updateAll()` resolved the month widget's id too, redrawing a
|
||||||
|
# placed month widget as the agenda one on the next data change (#89). Keeping
|
||||||
|
# the real names also survives app updates, which would otherwise renumber the
|
||||||
|
# obfuscated name and orphan the stored mapping.
|
||||||
|
-keep class * extends androidx.glance.appwidget.GlanceAppWidget
|
||||||
|
|||||||
@@ -33,6 +33,14 @@
|
|||||||
android:maxSdkVersion="32" />
|
android:maxSdkVersion="32" />
|
||||||
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
|
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
A reboot clears every pending alarm, including the one holding the next
|
||||||
|
reminder. Now that the app schedules that alarm itself (#75) rather than
|
||||||
|
leaning on the provider's, it has to hear about the reboot to re-arm it —
|
||||||
|
otherwise reminders simply stop after a restart.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
|
||||||
<!-- Package visibility (Android 11+): without this, getLaunchIntentForPackage
|
<!-- Package visibility (Android 11+): without this, getLaunchIntentForPackage
|
||||||
returns null and the calendar manager's per-account "manage" button can't
|
returns null and the calendar manager's per-account "manage" button can't
|
||||||
open the source sync app (DAVx5, ICSx5, Google Calendar, …). The LAUNCHER
|
open the source sync app (DAVx5, ICSx5, Google Calendar, …). The LAUNCHER
|
||||||
@@ -270,17 +278,22 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
<!-- The provider broadcasts EVENT_REMINDER at reminder time but posts
|
<!-- Reminder delivery is the app's own (#75): it plans the alarms from
|
||||||
no notification itself — a calendar app must (v1.4, Etar model).
|
Instances + Reminders instead of waiting for the provider's
|
||||||
Exported: the broadcast arrives from the provider's process. -->
|
EVENT_REMINDER broadcast, which OEM-modified providers demonstrably
|
||||||
|
retarget or never send. This receiver takes our scan alarm plus
|
||||||
|
every outside event that invalidates it — boot and package-replace
|
||||||
|
wipe pending alarms, and a clock or timezone change moves every
|
||||||
|
reminder relative to the one that is armed.
|
||||||
|
Exported: the system broadcasts arrive from outside the app. -->
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".data.reminders.EventReminderReceiver"
|
android:name=".data.reminders.ReminderScheduleReceiver"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.EVENT_REMINDER" />
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
<data
|
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||||
android:host="com.android.calendar"
|
<action android:name="android.intent.action.TIME_SET" />
|
||||||
android:scheme="content" />
|
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarVisibilityReconciler
|
|||||||
import de.jeanlucmakiola.calendula.data.backup.BackupWorker
|
import de.jeanlucmakiola.calendula.data.backup.BackupWorker
|
||||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
||||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker
|
||||||
|
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceScheduler
|
||||||
|
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceWorker
|
||||||
import de.jeanlucmakiola.floret.crash.CrashConfig
|
import de.jeanlucmakiola.floret.crash.CrashConfig
|
||||||
import de.jeanlucmakiola.floret.crash.CrashReporter
|
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -41,6 +43,25 @@ class CalendulaApp : Application() {
|
|||||||
reconcileAutoBackup()
|
reconcileAutoBackup()
|
||||||
reconcileSpecialDates()
|
reconcileSpecialDates()
|
||||||
reconcileCalendarVisibility()
|
reconcileCalendarVisibility()
|
||||||
|
startReminderDelivery()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bring reminder delivery up with the process (#75). The app plans and arms
|
||||||
|
* its own reminder alarms now, so launch is one of the moments that has to
|
||||||
|
* re-check them: a scan re-arms whatever the system dropped, posts anything
|
||||||
|
* a missed alarm still owes, and starts watching the provider so an edit
|
||||||
|
* re-plans without waiting for the next pass. The daily worker is the
|
||||||
|
* backstop for a device that drops the alarm with no reboot to announce it.
|
||||||
|
*/
|
||||||
|
private fun startReminderDelivery() {
|
||||||
|
val deps = EntryPointAccessors.fromApplication(
|
||||||
|
this, ReminderMaintenanceWorker.Deps::class.java,
|
||||||
|
)
|
||||||
|
val scanner = deps.reminderScanner()
|
||||||
|
scanner.startWatchingProvider()
|
||||||
|
scanner.scanInBackground()
|
||||||
|
ReminderMaintenanceScheduler.apply(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ import de.jeanlucmakiola.calendula.data.contacts.AndroidContactSpecialDatesDataS
|
|||||||
import de.jeanlucmakiola.calendula.data.contacts.AndroidSpecialDatesCalendarSpec
|
import de.jeanlucmakiola.calendula.data.contacts.AndroidSpecialDatesCalendarSpec
|
||||||
import de.jeanlucmakiola.calendula.data.contacts.ContactSpecialDatesDataSource
|
import de.jeanlucmakiola.calendula.data.contacts.ContactSpecialDatesDataSource
|
||||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
|
||||||
import de.jeanlucmakiola.calendula.data.reminders.AndroidReminderAlertStore
|
import de.jeanlucmakiola.calendula.data.reminders.ProviderReminderInstanceSource
|
||||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderAlertStore
|
import de.jeanlucmakiola.calendula.data.reminders.ReminderInstanceSource
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -46,9 +46,9 @@ abstract class DataBindModule {
|
|||||||
|
|
||||||
@Binds
|
@Binds
|
||||||
@Singleton
|
@Singleton
|
||||||
abstract fun bindReminderAlertStore(
|
abstract fun bindReminderInstanceSource(
|
||||||
impl: AndroidReminderAlertStore,
|
impl: ProviderReminderInstanceSource,
|
||||||
): ReminderAlertStore
|
): ReminderInstanceSource
|
||||||
|
|
||||||
@Binds
|
@Binds
|
||||||
@Singleton
|
@Singleton
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.prefs
|
||||||
|
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.longPreferencesKey
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far reminder delivery has got. One number, and it replaces everything the
|
||||||
|
* provider's `CalendarAlerts.STATE` used to do for us (#75).
|
||||||
|
*
|
||||||
|
* A scan posts the reminders whose moment falls after this watermark and up to
|
||||||
|
* now, then moves it to now. That single rule gives both halves of what the
|
||||||
|
* retired path got from the provider: a scan that runs twice cannot post the
|
||||||
|
* same reminder again, and a scan that runs *late* — after a reboot, an app
|
||||||
|
* update or a doze window swallowed the alarm — still posts everything the
|
||||||
|
* missed alarm would have.
|
||||||
|
*
|
||||||
|
* Unset means "never scanned". It is deliberately not treated as zero: the first
|
||||||
|
* scan after an install or an upgrade would otherwise consider every reminder
|
||||||
|
* since the epoch overdue and bury the user in notifications.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class ReminderStatePrefs @Inject constructor(
|
||||||
|
private val store: DataStore<Preferences>,
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** The watermark, or `null` before the first scan has ever run. */
|
||||||
|
suspend fun lastScanMillis(): Long? = store.data.map { it[LAST_SCAN_KEY] }.first()
|
||||||
|
|
||||||
|
suspend fun setLastScanMillis(millis: Long) {
|
||||||
|
store.edit { prefs -> prefs[LAST_SCAN_KEY] = millis }
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
val LAST_SCAN_KEY = longPreferencesKey("reminder_last_scan_millis")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.reminders
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Still relevant while the event has not ended: a reminder for an event that is
|
|
||||||
* already over is pointless to re-surface. Falls back to the begin time when the
|
|
||||||
* end is unknown (0L).
|
|
||||||
*/
|
|
||||||
internal fun ReminderAlert.isRelevantAt(nowMillis: Long): Boolean =
|
|
||||||
(endMillis.takeIf { it > 0L } ?: beginMillis) >= nowMillis
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The alerts [EventReminderReceiver] may mark handled (`STATE_FIRED`): the ones
|
|
||||||
* it posted, plus the ones it silenced whose event is already over.
|
|
||||||
*
|
|
||||||
* A silenced alert for an event still ahead is deliberately left
|
|
||||||
* `STATE_SCHEDULED`. Silencing is not handling — the calendar is switched off in
|
|
||||||
* Calendula while the provider still holds `VISIBLE = 1` (a read-only install,
|
|
||||||
* or an upgrade whose flush hasn't landed), so switching it back on before the
|
|
||||||
* event must still be able to surface the reminder. [ReminderAlertStore.dueAlerts]
|
|
||||||
* only ever returns scheduled rows, so marking them here would lose them for
|
|
||||||
* good; leaving them makes the provider's own table the stash
|
|
||||||
* ([ReminderRecovery]).
|
|
||||||
*/
|
|
||||||
internal fun handledAlertIds(
|
|
||||||
due: List<ReminderAlert>,
|
|
||||||
postedIds: Set<Long>,
|
|
||||||
nowMillis: Long,
|
|
||||||
): List<Long> = due
|
|
||||||
.filter { it.alertId in postedIds || !it.isRelevantAt(nowMillis) }
|
|
||||||
.map { it.alertId }
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.reminders
|
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.provider.CalendarContract
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Becomes the app that turns the calendar provider's reminder alarms into
|
|
||||||
* visible notifications (the Etar model — the provider broadcasts
|
|
||||||
* `EVENT_REMINDER` at reminder time but posts nothing itself).
|
|
||||||
*
|
|
||||||
* The broadcast's data URI only carries the alarm time, so it is ignored:
|
|
||||||
* we query every still-scheduled, due `CalendarAlerts` row ourselves, post
|
|
||||||
* them, and mark them fired. Posting happens before marking — a crash in
|
|
||||||
* between re-posts silently (same tag) rather than losing the reminder.
|
|
||||||
*
|
|
||||||
* There is no per-calendar filtering here: a calendar switched off in
|
|
||||||
* Settings → Calendars has `Calendars.VISIBLE = 0`, and the provider creates no
|
|
||||||
* alert rows for it in the first place (#75). The one case that flag can't
|
|
||||||
* cover — a read-only install, which keeps its switches app-side — is gated in
|
|
||||||
* [ReminderNotifier.post], where the snoozed re-show passes too. What that gate
|
|
||||||
* silences is *not* marked fired while the event is still ahead, so switching
|
|
||||||
* the calendar back on can still surface it (see [handledAlertIds]).
|
|
||||||
*/
|
|
||||||
@AndroidEntryPoint
|
|
||||||
class EventReminderReceiver : BroadcastReceiver() {
|
|
||||||
|
|
||||||
@Inject lateinit var alertStore: ReminderAlertStore
|
|
||||||
@Inject lateinit var notifier: ReminderNotifier
|
|
||||||
@Inject lateinit var settingsPrefs: SettingsPrefs
|
|
||||||
|
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
|
||||||
if (intent.action != CalendarContract.ACTION_EVENT_REMINDER) return
|
|
||||||
val readGranted = ContextCompat.checkSelfPermission(
|
|
||||||
context, Manifest.permission.READ_CALENDAR,
|
|
||||||
) == PackageManager.PERMISSION_GRANTED
|
|
||||||
if (!readGranted || !notifier.canPost()) return
|
|
||||||
|
|
||||||
val pendingResult = goAsync()
|
|
||||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
|
||||||
try {
|
|
||||||
if (settingsPrefs.remindersEnabled.first()) {
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
val due = alertStore.dueAlerts(now)
|
|
||||||
val postedIds = due
|
|
||||||
.filter { notifier.post(it) }
|
|
||||||
.mapTo(mutableSetOf()) { it.alertId }
|
|
||||||
alertStore.markFired(handledAlertIds(due, postedIds, now), now)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
pendingResult.finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
@@ -18,14 +19,14 @@ import javax.inject.Inject
|
|||||||
* intents (notification action buttons and our own [ReminderSnoozeScheduler]
|
* intents (notification action buttons and our own [ReminderSnoozeScheduler]
|
||||||
* alarm), so the receiver is not exported.
|
* alarm), so the receiver is not exported.
|
||||||
*
|
*
|
||||||
* - **Dismiss** just cancels the notification — the `CalendarAlerts` row is
|
* - **Dismiss** just cancels the notification — the scan's watermark has moved
|
||||||
* already fired, so nothing re-posts it.
|
* past this reminder, so nothing re-posts it.
|
||||||
* - **Snooze** cancels the notification and schedules an exact alarm to re-show
|
* - **Snooze** cancels the notification and schedules an exact alarm to re-show
|
||||||
* it after the user's snooze delay.
|
* it after the user's snooze delay.
|
||||||
* - **Show** (the alarm) re-posts the same notification, so the user can snooze
|
* - **Show** (the alarm) re-posts the same notification, so the user can snooze
|
||||||
* or dismiss it again — unless the calendar was switched off during the
|
* or dismiss it again — unless the calendar was switched off during the
|
||||||
* snooze, which [ReminderNotifier.post] catches (this alarm is ours, so no
|
* snooze, which [ReminderNotifier.post] catches — this alarm is its own
|
||||||
* provider alert row stands between it and the notification).
|
* trigger, outside the ordinary scan.
|
||||||
*/
|
*/
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class ReminderActionReceiver : BroadcastReceiver() {
|
class ReminderActionReceiver : BroadcastReceiver() {
|
||||||
@@ -75,7 +76,14 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
|||||||
const val ACTION_DISMISS = "de.jeanlucmakiola.calendula.reminders.DISMISS"
|
const val ACTION_DISMISS = "de.jeanlucmakiola.calendula.reminders.DISMISS"
|
||||||
const val ACTION_SHOW = "de.jeanlucmakiola.calendula.reminders.SHOW"
|
const val ACTION_SHOW = "de.jeanlucmakiola.calendula.reminders.SHOW"
|
||||||
|
|
||||||
private const val EXTRA_ALERT_ID = "alert_id"
|
/**
|
||||||
|
* Not handled here — the notification body opens the detail screen
|
||||||
|
* directly. It only claims a slot in [requestCode] so that intent stays
|
||||||
|
* distinct from the three this receiver does handle.
|
||||||
|
*/
|
||||||
|
const val ACTION_OPEN = "de.jeanlucmakiola.calendula.reminders.OPEN"
|
||||||
|
|
||||||
|
private const val EXTRA_ALERT_KEY = "alert_key"
|
||||||
private const val EXTRA_EVENT_ID = "event_id"
|
private const val EXTRA_EVENT_ID = "event_id"
|
||||||
private const val EXTRA_CALENDAR_ID = "calendar_id"
|
private const val EXTRA_CALENDAR_ID = "calendar_id"
|
||||||
private const val EXTRA_BEGIN = "begin"
|
private const val EXTRA_BEGIN = "begin"
|
||||||
@@ -84,11 +92,20 @@ 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
|
||||||
putExtra(EXTRA_ALERT_ID, alert.alertId)
|
data = "calendula://reminder/${alert.key}".toUri()
|
||||||
|
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)
|
||||||
putExtra(EXTRA_BEGIN, alert.beginMillis)
|
putExtra(EXTRA_BEGIN, alert.beginMillis)
|
||||||
@@ -99,23 +116,29 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A stable request code per (alert, action) so the three PendingIntents
|
* A stable request code per (alert, action) so one notification's
|
||||||
* of one notification 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
|
||||||
|
* keeps the action slot intact; the top three bits it drops can make two
|
||||||
|
* *different* reminders share a code, which the per-reminder data URI in
|
||||||
|
* [intent] separates.
|
||||||
*/
|
*/
|
||||||
fun requestCode(alert: ReminderAlert, action: String): Int {
|
fun requestCode(alert: ReminderAlert, action: String): Int {
|
||||||
val actionOffset = when (action) {
|
val actionOffset = when (action) {
|
||||||
ACTION_SNOOZE -> 1
|
ACTION_SNOOZE -> 1
|
||||||
ACTION_DISMISS -> 2
|
ACTION_DISMISS -> 2
|
||||||
ACTION_SHOW -> 3
|
ACTION_SHOW -> 3
|
||||||
|
ACTION_OPEN -> 4
|
||||||
else -> 0
|
else -> 0
|
||||||
}
|
}
|
||||||
return alert.alertId.toInt() * 8 + actionOffset
|
return (alert.key.toInt() shl 3) + actionOffset
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun alertFrom(intent: Intent): ReminderAlert? {
|
private fun alertFrom(intent: Intent): ReminderAlert? {
|
||||||
if (!intent.hasExtra(EXTRA_ALERT_ID)) return null
|
if (!intent.hasExtra(EXTRA_ALERT_KEY)) return null
|
||||||
return ReminderAlert(
|
return ReminderAlert(
|
||||||
alertId = intent.getLongExtra(EXTRA_ALERT_ID, 0L),
|
key = intent.getLongExtra(EXTRA_ALERT_KEY, 0L),
|
||||||
eventId = intent.getLongExtra(EXTRA_EVENT_ID, 0L),
|
eventId = intent.getLongExtra(EXTRA_EVENT_ID, 0L),
|
||||||
calendarId = intent.getLongExtra(EXTRA_CALENDAR_ID, 0L),
|
calendarId = intent.getLongExtra(EXTRA_CALENDAR_ID, 0L),
|
||||||
beginMillis = intent.getLongExtra(EXTRA_BEGIN, 0L),
|
beginMillis = intent.getLongExtra(EXTRA_BEGIN, 0L),
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
|
import android.app.AlarmManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.content.getSystemService
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True on API < 31 (no restriction), and on 31+ when the exact-alarm capability
|
||||||
|
* is held — auto-granted via `USE_EXACT_ALARM` on API 33+ (Calendula is a
|
||||||
|
* calendar app), user-revocable on 31–32.
|
||||||
|
*/
|
||||||
|
internal fun AlarmManager.canScheduleExactCompat(): Boolean =
|
||||||
|
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || canScheduleExactAlarms()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds the app's own wake-up for the next reminder — the half of delivery that
|
||||||
|
* used to be the provider's (#75).
|
||||||
|
*
|
||||||
|
* Exactly **one** alarm exists at a time, for the earliest reminder still ahead.
|
||||||
|
* Every firing re-scans and re-arms, so a reminder added, moved or deleted in
|
||||||
|
* between is picked up on the next pass instead of needing an alarm per reminder
|
||||||
|
* to be kept in sync with the provider's tables.
|
||||||
|
*
|
||||||
|
* A reminder that lands late is a broken reminder, hence an *exact* alarm; the
|
||||||
|
* inexact allow-while-idle fallback only applies where the OS withholds the
|
||||||
|
* capability (API 31–32 with the user's permission revoked).
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class ReminderAlarmScheduler @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun scheduleScan(triggerAtMillis: Long) {
|
||||||
|
val alarmManager = context.getSystemService<AlarmManager>() ?: return
|
||||||
|
val pendingIntent = scanPendingIntent(PendingIntent.FLAG_UPDATE_CURRENT)
|
||||||
|
if (alarmManager.canScheduleExactCompat()) {
|
||||||
|
alarmManager.setExactAndAllowWhileIdle(
|
||||||
|
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
alarmManager.setAndAllowWhileIdle(
|
||||||
|
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop the pending wake-up — reminders are off, or there is nothing to wait for. */
|
||||||
|
fun cancelScan() {
|
||||||
|
val alarmManager = context.getSystemService<AlarmManager>() ?: return
|
||||||
|
alarmManager.cancel(scanPendingIntent(PendingIntent.FLAG_UPDATE_CURRENT))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scanPendingIntent(flags: Int): PendingIntent = PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
SCAN_REQUEST_CODE,
|
||||||
|
Intent(context, ReminderScheduleReceiver::class.java)
|
||||||
|
.setAction(ReminderScheduleReceiver.ACTION_SCAN),
|
||||||
|
flags or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
// Fixed: there is only ever one scan alarm, and re-arming must replace it.
|
||||||
|
const val SCAN_REQUEST_CODE = 0x5CA1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.reminders.PlannedReminder
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One reminder as the notification layer needs it: what to show, and the stable
|
||||||
|
* [key] that identifies it across a reboot, a re-scan and a reinstall.
|
||||||
|
*
|
||||||
|
* [key] used to be the `CalendarAlerts` row id. It is now derived from the
|
||||||
|
* reminder itself (see [PlannedReminder.key]) because there is no row any more —
|
||||||
|
* in-house delivery reads `Instances` and `Reminders` and owns the alarm (#75).
|
||||||
|
* Everything downstream only ever needed it to be stable and unique, which it
|
||||||
|
* still is: it keys the notification tag, so a reminder posted twice replaces
|
||||||
|
* itself instead of stacking, and it keys the snooze/dismiss `PendingIntent`s.
|
||||||
|
*/
|
||||||
|
data class ReminderAlert(
|
||||||
|
val key: Long,
|
||||||
|
val eventId: Long,
|
||||||
|
val calendarId: Long,
|
||||||
|
val beginMillis: Long,
|
||||||
|
val endMillis: Long,
|
||||||
|
/** Raw event title; may be blank — the notifier substitutes "(no title)". */
|
||||||
|
val title: String,
|
||||||
|
val location: String?,
|
||||||
|
val isAllDay: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun PlannedReminder.toAlert(): ReminderAlert = ReminderAlert(
|
||||||
|
key = key,
|
||||||
|
eventId = instance.eventId,
|
||||||
|
calendarId = instance.calendarId,
|
||||||
|
beginMillis = instance.beginMillis,
|
||||||
|
endMillis = instance.endMillis,
|
||||||
|
title = instance.title,
|
||||||
|
location = instance.location,
|
||||||
|
isAllDay = instance.isAllDay,
|
||||||
|
)
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.reminders
|
|
||||||
|
|
||||||
import android.content.ContentValues
|
|
||||||
import android.content.Context
|
|
||||||
import android.provider.CalendarContract
|
|
||||||
import android.util.Log
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One due row of the provider's `CalendarAlerts` table (a join with Events).
|
|
||||||
* Stays in the data layer: alerts feed the notification path only and never
|
|
||||||
* reach a screen, so there is no domain model for them.
|
|
||||||
*/
|
|
||||||
data class ReminderAlert(
|
|
||||||
val alertId: Long,
|
|
||||||
val eventId: Long,
|
|
||||||
val calendarId: Long,
|
|
||||||
val beginMillis: Long,
|
|
||||||
val endMillis: Long,
|
|
||||||
/** Raw event title; may be blank — the notifier substitutes "(no title)". */
|
|
||||||
val title: String,
|
|
||||||
val location: String?,
|
|
||||||
val isAllDay: Boolean,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Seam over the `CalendarAlerts` table so the receiver logic can be exercised
|
|
||||||
* without a ContentResolver. The provider creates these rows itself — only
|
|
||||||
* for `METHOD_ALERT` reminders (verified in AOSP `CalendarAlarmManager`), so
|
|
||||||
* email reminders never show up here.
|
|
||||||
*/
|
|
||||||
interface ReminderAlertStore {
|
|
||||||
|
|
||||||
/** Alerts that are due (`ALARM_TIME` has passed) and still unhandled. */
|
|
||||||
fun dueAlerts(nowMillis: Long): List<ReminderAlert>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mark the given alerts handled (`STATE_FIRED`) so a later broadcast does
|
|
||||||
* not surface them again. Best effort: this write needs `WRITE_CALENDAR`,
|
|
||||||
* which the user may have declined — then re-broadcasts silently replace
|
|
||||||
* the already-posted notifications instead (same tag, alert-once).
|
|
||||||
*/
|
|
||||||
fun markFired(alertIds: List<Long>, nowMillis: Long)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Singleton
|
|
||||||
class AndroidReminderAlertStore @Inject constructor(
|
|
||||||
@ApplicationContext private val context: Context,
|
|
||||||
) : ReminderAlertStore {
|
|
||||||
|
|
||||||
override fun dueAlerts(nowMillis: Long): List<ReminderAlert> = context.contentResolver.query(
|
|
||||||
CalendarContract.CalendarAlerts.CONTENT_URI,
|
|
||||||
PROJECTION,
|
|
||||||
CalendarContract.CalendarAlerts.STATE + " = ? AND " +
|
|
||||||
CalendarContract.CalendarAlerts.ALARM_TIME + " <= ?",
|
|
||||||
arrayOf(
|
|
||||||
CalendarContract.CalendarAlerts.STATE_SCHEDULED.toString(),
|
|
||||||
nowMillis.toString(),
|
|
||||||
),
|
|
||||||
CalendarContract.CalendarAlerts.BEGIN + " ASC",
|
|
||||||
)?.use { c ->
|
|
||||||
buildList {
|
|
||||||
while (c.moveToNext()) {
|
|
||||||
add(
|
|
||||||
ReminderAlert(
|
|
||||||
alertId = c.getLong(0),
|
|
||||||
eventId = c.getLong(1),
|
|
||||||
calendarId = c.getLong(2),
|
|
||||||
beginMillis = c.getLong(3),
|
|
||||||
endMillis = c.getLong(4),
|
|
||||||
title = c.getString(5).orEmpty(),
|
|
||||||
location = c.getString(6)?.takeIf { it.isNotBlank() },
|
|
||||||
isAllDay = c.getInt(7) == 1,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} ?: emptyList()
|
|
||||||
|
|
||||||
override fun markFired(alertIds: List<Long>, nowMillis: Long) {
|
|
||||||
if (alertIds.isEmpty()) return
|
|
||||||
val values = ContentValues().apply {
|
|
||||||
put(CalendarContract.CalendarAlerts.STATE, CalendarContract.CalendarAlerts.STATE_FIRED)
|
|
||||||
put(CalendarContract.CalendarAlerts.RECEIVED_TIME, nowMillis)
|
|
||||||
put(CalendarContract.CalendarAlerts.NOTIFY_TIME, nowMillis)
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
context.contentResolver.update(
|
|
||||||
CalendarContract.CalendarAlerts.CONTENT_URI,
|
|
||||||
values,
|
|
||||||
CalendarContract.CalendarAlerts._ID +
|
|
||||||
" IN (" + alertIds.joinToString(",") + ")",
|
|
||||||
null,
|
|
||||||
)
|
|
||||||
} catch (e: SecurityException) {
|
|
||||||
Log.w(TAG, "Cannot mark alerts fired without WRITE_CALENDAR", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val TAG = "ReminderAlertStore"
|
|
||||||
val PROJECTION = arrayOf(
|
|
||||||
CalendarContract.CalendarAlerts._ID,
|
|
||||||
CalendarContract.CalendarAlerts.EVENT_ID,
|
|
||||||
CalendarContract.CalendarAlerts.CALENDAR_ID,
|
|
||||||
CalendarContract.CalendarAlerts.BEGIN,
|
|
||||||
CalendarContract.CalendarAlerts.END,
|
|
||||||
CalendarContract.CalendarAlerts.TITLE,
|
|
||||||
CalendarContract.CalendarAlerts.EVENT_LOCATION,
|
|
||||||
CalendarContract.CalendarAlerts.ALL_DAY,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.ContentUris
|
||||||
|
import android.provider.CalendarContract
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.jeanlucmakiola.calendula.domain.reminders.ReminderEventInstance
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The read side of in-house reminder delivery: occurrences and the reminder
|
||||||
|
* offsets hanging off them, straight out of the provider's own tables.
|
||||||
|
*
|
||||||
|
* Deliberately *not* `CalendarAlerts`. That table is the provider's own working
|
||||||
|
* copy of this same information, and the whole point of #75's second round is
|
||||||
|
* that we can no longer assume it gets written. `Instances` and `Reminders` are
|
||||||
|
* plain data the app already reads everywhere else.
|
||||||
|
*
|
||||||
|
* An interface so [ReminderScanner] can be exercised on the JVM without a
|
||||||
|
* ContentResolver, in the shape the rest of `data/calendar` uses.
|
||||||
|
*/
|
||||||
|
interface ReminderInstanceSource {
|
||||||
|
|
||||||
|
/** Occurrences overlapping `[fromMillis, toMillis]`, of switched-on calendars. */
|
||||||
|
fun occurrences(fromMillis: Long, toMillis: Long): List<ReminderEventInstance>
|
||||||
|
|
||||||
|
/** `METHOD_ALERT` reminder offsets per event id, for the given events. */
|
||||||
|
fun reminderMinutes(eventIds: Collection<Long>): Map<Long, List<Int>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The largest `METHOD_ALERT` offset anywhere in the table, so the query
|
||||||
|
* window can be stretched to cover it and a long-lead reminder is planned
|
||||||
|
* before it comes due rather than firing late.
|
||||||
|
*/
|
||||||
|
fun longestReminderMinutes(): Int
|
||||||
|
}
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class ProviderReminderInstanceSource @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) : ReminderInstanceSource {
|
||||||
|
|
||||||
|
override fun occurrences(fromMillis: Long, toMillis: Long): List<ReminderEventInstance> {
|
||||||
|
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
|
||||||
|
ContentUris.appendId(this, fromMillis)
|
||||||
|
ContentUris.appendId(this, toMillis)
|
||||||
|
}.build()
|
||||||
|
// `visible` is the calendar's system flag, which the app's one visibility
|
||||||
|
// model writes (#75) — a switched-off calendar must not plan reminders.
|
||||||
|
// The status clause mirrors CalendarDataSource.instances: a cancelled
|
||||||
|
// single occurrence of a series is a real row, and NULL means "normal",
|
||||||
|
// so a bare `!= CANCELED` would drop every ordinary event.
|
||||||
|
val selection = "${CalendarContract.Calendars.VISIBLE} = 1 AND " +
|
||||||
|
"(${CalendarContract.Instances.STATUS} IS NULL OR " +
|
||||||
|
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED})"
|
||||||
|
return context.contentResolver.query(
|
||||||
|
uri, OCCURRENCE_PROJECTION, selection, null, null,
|
||||||
|
)?.use { c ->
|
||||||
|
buildList {
|
||||||
|
while (c.moveToNext()) {
|
||||||
|
add(
|
||||||
|
ReminderEventInstance(
|
||||||
|
eventId = c.getLong(0),
|
||||||
|
calendarId = c.getLong(1),
|
||||||
|
beginMillis = c.getLong(2),
|
||||||
|
endMillis = if (c.isNull(3)) 0L else c.getLong(3),
|
||||||
|
title = c.getString(4).orEmpty(),
|
||||||
|
location = c.getString(5)?.takeIf { it.isNotBlank() },
|
||||||
|
isAllDay = c.getInt(6) == 1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} ?: emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun reminderMinutes(eventIds: Collection<Long>): Map<Long, List<Int>> {
|
||||||
|
if (eventIds.isEmpty()) return emptyMap()
|
||||||
|
val out = mutableMapOf<Long, MutableList<Int>>()
|
||||||
|
// Batched because the ids go into the selection literally; an unbounded
|
||||||
|
// `IN (...)` on a busy calendar would grow the SQL past what SQLite takes.
|
||||||
|
eventIds.distinct().chunked(EVENT_ID_BATCH).forEach { batch ->
|
||||||
|
context.contentResolver.query(
|
||||||
|
CalendarContract.Reminders.CONTENT_URI,
|
||||||
|
REMINDER_PROJECTION,
|
||||||
|
"${CalendarContract.Reminders.METHOD} = " +
|
||||||
|
"${CalendarContract.Reminders.METHOD_ALERT} AND " +
|
||||||
|
"${CalendarContract.Reminders.EVENT_ID} IN (${batch.joinToString(",")})",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
)?.use { c ->
|
||||||
|
while (c.moveToNext()) {
|
||||||
|
out.getOrPut(c.getLong(0)) { mutableListOf() } += c.getInt(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun longestReminderMinutes(): Int = context.contentResolver.query(
|
||||||
|
CalendarContract.Reminders.CONTENT_URI,
|
||||||
|
arrayOf(CalendarContract.Reminders.MINUTES),
|
||||||
|
"${CalendarContract.Reminders.METHOD} = ${CalendarContract.Reminders.METHOD_ALERT}",
|
||||||
|
null,
|
||||||
|
// One row is enough: the provider passes the sort order to SQLite.
|
||||||
|
"${CalendarContract.Reminders.MINUTES} DESC",
|
||||||
|
)?.use { c -> if (c.moveToFirst()) c.getInt(0) else 0 } ?: 0
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val EVENT_ID_BATCH = 50
|
||||||
|
|
||||||
|
val OCCURRENCE_PROJECTION = arrayOf(
|
||||||
|
CalendarContract.Instances.EVENT_ID,
|
||||||
|
CalendarContract.Instances.CALENDAR_ID,
|
||||||
|
CalendarContract.Instances.BEGIN,
|
||||||
|
CalendarContract.Instances.END,
|
||||||
|
CalendarContract.Instances.TITLE,
|
||||||
|
CalendarContract.Instances.EVENT_LOCATION,
|
||||||
|
CalendarContract.Instances.ALL_DAY,
|
||||||
|
)
|
||||||
|
|
||||||
|
val REMINDER_PROJECTION = arrayOf(
|
||||||
|
CalendarContract.Reminders.EVENT_ID,
|
||||||
|
CalendarContract.Reminders.MINUTES,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
|
import androidx.work.PeriodicWorkRequestBuilder
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import dagger.hilt.EntryPoint
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.EntryPointAccessors
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The backstop under the alarm: a daily scan that runs whether or not the alarm
|
||||||
|
* survived.
|
||||||
|
*
|
||||||
|
* The scan alarm re-arms itself at most a day out, so in the steady state this
|
||||||
|
* finds nothing to do. It exists for the case the whole feature is about — a
|
||||||
|
* device that quietly drops the alarm without a reboot to announce it. The
|
||||||
|
* scheduling half of reminder delivery must not have a single point of failure,
|
||||||
|
* which is exactly what the provider's broadcast turned out to be.
|
||||||
|
*/
|
||||||
|
object ReminderMaintenanceScheduler {
|
||||||
|
|
||||||
|
private const val WORK_NAME = "reminder-scan-maintenance"
|
||||||
|
|
||||||
|
/** Enqueue the daily backstop; idempotent, so every launch may call it. */
|
||||||
|
fun apply(context: Context) {
|
||||||
|
val request = PeriodicWorkRequestBuilder<ReminderMaintenanceWorker>(1, TimeUnit.DAYS)
|
||||||
|
// The launch scan covers now; let the first periodic run wait.
|
||||||
|
.setInitialDelay(1, TimeUnit.DAYS)
|
||||||
|
.build()
|
||||||
|
WorkManager.getInstance(context)
|
||||||
|
.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ReminderMaintenanceWorker(
|
||||||
|
appContext: Context,
|
||||||
|
params: WorkerParameters,
|
||||||
|
) : CoroutineWorker(appContext, params) {
|
||||||
|
|
||||||
|
@EntryPoint
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
interface Deps {
|
||||||
|
fun reminderScanner(): ReminderScanner
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun doWork(): Result = try {
|
||||||
|
EntryPointAccessors.fromApplication(applicationContext, Deps::class.java)
|
||||||
|
.reminderScanner()
|
||||||
|
.scan()
|
||||||
|
Result.success()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// The scan swallows its own failures; anything reaching here is the
|
||||||
|
// entry point itself, which a retry will not mend. Never fail the chain.
|
||||||
|
Log.w(TAG, "Reminder maintenance scan failed", e)
|
||||||
|
Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "ReminderMaintenance"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,11 +29,13 @@ import javax.inject.Inject
|
|||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Posts one notification per due reminder alert on a dedicated channel.
|
* Posts one notification per due reminder on a dedicated channel. Tapping opens
|
||||||
* Tapping opens the event's detail screen; the tag is the alert id, so a
|
* the event's detail screen.
|
||||||
* re-broadcast of an alert we couldn't mark fired replaces its notification
|
*
|
||||||
* silently ([NotificationCompat.Builder.setOnlyAlertOnce]) instead of
|
* The tag is the reminder's stable key, so a scan that posts the same reminder
|
||||||
* duplicating it.
|
* again — a catch-up pass overlapping the alarm that already fired — replaces
|
||||||
|
* its notification silently ([NotificationCompat.Builder.setOnlyAlertOnce])
|
||||||
|
* instead of stacking a second one.
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class ReminderNotifier @Inject constructor(
|
class ReminderNotifier @Inject constructor(
|
||||||
@@ -52,13 +54,12 @@ class ReminderNotifier @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The single choke point for "this calendar is switched off". The provider
|
* The single choke point for "this calendar is switched off". The scan
|
||||||
* side needs no help — with `VISIBLE = 0` it creates no alert rows at all —
|
* already filters on `Calendars.VISIBLE`, but two paths reach [post] around
|
||||||
* but two paths reach [post] without one: a snooze we re-show from our own
|
* it: a snooze re-shown from its own alarm, armed before the calendar was
|
||||||
* exact alarm, scheduled before the calendar was switched off, and a
|
* switched off, and a read-only install whose switch lives app-side
|
||||||
* read-only install whose switch lives app-side ([CalendarPrefs]) because it
|
* ([CalendarPrefs]) because it may not write the flag. Both are covered here
|
||||||
* may not write the flag. Both are covered here rather than in either
|
* rather than in either receiver.
|
||||||
* receiver.
|
|
||||||
*/
|
*/
|
||||||
private suspend fun isSilenced(calendarId: Long): Boolean =
|
private suspend fun isSilenced(calendarId: Long): Boolean =
|
||||||
calendarId in calendarPrefs.pendingDisabledCalendarIds.first() ||
|
calendarId in calendarPrefs.pendingDisabledCalendarIds.first() ||
|
||||||
@@ -66,8 +67,8 @@ class ReminderNotifier @Inject constructor(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Post [alert], unless its calendar is switched off. Returns whether the
|
* Post [alert], unless its calendar is switched off. Returns whether the
|
||||||
* notification was put up: a silenced alert must stay unhandled so that
|
* notification was put up, which the snooze re-show path uses to tell a
|
||||||
* switching the calendar back on can still surface it (see [handledAlertIds]).
|
* silenced reminder from a delivered one.
|
||||||
*/
|
*/
|
||||||
suspend fun post(alert: ReminderAlert): Boolean {
|
suspend fun post(alert: ReminderAlert): Boolean {
|
||||||
if (isSilenced(alert.calendarId)) return false
|
if (isSilenced(alert.calendarId)) return false
|
||||||
@@ -117,7 +118,7 @@ class ReminderNotifier @Inject constructor(
|
|||||||
.build()
|
.build()
|
||||||
try {
|
try {
|
||||||
NotificationManagerCompat.from(context)
|
NotificationManagerCompat.from(context)
|
||||||
.notify(alert.alertId.toString(), NOTIFICATION_ID, notification)
|
.notify(alert.key.toString(), NOTIFICATION_ID, notification)
|
||||||
} catch (e: SecurityException) {
|
} catch (e: SecurityException) {
|
||||||
// POST_NOTIFICATIONS was revoked between canPost() and here.
|
// POST_NOTIFICATIONS was revoked between canPost() and here.
|
||||||
Log.w(TAG, "Could not post reminder for event ${alert.eventId}", e)
|
Log.w(TAG, "Could not post reminder for event ${alert.eventId}", e)
|
||||||
@@ -128,7 +129,7 @@ class ReminderNotifier @Inject constructor(
|
|||||||
|
|
||||||
/** Remove a posted reminder (snooze re-shows it later; dismiss is final). */
|
/** Remove a posted reminder (snooze re-shows it later; dismiss is final). */
|
||||||
fun cancel(alert: ReminderAlert) {
|
fun cancel(alert: ReminderAlert) {
|
||||||
NotificationManagerCompat.from(context).cancel(alert.alertId.toString(), NOTIFICATION_ID)
|
NotificationManagerCompat.from(context).cancel(alert.key.toString(), NOTIFICATION_ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun actionIntent(alert: ReminderAlert, action: String): PendingIntent =
|
private fun actionIntent(alert: ReminderAlert, action: String): PendingIntent =
|
||||||
@@ -141,7 +142,11 @@ class ReminderNotifier @Inject constructor(
|
|||||||
|
|
||||||
private fun detailIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity(
|
private fun detailIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity(
|
||||||
context,
|
context,
|
||||||
/* requestCode = */ alert.alertId.toInt(),
|
// Shares the per-(alert, action) request-code scheme with the buttons, so
|
||||||
|
// the key's wider value range can't collide one notification's intents.
|
||||||
|
/* requestCode = */ ReminderActionReceiver.requestCode(
|
||||||
|
alert, ReminderActionReceiver.ACTION_OPEN,
|
||||||
|
),
|
||||||
MainActivity.eventDetailIntent(context, alert.eventId, alert.beginMillis, alert.endMillis),
|
MainActivity.eventDetailIntent(context, alert.eventId, alert.beginMillis, alert.endMillis),
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.reminders
|
|
||||||
|
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Re-posts the reminders a switched-off calendar silenced, when it is switched
|
|
||||||
* back on while they still matter.
|
|
||||||
*
|
|
||||||
* Only the app-side switch needs this — a read-only install, or an upgrade the
|
|
||||||
* reconciler hasn't flushed yet. With `Calendars.VISIBLE = 0` the provider
|
|
||||||
* deletes the calendar's alert rows itself and re-creates them on the way back;
|
|
||||||
* app-side the rows stay, still `STATE_SCHEDULED`, because
|
|
||||||
* [EventReminderReceiver] deliberately leaves the ones it silenced unhandled
|
|
||||||
* (see [handledAlertIds]). So the provider's own table is the stash, and nothing
|
|
||||||
* is mirrored locally.
|
|
||||||
*
|
|
||||||
* Best effort at switch-on time: it mirrors the receiver's gates (reminders on,
|
|
||||||
* notifications postable) and there is no later re-scan, so an alert left
|
|
||||||
* unposted because those are closed is simply released.
|
|
||||||
*/
|
|
||||||
@Singleton
|
|
||||||
class ReminderRecovery @Inject constructor(
|
|
||||||
private val alertStore: ReminderAlertStore,
|
|
||||||
private val notifier: ReminderNotifier,
|
|
||||||
private val settingsPrefs: SettingsPrefs,
|
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
|
||||||
) {
|
|
||||||
|
|
||||||
suspend fun rePostFor(calendarIds: Collection<Long>) = withContext(io) {
|
|
||||||
if (calendarIds.isEmpty()) return@withContext
|
|
||||||
if (!settingsPrefs.remindersEnabled.first() || !notifier.canPost()) return@withContext
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
val ids = calendarIds.toSet()
|
|
||||||
val recovered = alertStore.dueAlerts(now)
|
|
||||||
.filter { it.calendarId in ids && it.isRelevantAt(now) }
|
|
||||||
if (recovered.isEmpty()) return@withContext
|
|
||||||
val postedIds = recovered.filter { notifier.post(it) }.map { it.alertId }
|
|
||||||
alertStore.markFired(postedIds, now)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||||
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.ReminderStatePrefs
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.domain.reminders.planReminders
|
||||||
|
import de.jeanlucmakiola.calendula.domain.reminders.reminderQueryHorizon
|
||||||
|
import de.jeanlucmakiola.calendula.domain.reminders.reminderWatermark
|
||||||
|
import de.jeanlucmakiola.calendula.domain.reminders.scheduleReminders
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.CoroutineStart
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.debounce
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.time.ZoneId
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One pass of in-house reminder delivery: read what is planned, post what has
|
||||||
|
* come due, and arm the next wake-up.
|
||||||
|
*
|
||||||
|
* This is the whole loop. Every trigger — the alarm firing, boot, a clock or
|
||||||
|
* timezone change, an edit landing in the provider, the app starting, the daily
|
||||||
|
* safety net — runs the same [scan], so there is a single path to reason about
|
||||||
|
* and no ordering between triggers to get wrong. Re-running it is always safe:
|
||||||
|
* the watermark in [ReminderStatePrefs] decides what is owed, not the trigger.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class ReminderScanner @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
private val source: ReminderInstanceSource,
|
||||||
|
private val calendarDataSource: CalendarDataSource,
|
||||||
|
private val notifier: ReminderNotifier,
|
||||||
|
private val alarms: ReminderAlarmScheduler,
|
||||||
|
private val state: ReminderStatePrefs,
|
||||||
|
private val settingsPrefs: SettingsPrefs,
|
||||||
|
@IoDispatcher private val io: kotlinx.coroutines.CoroutineDispatcher,
|
||||||
|
) {
|
||||||
|
|
||||||
|
// Triggers overlap freely (an alarm during a burst of edits); serialize so
|
||||||
|
// two passes can't both read the same watermark and post the same reminder.
|
||||||
|
private val scanLock = Mutex()
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + io)
|
||||||
|
private val providerChanges = MutableSharedFlow<Unit>(
|
||||||
|
replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||||
|
)
|
||||||
|
private var watching = false
|
||||||
|
|
||||||
|
suspend fun scan() = withContext(io) {
|
||||||
|
scanLock.withLock {
|
||||||
|
try {
|
||||||
|
runScan()
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
// The calendar permission was revoked mid-flight. Nothing to
|
||||||
|
// re-arm and nothing to recover from — the next grant re-scans.
|
||||||
|
Log.w(TAG, "Reminder scan lacks the calendar permission", e)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Reminder scan failed", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun runScan() {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
if (!hasReadCalendar()) return
|
||||||
|
|
||||||
|
if (!settingsPrefs.remindersEnabled.first()) {
|
||||||
|
// Reminders off: drop the wake-up, but keep the watermark moving so
|
||||||
|
// switching them back on doesn't replay everything missed meanwhile.
|
||||||
|
alarms.cancelScan()
|
||||||
|
state.setLastScanMillis(now)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val lookahead = reminderQueryHorizon(LOOKAHEAD_MILLIS, source.longestReminderMinutes())
|
||||||
|
// Reach a little into the past as well: an event already under way can
|
||||||
|
// still owe a reminder (an all-day "at time of event" encodes to a
|
||||||
|
// negative offset, which fires after begin), and a catch-up pass needs
|
||||||
|
// to see the occurrences whose moment it missed.
|
||||||
|
val occurrences = source.occurrences(now - PAST_WINDOW_MILLIS, now + lookahead)
|
||||||
|
val planned = planReminders(
|
||||||
|
instances = occurrences,
|
||||||
|
minutesByEvent = source.reminderMinutes(occurrences.map { it.eventId }),
|
||||||
|
zone = ZoneId.systemDefault(),
|
||||||
|
allDayTimeMinutes = settingsPrefs.allDayReminderTimeMinutes.first(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val schedule = scheduleReminders(
|
||||||
|
planned = planned,
|
||||||
|
lastFiredMillis = reminderWatermark(state.lastScanMillis(), now),
|
||||||
|
nowMillis = now,
|
||||||
|
horizonMillis = now + MAX_ALARM_INTERVAL_MILLIS,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (notifier.canPost()) {
|
||||||
|
schedule.due.forEach { notifier.post(it.toAlert()) }
|
||||||
|
}
|
||||||
|
// Advance regardless of whether anything could be posted: a user who
|
||||||
|
// muted notifications is not owed a backlog when they unmute.
|
||||||
|
state.setLastScanMillis(now)
|
||||||
|
alarms.scheduleScan(schedule.nextAlarmMillis)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasReadCalendar(): Boolean = ContextCompat.checkSelfPermission(
|
||||||
|
context, Manifest.permission.READ_CALENDAR,
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-scan when the provider changes, so an event saved or deleted in the app
|
||||||
|
* re-arms the alarm immediately rather than waiting for the next pass.
|
||||||
|
*
|
||||||
|
* Debounced: a single save writes the event, its reminders and its
|
||||||
|
* attendees, and each lands as its own notification. Only useful while the
|
||||||
|
* process is alive — every other trigger covers the rest, which is why
|
||||||
|
* nothing here needs to survive it.
|
||||||
|
*/
|
||||||
|
fun startWatchingProvider() {
|
||||||
|
if (watching) return
|
||||||
|
watching = true
|
||||||
|
providerChanges
|
||||||
|
.debounce(PROVIDER_CHANGE_DEBOUNCE_MILLIS)
|
||||||
|
.onEach { scan() }
|
||||||
|
.launchIn(scope)
|
||||||
|
calendarDataSource.registerChangeListener { providerChanges.tryEmit(Unit) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fire-and-forget scan for callers that are not in a coroutine already. */
|
||||||
|
fun scanInBackground() {
|
||||||
|
scope.launch(start = CoroutineStart.DEFAULT) { scan() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "ReminderScanner"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far ahead occurrences are read. Stretched further by the longest
|
||||||
|
* reminder offset in the table, so this is only the floor.
|
||||||
|
*/
|
||||||
|
const val LOOKAHEAD_MILLIS = 7L * 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
/** How far back to look for occurrences that may still owe a reminder. */
|
||||||
|
const val PAST_WINDOW_MILLIS = 24L * 60 * 60 * 1000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Never wait longer than a day for the next pass, even with nothing
|
||||||
|
* pending: it rolls the lookahead window forward and re-arms an alarm
|
||||||
|
* the system may have dropped.
|
||||||
|
*/
|
||||||
|
const val MAX_ALARM_INTERVAL_MILLIS = 24L * 60 * 60 * 1000
|
||||||
|
|
||||||
|
const val PROVIDER_CHANGE_DEBOUNCE_MILLIS = 2_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every reason to re-run a reminder scan that arrives from outside the process.
|
||||||
|
*
|
||||||
|
* All of them do the same thing, because [ReminderScanner.scan] is idempotent
|
||||||
|
* and works out what is owed from its watermark rather than from why it was
|
||||||
|
* called:
|
||||||
|
*
|
||||||
|
* - **our own alarm** ([ACTION_SCAN]) — the ordinary case, a reminder is due;
|
||||||
|
* - **boot** and **package replaced** — both wipe pending alarms, so the app has
|
||||||
|
* to re-arm or reminders stop silently, which is the failure #75 is about;
|
||||||
|
* - **time and timezone changes** — they move every reminder relative to the
|
||||||
|
* armed alarm, and an all-day reminder's fire hour is recomposed in the
|
||||||
|
* current zone, so both need a fresh plan.
|
||||||
|
*
|
||||||
|
* Exported because the system broadcasts arrive from outside. [ACTION_SCAN] is
|
||||||
|
* ours and always sent as an explicit intent; another app triggering a scan
|
||||||
|
* early would only make it re-read the provider and re-arm, which is harmless.
|
||||||
|
*/
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class ReminderScheduleReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
|
@Inject lateinit var scanner: ReminderScanner
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
// Every action here does the same thing, but the filter still has to be
|
||||||
|
// checked: the receiver is exported, and the system broadcasts it takes
|
||||||
|
// are protected, so an intent arriving with any other action did not
|
||||||
|
// come from where it claims to.
|
||||||
|
if (intent.action !in HANDLED_ACTIONS) return
|
||||||
|
val pendingResult = goAsync()
|
||||||
|
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||||
|
try {
|
||||||
|
scanner.scan()
|
||||||
|
} finally {
|
||||||
|
pendingResult.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val ACTION_SCAN = "de.jeanlucmakiola.calendula.reminders.SCAN"
|
||||||
|
|
||||||
|
private val HANDLED_ACTIONS = setOf(
|
||||||
|
ACTION_SCAN,
|
||||||
|
Intent.ACTION_BOOT_COMPLETED,
|
||||||
|
Intent.ACTION_MY_PACKAGE_REPLACED,
|
||||||
|
Intent.ACTION_TIME_CHANGED,
|
||||||
|
Intent.ACTION_TIMEZONE_CHANGED,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,9 +12,12 @@ import javax.inject.Singleton
|
|||||||
/**
|
/**
|
||||||
* Schedules a one-off exact alarm that re-shows a snoozed reminder.
|
* Schedules a one-off exact alarm that re-shows a snoozed reminder.
|
||||||
*
|
*
|
||||||
* The app otherwise relies entirely on the calendar provider's `EVENT_REMINDER`
|
* Separate from [ReminderAlarmScheduler]'s scan alarm, and deliberately so: a
|
||||||
* broadcast (the Etar model), but a snoozed reminder has no provider backing —
|
* snooze is pinned to one reminder at a time the user chose, while the scan
|
||||||
* its `CalendarAlerts` row is already fired — so we must re-fire it ourselves.
|
* alarm is a single moving wake-up for whatever comes next. A re-show also has
|
||||||
|
* to outlive the scan's watermark moving past that reminder, so it carries the
|
||||||
|
* reminder in its own intent rather than re-deriving it.
|
||||||
|
*
|
||||||
* A snooze that lands late is a broken snooze, hence an *exact* alarm; we fall
|
* A snooze that lands late is a broken snooze, hence an *exact* alarm; we fall
|
||||||
* back to an inexact allow-while-idle alarm only if the OS withholds the
|
* back to an inexact allow-while-idle alarm only if the OS withholds the
|
||||||
* exact-alarm capability (API 31–32 where the user revoked it).
|
* exact-alarm capability (API 31–32 where the user revoked it).
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.reminders
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import java.time.temporal.ChronoUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
private const val MINUTES_PER_DAY = 1_440
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pair every instance with each of its event's reminder offsets.
|
||||||
|
*
|
||||||
|
* A **timed** occurrence is trivial: `begin` is an absolute instant, so
|
||||||
|
* `begin − minutes` is exact by construction, in any timezone, across any DST
|
||||||
|
* boundary.
|
||||||
|
*
|
||||||
|
* An **all-day** occurrence is not, and taking the offset at face value is what
|
||||||
|
* makes reminders land at the wrong hour. Its `begin` is UTC midnight, and the
|
||||||
|
* stored offset is not a plain lead time — `AllDayReminderEncoding` folds the
|
||||||
|
* wanted wall-clock hour into it, sampled against *one* date's UTC offset. Fire
|
||||||
|
* at `begin − minutes` and every occurrence in a different DST phase than the one
|
||||||
|
* that was sampled drifts by the offset delta, an hour early in one direction and
|
||||||
|
* an hour late in the other. Rows written by other apps carry no wall-clock at
|
||||||
|
* all — a conventional `1440` fires at UTC midnight, which is 01:00 or 02:00
|
||||||
|
* local in Berlin and the wrong day west of UTC.
|
||||||
|
*
|
||||||
|
* So the offset is only read for *which day* it means, via [allDayLeadDays], and
|
||||||
|
* the hour comes from [allDayTimeMinutes] — the one global "show all-day
|
||||||
|
* reminders at" setting — recomposed against each occurrence's own date in
|
||||||
|
* [zone]. 09:00 Berlin is then 09:00 Berlin on every occurrence, whatever the
|
||||||
|
* offset was when the row was written.
|
||||||
|
*
|
||||||
|
* [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>>,
|
||||||
|
zone: ZoneId,
|
||||||
|
allDayTimeMinutes: Int,
|
||||||
|
): List<PlannedReminder> = instances.flatMap { instance ->
|
||||||
|
minutesByEvent[instance.eventId].orEmpty().distinct().map { minutes ->
|
||||||
|
PlannedReminder(
|
||||||
|
instance = instance,
|
||||||
|
minutes = minutes,
|
||||||
|
alarmMillis = if (instance.isAllDay) {
|
||||||
|
allDayAlarmMillis(instance.beginMillis, minutes, zone, allDayTimeMinutes)
|
||||||
|
} else {
|
||||||
|
instance.beginMillis - minutes * MILLIS_PER_MINUTE
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** UTC midnight of an all-day occurrence, as the calendar date it stands for. */
|
||||||
|
private fun allDayDate(beginMillis: Long): LocalDate =
|
||||||
|
Instant.ofEpochMilli(beginMillis).atZone(ZoneOffset.UTC).toLocalDate()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many whole days before its occurrence a raw all-day offset means.
|
||||||
|
*
|
||||||
|
* Normally the local date of the encoded fire instant answers it: our own rows
|
||||||
|
* fold the wanted hour into the offset, so that date *is* the day the reminder
|
||||||
|
* belongs to, whatever the offset was when it was written.
|
||||||
|
*
|
||||||
|
* A plain multiple of 1440 is the exception. Those come from calendar apps that
|
||||||
|
* store a bare lead time against UTC midnight, and east of UTC both readings
|
||||||
|
* agree anyway — but west of UTC the instant falls on the previous local date,
|
||||||
|
* so the day count has to be taken at face value or it comes out one too many.
|
||||||
|
*
|
||||||
|
* 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,
|
||||||
|
startDate: LocalDate,
|
||||||
|
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(
|
||||||
|
beginMillis: Long,
|
||||||
|
rawMinutes: Int,
|
||||||
|
zone: ZoneId,
|
||||||
|
allDayTimeMinutes: Int,
|
||||||
|
): Long = allDayDate(beginMillis)
|
||||||
|
.minusDays(allDayLeadDays(rawMinutes, allDayDate(beginMillis), zone, allDayTimeMinutes))
|
||||||
|
.atTime(LocalTime.of(allDayTimeMinutes / 60, allDayTimeMinutes % 60))
|
||||||
|
.atZone(zone)
|
||||||
|
.toInstant()
|
||||||
|
.toEpochMilli()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The watermark a scan at [nowMillis] should measure against, given what the
|
||||||
|
* last one recorded.
|
||||||
|
*
|
||||||
|
* A first-ever scan ([lastScanMillis] `null`) claims the present, so an install
|
||||||
|
* or an upgrade onto in-house delivery does not treat every reminder since the
|
||||||
|
* epoch as overdue and bury the user in notifications. A watermark in the
|
||||||
|
* *future* — the clock was moved back, or the user travelled across the date
|
||||||
|
* line — is clamped for the mirror-image reason: left alone it would silence
|
||||||
|
* every reminder until real time caught up with it.
|
||||||
|
*/
|
||||||
|
fun reminderWatermark(lastScanMillis: Long?, nowMillis: Long): Long =
|
||||||
|
lastScanMillis?.coerceAtMost(nowMillis) ?: 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).
|
||||||
|
*
|
||||||
|
* 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 =
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,7 +99,10 @@ import de.jeanlucmakiola.calendula.domain.isNotSynced
|
|||||||
import de.jeanlucmakiola.calendula.domain.orderedForManager
|
import de.jeanlucmakiola.calendula.domain.orderedForManager
|
||||||
import de.jeanlucmakiola.calendula.domain.stateLabels
|
import de.jeanlucmakiola.calendula.domain.stateLabels
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.AccountKey
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.accountGroupTitle
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
||||||
@@ -228,7 +231,7 @@ private fun CalendarsList(
|
|||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
// Accounts the user has folded shut; empty = all expanded (keeps every
|
// Accounts the user has folded shut; empty = all expanded (keeps every
|
||||||
// calendar visible by default, the section is collapsible for tidiness).
|
// calendar visible by default, the section is collapsible for tidiness).
|
||||||
var collapsedAccounts by remember { mutableStateOf(emptySet<String>()) }
|
var collapsedAccounts by remember { mutableStateOf(emptySet<AccountKey>()) }
|
||||||
var localExpanded by remember { mutableStateOf(true) }
|
var localExpanded by remember { mutableStateOf(true) }
|
||||||
val writeErrorText = stringResource(R.string.calendars_write_error)
|
val writeErrorText = stringResource(R.string.calendars_write_error)
|
||||||
|
|
||||||
@@ -417,10 +420,11 @@ private fun CalendarsList(
|
|||||||
SectionHeader(stringResource(R.string.calendars_synced_header))
|
SectionHeader(stringResource(R.string.calendars_synced_header))
|
||||||
HintText(stringResource(R.string.calendars_synced_hint))
|
HintText(stringResource(R.string.calendars_synced_hint))
|
||||||
synced
|
synced
|
||||||
.groupBy { it.accountName.ifBlank { it.accountType } }
|
.groupByAccount()
|
||||||
.forEach { (account, cals) ->
|
.forEach { group ->
|
||||||
val expanded = account !in collapsedAccounts
|
val cals = group.calendars
|
||||||
val accountType = cals.first().accountType
|
val expanded = group.key !in collapsedAccounts
|
||||||
|
val accountType = group.accountType
|
||||||
// A non-syncing calendar has no switch, so it neither counts
|
// A non-syncing calendar has no switch, so it neither counts
|
||||||
// towards "the whole account is off" nor moves with toggle-all.
|
// towards "the whole account is off" nor moves with toggle-all.
|
||||||
val switchable = cals.filter { it.hasVisibilitySwitch }
|
val switchable = cals.filter { it.hasVisibilitySwitch }
|
||||||
@@ -428,7 +432,7 @@ private fun CalendarsList(
|
|||||||
switchable.none { it.isVisibleInSystem }
|
switchable.none { it.isVisibleInSystem }
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
CalendarGroup(
|
CalendarGroup(
|
||||||
title = account,
|
title = accountGroupTitle(group),
|
||||||
expanded = expanded,
|
expanded = expanded,
|
||||||
bodyHasRows = true,
|
bodyHasRows = true,
|
||||||
headerDisabled = accountDisabled,
|
headerDisabled = accountDisabled,
|
||||||
@@ -440,9 +444,9 @@ private fun CalendarsList(
|
|||||||
},
|
},
|
||||||
onToggleExpand = {
|
onToggleExpand = {
|
||||||
collapsedAccounts = if (expanded) {
|
collapsedAccounts = if (expanded) {
|
||||||
collapsedAccounts + account
|
collapsedAccounts + group.key
|
||||||
} else {
|
} else {
|
||||||
collapsedAccounts - account
|
collapsedAccounts - group.key
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
showToggleAll = switchable.isNotEmpty(),
|
showToggleAll = switchable.isNotEmpty(),
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
|||||||
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderRecovery
|
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
@@ -44,7 +43,6 @@ class CalendarsViewModel @Inject constructor(
|
|||||||
private val repository: CalendarRepository,
|
private val repository: CalendarRepository,
|
||||||
private val icsExporter: IcsExporter,
|
private val icsExporter: IcsExporter,
|
||||||
private val settingsPrefs: SettingsPrefs,
|
private val settingsPrefs: SettingsPrefs,
|
||||||
private val reminderRecovery: ReminderRecovery,
|
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -155,24 +153,24 @@ 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.
|
||||||
*
|
*
|
||||||
* Switching one back on also re-posts the reminders it silenced while it was
|
* A reminder that came due while the calendar was off stays gone when it is
|
||||||
* off and that are still relevant — those the app kept app-side because it
|
* switched back on. Delivery plans from `Instances` and `Reminders` on every
|
||||||
* may not write the flag ([ReminderRecovery]).
|
* 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)
|
||||||
if (visible) reminderRecovery.rePostFor(listOf(id))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Switch every calendar of one account on or off — the "toggle all"
|
* Switch every calendar of one account on or off — the "toggle all"
|
||||||
* affordance on an account header. Each row is written on its own (the
|
* affordance on an account header. Each row is written on its own, in one
|
||||||
* provider only re-arms reminder alarms for a single-id update), in one
|
|
||||||
* coroutine so the writes can't race each other.
|
* coroutine so the writes can't race each other.
|
||||||
*/
|
*/
|
||||||
fun setAccountVisible(ids: Collection<Long>, visible: Boolean) = write {
|
fun setAccountVisible(ids: Collection<Long>, visible: Boolean) = write {
|
||||||
repository.setCalendarsVisible(ids, visible)
|
repository.setCalendarsVisible(ids, visible)
|
||||||
if (visible) reminderRecovery.rePostFor(ids)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Automatic backup (issue #8) ------------------------------------
|
// --- Automatic backup (issue #8) ------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One account's calendars, as every surface that lists calendars by account
|
||||||
|
* shows them.
|
||||||
|
*
|
||||||
|
* An account is identified by name **and** type (#77). A Google account and a
|
||||||
|
* DAVx5 account can carry the same address and still be two separate accounts,
|
||||||
|
* from separate apps: merging them mixed their calendars into one group whose
|
||||||
|
* header — source logo, "manage in app", toggle-all, collapsed state — was
|
||||||
|
* derived from whichever calendar happened to sort first.
|
||||||
|
*/
|
||||||
|
data class CalendarAccountGroup(
|
||||||
|
/** Stable identity: what makes two calendars belong to the same account. */
|
||||||
|
val key: AccountKey,
|
||||||
|
/** The account's own name, as shown when it is unambiguous. */
|
||||||
|
val label: String,
|
||||||
|
/** True when another group shows the same [label] under a different type. */
|
||||||
|
val ambiguous: Boolean,
|
||||||
|
val calendars: List<CalendarSource>,
|
||||||
|
) {
|
||||||
|
val accountType: String get() = key.type
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The pair a group is keyed on. */
|
||||||
|
data class AccountKey(val name: String, val type: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group [calendars] under their owning account, preserving the provider's order
|
||||||
|
* within each group and ordering groups by first appearance.
|
||||||
|
*
|
||||||
|
* The label falls back through name → type → the first calendar's own name, so
|
||||||
|
* a calendar with no account still lands somewhere sensible.
|
||||||
|
*/
|
||||||
|
fun List<CalendarSource>.groupByAccount(): List<CalendarAccountGroup> {
|
||||||
|
val grouped = groupBy { AccountKey(it.accountName, it.accountType) }
|
||||||
|
val labels = grouped.mapValues { (key, cals) ->
|
||||||
|
key.name.ifBlank { key.type }.ifBlank { cals.first().displayName }
|
||||||
|
}
|
||||||
|
val shared = labels.values.groupingBy { it }.eachCount()
|
||||||
|
return grouped.map { (key, cals) ->
|
||||||
|
val label = labels.getValue(key)
|
||||||
|
CalendarAccountGroup(
|
||||||
|
key = key,
|
||||||
|
label = label,
|
||||||
|
ambiguous = shared.getValue(label) > 1,
|
||||||
|
calendars = cals,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What to write above a group: its account name, qualified with the app the
|
||||||
|
* account comes from when another account shares the name (#77).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun accountGroupTitle(group: CalendarAccountGroup): String =
|
||||||
|
if (!group.ambiguous) {
|
||||||
|
group.label
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.calendars_account_from_source, group.label, sourceAppName(group.accountType))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The human name of the app backing [accountType] — the same app whose icon
|
||||||
|
* [SourceLogo] draws. Falls back to the raw account type, which is at least
|
||||||
|
* unique, when no installed app resolves for it.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun sourceAppName(accountType: String): String {
|
||||||
|
val context = LocalContext.current
|
||||||
|
return remember(accountType) {
|
||||||
|
val pm = context.packageManager
|
||||||
|
val packages = sourceAppPackages(context, accountType)
|
||||||
|
packages.firstNotNullOfOrNull { pkg ->
|
||||||
|
runCatching { pm.getApplicationLabel(pm.getApplicationInfo(pkg, 0)).toString() }.getOrNull()
|
||||||
|
} ?: accountType
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,9 +60,7 @@ fun ColumnScope.CalendarPickerGroups(
|
|||||||
) {
|
) {
|
||||||
val local = remember(calendars) { calendars.filter { it.isLocal } }
|
val local = remember(calendars) { calendars.filter { it.isLocal } }
|
||||||
val syncedGroups = remember(calendars) {
|
val syncedGroups = remember(calendars) {
|
||||||
calendars.filterNot { it.isLocal }
|
calendars.filterNot { it.isLocal }.groupByAccount()
|
||||||
.groupBy { it.accountName.ifBlank { it.accountType }.ifBlank { it.displayName } }
|
|
||||||
.toList()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (local.isNotEmpty()) {
|
if (local.isNotEmpty()) {
|
||||||
@@ -74,12 +72,12 @@ fun ColumnScope.CalendarPickerGroups(
|
|||||||
onSelect = onSelect,
|
onSelect = onSelect,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
syncedGroups.forEachIndexed { index, (account, cals) ->
|
syncedGroups.forEachIndexed { index, group ->
|
||||||
if (local.isNotEmpty() || index > 0) Spacer(Modifier.height(16.dp))
|
if (local.isNotEmpty() || index > 0) Spacer(Modifier.height(16.dp))
|
||||||
CalendarPickerGroup(
|
CalendarPickerGroup(
|
||||||
title = account,
|
title = accountGroupTitle(group),
|
||||||
leading = { SourceLogo(cals.first().accountType) },
|
leading = { SourceLogo(group.accountType) },
|
||||||
calendars = cals,
|
calendars = group.calendars,
|
||||||
selectedId = selectedId,
|
selectedId = selectedId,
|
||||||
onSelect = onSelect,
|
onSelect = onSelect,
|
||||||
)
|
)
|
||||||
@@ -181,20 +179,22 @@ fun LeadingAvatar(icon: ImageVector) {
|
|||||||
/** The launcher icon of the app backing [accountType], preferring the human-facing app. */
|
/** The launcher icon of the app backing [accountType], preferring the human-facing app. */
|
||||||
private fun sourceAppLogo(context: Context, accountType: String): ImageBitmap? {
|
private fun sourceAppLogo(context: Context, accountType: String): ImageBitmap? {
|
||||||
val pm = context.packageManager
|
val pm = context.packageManager
|
||||||
val candidates = buildList {
|
for (pkg in sourceAppPackages(context, accountType)) {
|
||||||
curatedSourcePackage(accountType)?.let { add(it) }
|
|
||||||
AccountManager.get(context).authenticatorTypes
|
|
||||||
.firstOrNull { it.type.equals(accountType, ignoreCase = true) }
|
|
||||||
?.packageName
|
|
||||||
?.let { add(it) }
|
|
||||||
}
|
|
||||||
for (pkg in candidates) {
|
|
||||||
val bitmap = runCatching { pm.getApplicationIcon(pkg).toBitmap() }.getOrNull()
|
val bitmap = runCatching { pm.getApplicationIcon(pkg).toBitmap() }.getOrNull()
|
||||||
if (bitmap != null) return bitmap.asImageBitmap()
|
if (bitmap != null) return bitmap.asImageBitmap()
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Apps that could stand for [accountType], best candidate first. */
|
||||||
|
internal fun sourceAppPackages(context: Context, accountType: String): List<String> = buildList {
|
||||||
|
curatedSourcePackage(accountType)?.let { add(it) }
|
||||||
|
AccountManager.get(context).authenticatorTypes
|
||||||
|
.firstOrNull { it.type.equals(accountType, ignoreCase = true) }
|
||||||
|
?.packageName
|
||||||
|
?.let { add(it) }
|
||||||
|
}
|
||||||
|
|
||||||
/** Preferred app for account types whose authenticator isn't the app to open. */
|
/** Preferred app for account types whose authenticator isn't the app to open. */
|
||||||
internal fun curatedSourcePackage(accountType: String): String? = when {
|
internal fun curatedSourcePackage(accountType: String): String? = when {
|
||||||
accountType.equals("com.google", ignoreCase = true) -> "com.google.android.calendar"
|
accountType.equals("com.google", ignoreCase = true) -> "com.google.android.calendar"
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.sourceAppName
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
|
||||||
@@ -61,7 +62,15 @@ private fun FilterList(
|
|||||||
Column(modifier = modifier.fillMaxWidth()) {
|
Column(modifier = modifier.fillMaxWidth()) {
|
||||||
groups.forEach { group ->
|
groups.forEach { group ->
|
||||||
Text(
|
Text(
|
||||||
text = group.account,
|
text = if (group.ambiguous) {
|
||||||
|
stringResource(
|
||||||
|
R.string.calendars_account_from_source,
|
||||||
|
group.account,
|
||||||
|
sourceAppName(group.accountType),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
group.account
|
||||||
|
},
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 12.dp, bottom = 4.dp),
|
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 12.dp, bottom = 4.dp),
|
||||||
|
|||||||
@@ -13,9 +13,17 @@ sealed interface FilterUiState {
|
|||||||
data class Success(val groups: List<AccountGroup>) : FilterUiState
|
data class Success(val groups: List<AccountGroup>) : FilterUiState
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Calendars grouped under the account that owns them (Nextcloud / Local / …). */
|
/**
|
||||||
|
* Calendars grouped under the account that owns them (Nextcloud / Local / …).
|
||||||
|
*
|
||||||
|
* [accountType] and [ambiguous] carry what the header needs to tell two
|
||||||
|
* same-named accounts from different apps apart (#77); the label itself is
|
||||||
|
* built in the UI layer, which is where the source app's name can be looked up.
|
||||||
|
*/
|
||||||
data class AccountGroup(
|
data class AccountGroup(
|
||||||
val account: String,
|
val account: String,
|
||||||
|
val accountType: String,
|
||||||
|
val ambiguous: Boolean,
|
||||||
val calendars: List<CalendarRow>,
|
val calendars: List<CalendarRow>,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -38,7 +39,7 @@ class FilterViewModel @Inject constructor(
|
|||||||
if (enabled.isEmpty()) {
|
if (enabled.isEmpty()) {
|
||||||
FilterUiState.Failure(FailureReason.NoCalendarsConfigured)
|
FilterUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||||
} else {
|
} else {
|
||||||
FilterUiState.Success(groupByAccount(enabled, hidden))
|
FilterUiState.Success(groupCalendarsForFilter(enabled, hidden))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.catch { emit(FilterUiState.Failure(FailureReason.ProviderUnavailable)) }
|
.catch { emit(FilterUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||||
@@ -60,30 +61,27 @@ class FilterViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Group calendars under their owning account, preserving the provider's order
|
* Group calendars under their owning account — by name *and* type, so two
|
||||||
* within each group and ordering groups by first appearance. A calendar is
|
* accounts that merely share a name stay apart (#77) — preserving the
|
||||||
* "visible" when its id is *not* in [hidden].
|
* provider's order within each group and ordering groups by first appearance.
|
||||||
|
* A calendar is "visible" when its id is *not* in [hidden].
|
||||||
*/
|
*/
|
||||||
internal fun groupByAccount(
|
internal fun groupCalendarsForFilter(
|
||||||
calendars: List<CalendarSource>,
|
calendars: List<CalendarSource>,
|
||||||
hidden: Set<Long>,
|
hidden: Set<Long>,
|
||||||
): List<AccountGroup> =
|
): List<AccountGroup> =
|
||||||
calendars
|
calendars.groupByAccount().map { group ->
|
||||||
.groupBy { it.accountLabel() }
|
AccountGroup(
|
||||||
.map { (account, cals) ->
|
account = group.label,
|
||||||
AccountGroup(
|
accountType = group.accountType,
|
||||||
account = account,
|
ambiguous = group.ambiguous,
|
||||||
calendars = cals.map { c ->
|
calendars = group.calendars.map { c ->
|
||||||
CalendarRow(
|
CalendarRow(
|
||||||
id = c.id,
|
id = c.id,
|
||||||
displayName = c.displayName,
|
displayName = c.displayName,
|
||||||
color = c.color,
|
color = c.color,
|
||||||
visible = c.id !in hidden,
|
visible = c.id !in hidden,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Account header text: the account name, falling back to its type. */
|
|
||||||
private fun CalendarSource.accountLabel(): String =
|
|
||||||
accountName.takeIf { it.isNotBlank() } ?: accountType.takeIf { it.isNotBlank() } ?: displayName
|
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ import kotlinx.datetime.toJavaLocalDate
|
|||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Instant
|
||||||
import java.time.format.TextStyle as JavaTextStyle
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
@@ -1277,7 +1278,7 @@ internal fun SplitMonthGrid(
|
|||||||
SplitDayCell(
|
SplitDayCell(
|
||||||
date = day,
|
date = day,
|
||||||
events = seated,
|
events = seated,
|
||||||
hidden = (week.countByDay[day] ?: 0) - seated.size,
|
hidden = week.overflowEvents(col, day, MAX_EVENT_ROWS),
|
||||||
isToday = day == state.today,
|
isToday = day == state.today,
|
||||||
// A page marks only the days its own month owns. Paging
|
// A page marks only the days its own month owns. Paging
|
||||||
// moves the selection before this month's replacement
|
// moves the selection before this month's replacement
|
||||||
@@ -1318,7 +1319,7 @@ private fun SplitDayCell(
|
|||||||
date: LocalDate,
|
date: LocalDate,
|
||||||
events: List<EventInstance>,
|
events: List<EventInstance>,
|
||||||
/** Events on this day that didn't fit a lane, shown as a "+N" beside the dots. */
|
/** Events on this day that didn't fit a lane, shown as a "+N" beside the dots. */
|
||||||
hidden: Int,
|
hidden: List<EventInstance>,
|
||||||
isToday: Boolean,
|
isToday: Boolean,
|
||||||
isSelected: Boolean,
|
isSelected: Boolean,
|
||||||
inMonth: Boolean,
|
inMonth: Boolean,
|
||||||
@@ -1436,9 +1437,15 @@ private fun SplitDayCell(
|
|||||||
* bar with no dot to grow out of.
|
* bar with no dot to grow out of.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun SplitDots(date: LocalDate, events: List<EventInstance>, hidden: Int, dark: Boolean) {
|
private fun SplitDots(
|
||||||
|
date: LocalDate,
|
||||||
|
events: List<EventInstance>,
|
||||||
|
hidden: List<EventInstance>,
|
||||||
|
dark: Boolean,
|
||||||
|
) {
|
||||||
if (events.isEmpty()) return
|
if (events.isEmpty()) return
|
||||||
val soften = LocalSoftenColors.current
|
val soften = LocalSoftenColors.current
|
||||||
|
val dimCutoff = LocalDimCutoff.current
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -1461,18 +1468,21 @@ private fun SplitDots(date: LocalDate, events: List<EventInstance>, hidden: Int,
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.morphBounds(MonthMorphKey.Event(date, event.instanceId))
|
.morphBounds(MonthMorphKey.Event(date, event.instanceId))
|
||||||
.size(SPLIT_DOT_SIZE)
|
.size(SPLIT_DOT_SIZE)
|
||||||
|
.alpha(if (dimCutoff != null && event.hasEnded(dimCutoff)) EventDimAlpha else 1f)
|
||||||
.background(eventFill(event.color, dark, soften), CircleShape),
|
.background(eventFill(event.color, dark, soften), CircleShape),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (hidden > 0) {
|
if (hidden.isNotEmpty()) {
|
||||||
// Tagged, not lifted: this count and the expanded grid's dot row are
|
// Tagged, not lifted: this count and the expanded grid's dot row are
|
||||||
// the same marker on the same day, so it travels with its cell like
|
// the same marker on the same day, so it travels with its cell like
|
||||||
// everything else rather than riding above the grid on its own layer.
|
// everything else rather than riding above the grid on its own layer.
|
||||||
Text(
|
Text(
|
||||||
text = "+$hidden",
|
text = "+${hidden.size}",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.morphBounds(MonthMorphKey.Overflow(date)),
|
modifier = Modifier
|
||||||
|
.morphBounds(MonthMorphKey.Overflow(date))
|
||||||
|
.alpha(if (allEnded(hidden, dimCutoff)) EventDimAlpha else 1f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1870,15 +1880,15 @@ private fun MonthWeekRow(
|
|||||||
}
|
}
|
||||||
val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size
|
val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size
|
||||||
if (hidden > 0) {
|
if (hidden > 0) {
|
||||||
val hiddenColors = buildList {
|
val hiddenEvents = buildList {
|
||||||
week.spans
|
week.spans
|
||||||
.filter { it.lane >= shownLanes && col in it.startCol..it.endCol }
|
.filter { it.lane >= shownLanes && col in it.startCol..it.endCol }
|
||||||
.forEach { add(it.event.color) }
|
.forEach { add(it.event) }
|
||||||
timed.drop(pillsShown.size).forEach { add(it.color) }
|
addAll(timed.drop(pillsShown.size))
|
||||||
}.distinct().take(3)
|
}
|
||||||
OverflowDots(
|
OverflowDots(
|
||||||
colors = hiddenColors,
|
events = hiddenEvents,
|
||||||
extra = hidden - hiddenColors.size,
|
total = hidden,
|
||||||
dark = dark,
|
dark = dark,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.offset(x = colW * col, y = EVENT_ROW_HEIGHT * MAX_EVENT_ROWS)
|
.offset(x = colW * col, y = EVENT_ROW_HEIGHT * MAX_EVENT_ROWS)
|
||||||
@@ -2058,37 +2068,52 @@ private fun MonthBar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Overflow row: a dot per hidden event (up to three) plus "+N" for the rest. */
|
/**
|
||||||
|
* Overflow row: a dot per hidden colour (up to three) plus "+N" for the rest.
|
||||||
|
*
|
||||||
|
* A dot stands for every hidden event sharing its colour, so it dims only once
|
||||||
|
* all of them have ended; the "+N" dims once the whole overflow has (#79).
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun OverflowDots(
|
private fun OverflowDots(
|
||||||
colors: List<Int>,
|
events: List<EventInstance>,
|
||||||
extra: Int,
|
total: Int,
|
||||||
dark: Boolean,
|
dark: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val soften = LocalSoftenColors.current
|
val soften = LocalSoftenColors.current
|
||||||
|
val dimCutoff = LocalDimCutoff.current
|
||||||
|
val byColor = events.groupBy { it.color }
|
||||||
|
val dots = byColor.keys.take(3)
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier.height(EVENT_ROW_HEIGHT),
|
modifier = modifier.height(EVENT_ROW_HEIGHT),
|
||||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
colors.forEach { argb ->
|
dots.forEach { argb ->
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(6.dp)
|
.size(6.dp)
|
||||||
|
.alpha(if (allEnded(byColor.getValue(argb), dimCutoff)) EventDimAlpha else 1f)
|
||||||
.background(eventFill(argb, dark, soften), CircleShape),
|
.background(eventFill(argb, dark, soften), CircleShape),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
val extra = total - dots.size
|
||||||
if (extra > 0) {
|
if (extra > 0) {
|
||||||
Text(
|
Text(
|
||||||
text = "+$extra",
|
text = "+$extra",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.alpha(if (allEnded(events, dimCutoff)) EventDimAlpha else 1f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True when dimming is on and every one of [events] is already over. */
|
||||||
|
private fun allEnded(events: List<EventInstance>, dimCutoff: Instant?): Boolean =
|
||||||
|
dimCutoff != null && events.isNotEmpty() && events.all { it.hasEnded(dimCutoff) }
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MonthGridLoading() {
|
private fun MonthGridLoading() {
|
||||||
val shape = MaterialTheme.shapes.medium
|
val shape = MaterialTheme.shapes.medium
|
||||||
|
|||||||
@@ -67,6 +67,24 @@ fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInst
|
|||||||
return byLane.filterNotNull()
|
return byLane.filterNotNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The events on [day] that [laneEvents] had no lane left for — the exact
|
||||||
|
* complement of what it seats, in the same bars-then-pills order. Together the
|
||||||
|
* two partition the day, so their sizes add up to [countByDay].
|
||||||
|
*
|
||||||
|
* The "+N" marker needs the events themselves, not just how many there are:
|
||||||
|
* dimming a completed event is a per-event question (#79).
|
||||||
|
*/
|
||||||
|
fun MonthWeek.overflowEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInstance> {
|
||||||
|
val seatedLanes = spans.count { it.lane < laneCap && col in it.startCol..it.endCol }
|
||||||
|
return buildList {
|
||||||
|
spans.forEach { span ->
|
||||||
|
if (span.lane >= laneCap && col in span.startCol..span.endCol) add(span.event)
|
||||||
|
}
|
||||||
|
addAll(timedByDay[day].orEmpty().drop(laneCap - seatedLanes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* State for the continuous style (#38): a vertical stream of *self-contained*
|
* State for the continuous style (#38): a vertical stream of *self-contained*
|
||||||
* months rather than one undifferentiated run of weeks. Each month is keyed by
|
* months rather than one undifferentiated run of weeks. Each month is keyed by
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -889,6 +889,7 @@ private fun ViewsScreen(
|
|||||||
CollapsingScaffold(
|
CollapsingScaffold(
|
||||||
title = stringResource(R.string.settings_section_views),
|
title = stringResource(R.string.settings_section_views),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
|
predictiveBack = true,
|
||||||
) {
|
) {
|
||||||
val config = state.quickSwitchConfig
|
val config = state.quickSwitchConfig
|
||||||
|
|
||||||
@@ -1430,6 +1431,7 @@ private fun SpecialDatesScreen(
|
|||||||
CollapsingScaffold(
|
CollapsingScaffold(
|
||||||
title = stringResource(R.string.settings_section_special_dates),
|
title = stringResource(R.string.settings_section_special_dates),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
|
predictiveBack = true,
|
||||||
) {
|
) {
|
||||||
// Paused banner: the permission was revoked after enabling.
|
// Paused banner: the permission was revoked after enabling.
|
||||||
if (state.enabled && state.stalledPermission) {
|
if (state.enabled && state.stalledPermission) {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -71,6 +72,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() {
|
||||||
@@ -611,8 +613,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) {
|
||||||
@@ -627,8 +637,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) {
|
||||||
|
|||||||
@@ -496,6 +496,8 @@
|
|||||||
<string name="calendars_synced_header">Synced calendars</string>
|
<string name="calendars_synced_header">Synced calendars</string>
|
||||||
<string name="calendars_synced_hint">These come from accounts on your device. Create and edit them in their own app.</string>
|
<string name="calendars_synced_hint">These come from accounts on your device. Create and edit them in their own app.</string>
|
||||||
<string name="calendars_manage_in_app">Manage in app</string>
|
<string name="calendars_manage_in_app">Manage in app</string>
|
||||||
|
<!-- Account header when two accounts share a name: %1$s is the account, %2$s the app it comes from. -->
|
||||||
|
<string name="calendars_account_from_source">%1$s (%2$s)</string>
|
||||||
<string name="calendars_account_menu_a11y">More options for %1$s</string>
|
<string name="calendars_account_menu_a11y">More options for %1$s</string>
|
||||||
<string name="calendars_enable_all">Enable all</string>
|
<string name="calendars_enable_all">Enable all</string>
|
||||||
<string name="calendars_disable_all">Disable all</string>
|
<string name="calendars_disable_all">Disable all</string>
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.reminders
|
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
|
||||||
import org.junit.jupiter.api.Test
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What the reminder receiver may mark handled. The silenced-but-still-ahead case
|
|
||||||
* is the one that matters: those rows are the only copy of the reminder (#75).
|
|
||||||
*/
|
|
||||||
class AlertHandlingTest {
|
|
||||||
|
|
||||||
private val now = 1_700_000_000_000L
|
|
||||||
|
|
||||||
private fun alert(
|
|
||||||
id: Long,
|
|
||||||
calendarId: Long = 1L,
|
|
||||||
beginMillis: Long = now + 60_000L,
|
|
||||||
endMillis: Long = now + 3_600_000L,
|
|
||||||
) = ReminderAlert(
|
|
||||||
alertId = id,
|
|
||||||
eventId = id * 10,
|
|
||||||
calendarId = calendarId,
|
|
||||||
beginMillis = beginMillis,
|
|
||||||
endMillis = endMillis,
|
|
||||||
title = "E $id",
|
|
||||||
location = null,
|
|
||||||
isAllDay = false,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `posted alerts are handled`() {
|
|
||||||
val due = listOf(alert(1L), alert(2L))
|
|
||||||
|
|
||||||
assertThat(handledAlertIds(due, postedIds = setOf(1L, 2L), nowMillis = now))
|
|
||||||
.containsExactly(1L, 2L)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a silenced alert whose event is still ahead stays unhandled`() {
|
|
||||||
// Switching its calendar back on before the event has to bring it back,
|
|
||||||
// and dueAlerts only ever returns STATE_SCHEDULED rows.
|
|
||||||
val due = listOf(alert(1L), alert(2L))
|
|
||||||
|
|
||||||
assertThat(handledAlertIds(due, postedIds = setOf(1L), nowMillis = now))
|
|
||||||
.containsExactly(1L)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a silenced alert whose event is over is handled`() {
|
|
||||||
// Nothing left to re-surface, so it must not linger as scheduled.
|
|
||||||
val over = alert(2L, beginMillis = now - 7_200_000L, endMillis = now - 3_600_000L)
|
|
||||||
val due = listOf(alert(1L), over)
|
|
||||||
|
|
||||||
assertThat(handledAlertIds(due, postedIds = setOf(1L), nowMillis = now))
|
|
||||||
.containsExactly(1L, 2L)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `an unknown end time falls back to the begin time`() {
|
|
||||||
val started = alert(1L, beginMillis = now - 1L, endMillis = 0L)
|
|
||||||
val notYet = alert(2L, beginMillis = now + 1L, endMillis = 0L)
|
|
||||||
|
|
||||||
assertThat(handledAlertIds(listOf(started, notYet), postedIds = emptySet(), nowMillis = now))
|
|
||||||
.containsExactly(1L)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,526 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.reminders
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import java.time.ZonedDateTime
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** [planReminders] with the fixtures' zone and all-day hour filled in. */
|
||||||
|
private fun plan(
|
||||||
|
instances: List<ReminderEventInstance>,
|
||||||
|
minutesByEvent: Map<Long, List<Int>>,
|
||||||
|
zone: ZoneId = berlin,
|
||||||
|
allDayTimeMinutes: Int = nineAm,
|
||||||
|
) = planReminders(instances, minutesByEvent, zone, allDayTimeMinutes)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a reminder fires its offset before the occurrence begins`() {
|
||||||
|
val begin = now + 30 * minute
|
||||||
|
val planned = plan(
|
||||||
|
instances = listOf(instance(1L, beginMillis = begin)),
|
||||||
|
minutesByEvent = mapOf(1L to listOf(10)),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(planned.map { it.alarmMillis }).containsExactly(begin - 10 * minute)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- all-day: the hour the user picked, on every occurrence -------------
|
||||||
|
|
||||||
|
private val berlin = ZoneId.of("Europe/Berlin")
|
||||||
|
private val nineAm = 540
|
||||||
|
|
||||||
|
/** UTC midnight of [date] — how the provider stores an all-day occurrence. */
|
||||||
|
private fun allDayBegin(date: String): Long =
|
||||||
|
LocalDate.parse(date).atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
||||||
|
|
||||||
|
private fun firedAt(alarmMillis: Long, zone: ZoneId = berlin): ZonedDateTime =
|
||||||
|
java.time.Instant.ofEpochMilli(alarmMillis).atZone(zone)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The offset AllDayReminderEncoding would store for "[days] before, at
|
||||||
|
* [timeOfDayMinutes]" when sampled against [eventDate] — i.e. exactly the row
|
||||||
|
* the app writes today.
|
||||||
|
*/
|
||||||
|
private fun encodedAllDayMinutes(
|
||||||
|
eventDate: String,
|
||||||
|
days: Long,
|
||||||
|
timeOfDayMinutes: Int = nineAm,
|
||||||
|
zone: ZoneId = berlin,
|
||||||
|
): Int {
|
||||||
|
val date = LocalDate.parse(eventDate)
|
||||||
|
val utcMidnight = date.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
||||||
|
val fire = date.minusDays(days)
|
||||||
|
.atTime(LocalTime.of(timeOfDayMinutes / 60, timeOfDayMinutes % 60))
|
||||||
|
.atZone(zone).toInstant().toEpochMilli()
|
||||||
|
return ((utcMidnight - fire) / minute).toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun allDayAlarm(eventDate: String, rawMinutes: Int, zone: ZoneId = berlin): Long =
|
||||||
|
plan(
|
||||||
|
instances = listOf(
|
||||||
|
instance(1L, beginMillis = allDayBegin(eventDate), isAllDay = true),
|
||||||
|
),
|
||||||
|
minutesByEvent = mapOf(1L to listOf(rawMinutes)),
|
||||||
|
zone = zone,
|
||||||
|
allDayTimeMinutes = nineAm,
|
||||||
|
).single().alarmMillis
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an all-day reminder fires at the hour the setting names`() {
|
||||||
|
// Winter: Berlin is UTC+1. "1 day before" on the 15th means the 14th, 09:00.
|
||||||
|
val alarm = allDayAlarm("2026-01-15", encodedAllDayMinutes("2026-01-15", days = 1))
|
||||||
|
|
||||||
|
assertThat(firedAt(alarm).toLocalDateTime())
|
||||||
|
.isEqualTo(LocalDate.parse("2026-01-14").atTime(9, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a summer occurrence of a row written in winter is not an hour early`() {
|
||||||
|
// The drift this replaces: the offset was sampled at UTC+1, the occurrence
|
||||||
|
// falls at UTC+2, and firing at `begin - minutes` would land at 08:00.
|
||||||
|
val winterRow = encodedAllDayMinutes("2026-01-15", days = 1)
|
||||||
|
|
||||||
|
val alarm = allDayAlarm("2026-07-15", winterRow)
|
||||||
|
|
||||||
|
assertThat(firedAt(alarm).toLocalDateTime())
|
||||||
|
.isEqualTo(LocalDate.parse("2026-07-14").atTime(9, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a winter occurrence of a row written in summer is not an hour late`() {
|
||||||
|
// The same drift in the other direction: sampled at UTC+2, fires at UTC+1.
|
||||||
|
val summerRow = encodedAllDayMinutes("2026-07-15", days = 1)
|
||||||
|
|
||||||
|
val alarm = allDayAlarm("2026-01-15", summerRow)
|
||||||
|
|
||||||
|
assertThat(firedAt(alarm).toLocalDateTime())
|
||||||
|
.isEqualTo(LocalDate.parse("2026-01-14").atTime(9, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `every occurrence of a yearly all-day series fires at the same wall clock`() {
|
||||||
|
// A birthday's offset is sampled once; the series must not walk off it.
|
||||||
|
val row = encodedAllDayMinutes("2026-07-15", days = 1)
|
||||||
|
val fired = listOf("2026-07-15", "2027-01-15", "2027-07-15", "2028-01-15")
|
||||||
|
.map { firedAt(allDayAlarm(it, row)).toLocalTime() }
|
||||||
|
|
||||||
|
assertThat(fired.toSet()).containsExactly(LocalTime.of(9, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an all-day reminder on the day itself fires that morning`() {
|
||||||
|
// "At time of event" on an all-day event encodes to a negative offset.
|
||||||
|
val sameDay = encodedAllDayMinutes("2026-07-15", days = 0)
|
||||||
|
assertThat(sameDay).isLessThan(0)
|
||||||
|
|
||||||
|
val alarm = allDayAlarm("2026-07-15", sameDay)
|
||||||
|
|
||||||
|
assertThat(firedAt(alarm).toLocalDateTime())
|
||||||
|
.isEqualTo(LocalDate.parse("2026-07-15").atTime(9, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a plain 1440 row from another calendar app means one day before`() {
|
||||||
|
// Foreign rows carry no encoded hour. Read at face value they would fire
|
||||||
|
// at UTC midnight — 02:00 local in summer Berlin.
|
||||||
|
val alarm = allDayAlarm("2026-07-15", rawMinutes = 1_440)
|
||||||
|
|
||||||
|
assertThat(firedAt(alarm).toLocalDateTime())
|
||||||
|
.isEqualTo(LocalDate.parse("2026-07-14").atTime(9, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a plain 1440 row west of UTC still means one day before`() {
|
||||||
|
// UTC midnight of the 15th is the evening of the 14th in New York, so a
|
||||||
|
// local-date reading of the offset would put this two days out.
|
||||||
|
val newYork = ZoneId.of("America/New_York")
|
||||||
|
|
||||||
|
val alarm = allDayAlarm("2026-07-15", rawMinutes = 1_440, zone = newYork)
|
||||||
|
|
||||||
|
assertThat(firedAt(alarm, newYork).toLocalDateTime())
|
||||||
|
.isEqualTo(LocalDate.parse("2026-07-14").atTime(9, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an encoded row west of UTC fires at the named hour`() {
|
||||||
|
val newYork = ZoneId.of("America/New_York")
|
||||||
|
val row = encodedAllDayMinutes("2026-07-15", days = 1, zone = newYork)
|
||||||
|
|
||||||
|
val alarm = allDayAlarm("2026-07-15", row, zone = newYork)
|
||||||
|
|
||||||
|
assertThat(firedAt(alarm, newYork).toLocalDateTime())
|
||||||
|
.isEqualTo(LocalDate.parse("2026-07-14").atTime(9, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a zero-day row fires on the event's own date`() {
|
||||||
|
assertThat(
|
||||||
|
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
|
||||||
|
fun `a timed reminder is exact across a DST boundary`() {
|
||||||
|
// Nothing to re-anchor: begin is an absolute instant either way.
|
||||||
|
val begin = LocalDate.parse("2026-03-29").atTime(14, 0)
|
||||||
|
.atZone(berlin).toInstant().toEpochMilli()
|
||||||
|
val planned = plan(
|
||||||
|
instances = listOf(instance(1L, beginMillis = begin, isAllDay = false)),
|
||||||
|
minutesByEvent = mapOf(1L to listOf(30)),
|
||||||
|
zone = berlin,
|
||||||
|
allDayTimeMinutes = nineAm,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(firedAt(planned.single().alarmMillis).toLocalDateTime())
|
||||||
|
.isEqualTo(LocalDate.parse("2026-03-29").atTime(13, 30))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `every occurrence of a series gets its own reminder`() {
|
||||||
|
val planned = plan(
|
||||||
|
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 = plan(
|
||||||
|
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 = plan(
|
||||||
|
instances = listOf(instance(1L)),
|
||||||
|
minutesByEvent = emptyMap(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(planned).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the same reminder keeps its key across scans`() {
|
||||||
|
val plan = {
|
||||||
|
plan(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 = plan(
|
||||||
|
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 = plan(
|
||||||
|
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 = plan(
|
||||||
|
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 = plan(
|
||||||
|
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 = plan(
|
||||||
|
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 = plan(
|
||||||
|
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 = plan(
|
||||||
|
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 = plan(
|
||||||
|
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 = plan(
|
||||||
|
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 = plan(
|
||||||
|
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 = plan(
|
||||||
|
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 `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
|
||||||
|
fun `a first-ever scan starts from now, not from the epoch`() {
|
||||||
|
// Otherwise an install — or the upgrade onto in-house delivery — treats
|
||||||
|
// every reminder ever set as overdue and posts the lot.
|
||||||
|
assertThat(reminderWatermark(lastScanMillis = null, nowMillis = now)).isEqualTo(now)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an ordinary watermark is used as recorded`() {
|
||||||
|
assertThat(reminderWatermark(lastScanMillis = now - day, nowMillis = now))
|
||||||
|
.isEqualTo(now - day)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a watermark left in the future by a clock change is clamped`() {
|
||||||
|
// Left alone it would silence every reminder until real time caught up.
|
||||||
|
assertThat(reminderWatermark(lastScanMillis = now + 5 * day, nowMillis = now))
|
||||||
|
.isEqualTo(now)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a negative longest offset does not shrink the query horizon`() {
|
||||||
|
assertThat(reminderQueryHorizon(lookaheadMillis = 7 * day, maxReminderMinutes = -420))
|
||||||
|
.isEqualTo(7 * day)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@ class FilterGroupingTest {
|
|||||||
cal(3, "Shared", "team@dav"),
|
cal(3, "Shared", "team@dav"),
|
||||||
)
|
)
|
||||||
|
|
||||||
val groups = groupByAccount(calendars, hidden = emptySet())
|
val groups = groupCalendarsForFilter(calendars, hidden = emptySet())
|
||||||
|
|
||||||
assertThat(groups.map { it.account }).containsExactly("alice@dav", "team@dav").inOrder()
|
assertThat(groups.map { it.account }).containsExactly("alice@dav", "team@dav").inOrder()
|
||||||
assertThat(groups[0].calendars.map { it.displayName })
|
assertThat(groups[0].calendars.map { it.displayName })
|
||||||
@@ -43,7 +43,7 @@ class FilterGroupingTest {
|
|||||||
cal(2, "Work", "alice@dav"),
|
cal(2, "Work", "alice@dav"),
|
||||||
)
|
)
|
||||||
|
|
||||||
val groups = groupByAccount(calendars, hidden = setOf(2L))
|
val groups = groupCalendarsForFilter(calendars, hidden = setOf(2L))
|
||||||
val rows = groups.single().calendars.associateBy { it.id }
|
val rows = groups.single().calendars.associateBy { it.id }
|
||||||
|
|
||||||
assertThat(rows.getValue(1L).visible).isTrue()
|
assertThat(rows.getValue(1L).visible).isTrue()
|
||||||
@@ -52,10 +52,52 @@ class FilterGroupingTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `blank account name falls back to type`() {
|
fun `blank account name falls back to type`() {
|
||||||
val groups = groupByAccount(
|
val groups = groupCalendarsForFilter(
|
||||||
listOf(cal(1, "Birthdays", account = "", type = "LOCAL")),
|
listOf(cal(1, "Birthdays", account = "", type = "LOCAL")),
|
||||||
hidden = emptySet(),
|
hidden = emptySet(),
|
||||||
)
|
)
|
||||||
assertThat(groups.single().account).isEqualTo("LOCAL")
|
assertThat(groups.single().account).isEqualTo("LOCAL")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** #77: same address, two apps — two accounts, and they must read as two. */
|
||||||
|
@Test
|
||||||
|
fun `one name under two account types stays two groups`() {
|
||||||
|
val calendars = listOf(
|
||||||
|
cal(1, "Personal", "me@example.com", type = "com.google"),
|
||||||
|
cal(2, "Shared", "me@example.com", type = "bitfire.at.davdroid"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val groups = groupCalendarsForFilter(calendars, hidden = emptySet())
|
||||||
|
|
||||||
|
assertThat(groups).hasSize(2)
|
||||||
|
assertThat(groups.map { it.accountType })
|
||||||
|
.containsExactly("com.google", "bitfire.at.davdroid").inOrder()
|
||||||
|
assertThat(groups.map { it.calendars.single().id }).containsExactly(1L, 2L).inOrder()
|
||||||
|
assertThat(groups.all { it.ambiguous }).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `one name under one type is not ambiguous`() {
|
||||||
|
val groups = groupCalendarsForFilter(
|
||||||
|
listOf(cal(1, "Personal", "me@example.com")),
|
||||||
|
hidden = emptySet(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(groups.single().ambiguous).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Two different names from the same app are still two plain groups. */
|
||||||
|
@Test
|
||||||
|
fun `different names under one type are not ambiguous`() {
|
||||||
|
val groups = groupCalendarsForFilter(
|
||||||
|
listOf(
|
||||||
|
cal(1, "Personal", "alice@example.com"),
|
||||||
|
cal(2, "Shared", "bob@example.com"),
|
||||||
|
),
|
||||||
|
hidden = emptySet(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(groups).hasSize(2)
|
||||||
|
assertThat(groups.none { it.ambiguous }).isTrue()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,6 +122,42 @@ class LaneEventsTest {
|
|||||||
.containsNoneIn(seated)
|
.containsNoneIn(seated)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `overflow is exactly what seating left behind`() {
|
||||||
|
val events = (0 until 5).map { timed(LocalDate(2026, 7, 6), hour = 8 + it, id = it + 1L) }
|
||||||
|
val week = rowOfJuly6(events)
|
||||||
|
val day = LocalDate(2026, 7, 6)
|
||||||
|
|
||||||
|
val seated = week.laneEvents(col = 0, day = day, laneCap = 3)
|
||||||
|
val overflow = week.overflowEvents(col = 0, day = day, laneCap = 3)
|
||||||
|
|
||||||
|
assertThat(overflow).containsExactlyElementsIn(events.drop(3)).inOrder()
|
||||||
|
assertThat(seated + overflow).containsExactlyElementsIn(events)
|
||||||
|
assertThat(seated.size + overflow.size).isEqualTo(week.countByDay[day])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a bar beyond the cap overflows on every day it covers`() {
|
||||||
|
val bars = (0 until 4).map {
|
||||||
|
allDay(LocalDate(2026, 7, 6), LocalDate(2026, 7, 8), id = it + 1L)
|
||||||
|
}
|
||||||
|
val week = rowOfJuly6(bars)
|
||||||
|
val parked = week.spans.filter { it.lane >= 3 }.map { it.event }
|
||||||
|
|
||||||
|
(0..2).forEach { col ->
|
||||||
|
val day = LocalDate(2026, 7, 6 + col)
|
||||||
|
assertThat(week.overflowEvents(col, day, laneCap = 3))
|
||||||
|
.containsExactlyElementsIn(parked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a day that fits has no overflow`() {
|
||||||
|
val week = rowOfJuly6(listOf(timed(LocalDate(2026, 7, 6), hour = 9, id = 1L)))
|
||||||
|
|
||||||
|
assertThat(week.overflowEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3)).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val BLUE = 0xFF3366CC.toInt()
|
const val BLUE = 0xFF3366CC.toInt()
|
||||||
const val RED = 0xFFCC3333.toInt()
|
const val RED = 0xFFCC3333.toInt()
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ flowchart TD
|
|||||||
Repo["CalendarRepository\n(interface + impl, Flow-based, io-dispatched)"]
|
Repo["CalendarRepository\n(interface + impl, Flow-based, io-dispatched)"]
|
||||||
DS["CalendarDataSource\n(interface + AndroidCalendarDataSource)"]
|
DS["CalendarDataSource\n(interface + AndroidCalendarDataSource)"]
|
||||||
Prefs["SettingsPrefs / CalendarPrefs\n(DataStore)"]
|
Prefs["SettingsPrefs / CalendarPrefs\n(DataStore)"]
|
||||||
Rem["reminders/\nReminderAlertStore + ReminderNotifier"]
|
Rem["reminders/\nReminderScanner + ReminderNotifier"]
|
||||||
end
|
end
|
||||||
Provider[("CalendarContract\n(system calendar provider)")]
|
Provider[("CalendarContract\n(system calendar provider)")]
|
||||||
|
|
||||||
@@ -137,29 +137,67 @@ can't fake a conflict.
|
|||||||
|
|
||||||
## Reminder delivery
|
## Reminder delivery
|
||||||
|
|
||||||
The provider schedules reminder alarms (for `METHOD_ALERT` rows only) and
|
Calendula plans and fires its own reminders. It reads the offsets in
|
||||||
broadcasts `EVENT_REMINDER` — but posts no notification; a calendar app
|
`Reminders` as data, works out when each occurrence's reminder is due, and holds
|
||||||
must (the Etar model):
|
**one** exact alarm for the earliest one still ahead:
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
participant P as CalendarProvider
|
participant T as Trigger (alarm / boot / time change / edit / launch / daily worker)
|
||||||
participant R as EventReminderReceiver
|
participant Sc as ReminderScanner
|
||||||
participant S as ReminderAlertStore
|
participant Src as ReminderInstanceSource
|
||||||
|
participant P as ReminderPlan (pure)
|
||||||
participant N as ReminderNotifier
|
participant N as ReminderNotifier
|
||||||
P->>R: EVENT_REMINDER broadcast (manifest receiver, exported)
|
participant A as ReminderAlarmScheduler
|
||||||
R->>S: dueAlerts(now) — CalendarAlerts: SCHEDULED, alarmTime ≤ now
|
T->>Sc: scan()
|
||||||
S-->>R: due alerts
|
Sc->>Src: occurrences(window) + reminderMinutes(ids)
|
||||||
R->>N: post(alert) — one notification per alert, tag = alert id
|
Src-->>Sc: Instances ⋈ Reminders
|
||||||
R->>S: markFired(ids) — best effort, needs WRITE_CALENDAR
|
Sc->>P: planReminders / scheduleReminders(watermark, now)
|
||||||
|
P-->>Sc: due + next alarm
|
||||||
|
Sc->>N: post(alert) — tag = reminder key
|
||||||
|
Sc->>A: scheduleScan(next)
|
||||||
```
|
```
|
||||||
|
|
||||||
Posting happens before marking: a crash in between re-posts silently (same
|
**Why not the provider's broadcast.** It used to schedule the alarms, write the
|
||||||
tag + `setOnlyAlertOnce`) rather than losing a reminder. Swiped
|
`CalendarAlerts` rows and broadcast `EVENT_REMINDER`, and the app only reacted.
|
||||||
notifications never return because `FIRED` rows are never re-queried.
|
That chain holds on stock Android and demonstrably not everywhere: AOSP's own
|
||||||
|
unbundled calendar carries three separate workarounds for OEM providers that
|
||||||
|
retarget the broadcast or only write the alert row at alert time. A reacting app
|
||||||
|
cannot tell "nothing was due" from "the broadcast never came" (#75) — and the
|
||||||
|
reporter's silent events were in a calendar Calendula created itself, so
|
||||||
|
`VISIBLE` was never the cause there.
|
||||||
|
|
||||||
**One visibility model.** The provider only schedules alarms for calendars with
|
**The watermark replaces `CalendarAlerts.STATE`.** A scan posts the reminders
|
||||||
`Calendars.VISIBLE = 1`, so that flag *is* the app's on/off switch: Settings →
|
whose moment falls in `(lastScan, now]`, then moves the mark
|
||||||
|
(`ReminderStatePrefs`). Half-open, so a scan that runs twice cannot post twice,
|
||||||
|
while a scan that runs *late* still posts what the missed alarm owed — a reboot,
|
||||||
|
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
|
||||||
|
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`
|
||||||
|
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
|
||||||
|
not a plain lead time — `AllDayReminderEncoding` folds a wall-clock hour into it,
|
||||||
|
sampled against one date's UTC offset — so taking it at face value drifts by the
|
||||||
|
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
|
||||||
|
from the global all-day reminder setting, recomposed against each occurrence's
|
||||||
|
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
|
||||||
|
`Calendars.VISIBLE = 1`, and that flag *is* the app's on/off switch: Settings →
|
||||||
Calendars writes it (one calendar per update — `CalendarProvider2` skips its own
|
Calendars writes it (one calendar per update — `CalendarProvider2` skips its own
|
||||||
`checkNextAlarm()` reschedule for any selection that isn't `_id=`), and every
|
`checkNextAlarm()` reschedule for any selection that isn't `_id=`), and every
|
||||||
display predicate reads `CalendarSource.isVisibleInSystem`. The reconciliation
|
display predicate reads `CalendarSource.isVisibleInSystem`. The reconciliation
|
||||||
@@ -171,16 +209,14 @@ the app has not been allowed to write yet (read-only permission grant, or a
|
|||||||
pre-permission launch); `CalendarVisibilityReconciler` drains it entry by entry,
|
pre-permission launch); `CalendarVisibilityReconciler` drains it entry by entry,
|
||||||
and until it does, the repository and `ReminderNotifier.post` honour it. That
|
and until it does, the repository and `ReminderNotifier.post` honour it. That
|
||||||
gate also covers a snooze re-shown from our own alarm after its calendar was
|
gate also covers a snooze re-shown from our own alarm after its calendar was
|
||||||
switched off. Silencing is not handling: an alert the gate drops keeps its
|
switched off. The drawer's filter sheet (`CalendarPrefs.hiddenCalendarIds`) is a
|
||||||
`SCHEDULED` state while its event is still ahead (`handledAlertIds`), so
|
separate in-app declutter that never touches reminders.
|
||||||
switching the calendar back on re-posts it (`ReminderRecovery`) instead of
|
|
||||||
losing it — the provider's own table is the stash. The drawer's filter sheet
|
|
||||||
(`CalendarPrefs.hiddenCalendarIds`) is a separate in-app declutter that never
|
|
||||||
touches reminders.
|
|
||||||
|
|
||||||
Deliberately absent until real devices prove it necessary: own alarm
|
Deliberately absent: a fallback to the provider's `EVENT_REMINDER` broadcast.
|
||||||
scheduling, `BOOT_COMPLETED`, snooze/dismiss actions, battery-exemption
|
Keeping both would double-post wherever the provider works, and Etar's way out —
|
||||||
prompts.
|
a latch that disables its own scheduling once a real broadcast arrives — cannot
|
||||||
|
be copied, because our failure mode includes a broadcast that arrives with no
|
||||||
|
alert row behind it.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user