feat(reminders): schedule and fire reminders in-house (#75)
Delivery no longer waits to be told. The scan reads `Instances` and `Reminders`, plans every reminder, posts what has come due and arms one exact alarm for the next — replacing both halves of what the provider used to do for us. Reacting to `EVENT_REMINDER` could not be made reliable, only more hopeful. An app that reacts cannot distinguish "nothing was due" from "the broadcast never came", and AOSP's own unbundled calendar carries three workarounds for OEM providers that retarget it or write the alert row late. Etar's fallback was not the answer either: it replaces the alarm but still reads `CalendarAlerts` for what to show, and it disables itself the moment one real broadcast arrives — useless against a broadcast that arrives with no row behind it. Keeping the old receiver alongside was rejected for the same reason it looks attractive: on a healthy device both paths fire, and there is no honest way to suppress one without the latch we just ruled out. So the provider path goes — `EventReminderReceiver`, `ReminderAlertStore`, `ReminderRecovery` and the `CalendarAlerts` writes with it. Reminder delivery no longer needs WRITE_CALENDAR. One alarm exists at a time, re-planned on every firing, so an edit needs no alarm bookkeeping to stay in sync. Every trigger runs the same idempotent scan: the alarm, boot and package-replace (both wipe pending alarms), clock and timezone changes, a provider change while the app is up, launch, and a daily worker for a device that drops the alarm with nothing to announce it. RECEIVE_BOOT_COMPLETED is new and load-bearing — without it reminders stop dead after a restart. `ReminderAlert.alertId` becomes `key`, derived from the reminder rather than a row id that no longer exists, and the notification tag and PendingIntent request codes ride on it, so a re-posted reminder still replaces itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
19
CHANGELOG.md
19
CHANGELOG.md
@@ -40,6 +40,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- 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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -18,14 +18,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 +75,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"
|
||||||
@@ -88,7 +95,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
|||||||
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)
|
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 +106,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 only make two
|
||||||
|
* *different* reminders collide, which the intent extras then separate
|
||||||
|
* (PendingIntents compare their intents too, not just the request code).
|
||||||
*/
|
*/
|
||||||
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).
|
||||||
|
|||||||
@@ -195,6 +195,20 @@ fun scheduleReminders(
|
|||||||
fun ReminderEventInstance.isStillRelevant(nowMillis: Long): Boolean =
|
fun ReminderEventInstance.isStillRelevant(nowMillis: Long): Boolean =
|
||||||
(endMillis.takeIf { it > 0L } ?: beginMillis) >= nowMillis
|
(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
|
* 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
|
* reminder in time: the plain lookahead plus the longest offset any reminder row
|
||||||
|
|||||||
@@ -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,21 @@ 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
|
* Nothing has to be re-posted on the way back on: reminder delivery plans
|
||||||
* off and that are still relevant — those the app kept app-side because it
|
* from `Instances` and `Reminders` on every scan (#75), and the provider
|
||||||
* may not write the flag ([ReminderRecovery]).
|
* change this write makes triggers one.
|
||||||
*/
|
*/
|
||||||
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) ------------------------------------
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -437,6 +437,26 @@ class ReminderPlanTest {
|
|||||||
.isEqualTo(7 * day + 14 * day)
|
.isEqualTo(7 * day + 14 * day)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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
|
@Test
|
||||||
fun `a negative longest offset does not shrink the query horizon`() {
|
fun `a negative longest offset does not shrink the query horizon`() {
|
||||||
assertThat(reminderQueryHorizon(lookaheadMillis = 7 * day, maxReminderMinutes = -420))
|
assertThat(reminderQueryHorizon(lookaheadMillis = 7 * day, maxReminderMinutes = -420))
|
||||||
|
|||||||
@@ -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,56 @@ 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.
|
||||||
|
|
||||||
|
**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. 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 +198,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