diff --git a/CHANGELOG.md b/CHANGELOG.md index db008f1..84c6ba1 100644 --- a/CHANGELOG.md +++ b/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 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]). +- 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 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 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8e0df89..1db01ea 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -33,6 +33,14 @@ android:maxSdkVersion="32" /> + + + + - - + + + + diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt b/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt index ba988f6..55c2595 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt @@ -8,6 +8,8 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarVisibilityReconciler import de.jeanlucmakiola.calendula.data.backup.BackupWorker import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler 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.CrashReporter import kotlinx.coroutines.CoroutineScope @@ -41,6 +43,25 @@ class CalendulaApp : Application() { reconcileAutoBackup() reconcileSpecialDates() 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) } /** diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/di/DataModule.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/di/DataModule.kt index 8b001d4..963f11c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/di/DataModule.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/di/DataModule.kt @@ -18,8 +18,8 @@ import de.jeanlucmakiola.calendula.data.contacts.AndroidContactSpecialDatesDataS import de.jeanlucmakiola.calendula.data.contacts.AndroidSpecialDatesCalendarSpec import de.jeanlucmakiola.calendula.data.contacts.ContactSpecialDatesDataSource import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec -import de.jeanlucmakiola.calendula.data.reminders.AndroidReminderAlertStore -import de.jeanlucmakiola.calendula.data.reminders.ReminderAlertStore +import de.jeanlucmakiola.calendula.data.reminders.ProviderReminderInstanceSource +import de.jeanlucmakiola.calendula.data.reminders.ReminderInstanceSource import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import javax.inject.Singleton @@ -46,9 +46,9 @@ abstract class DataBindModule { @Binds @Singleton - abstract fun bindReminderAlertStore( - impl: AndroidReminderAlertStore, - ): ReminderAlertStore + abstract fun bindReminderInstanceSource( + impl: ProviderReminderInstanceSource, + ): ReminderInstanceSource @Binds @Singleton diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/ReminderStatePrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/ReminderStatePrefs.kt new file mode 100644 index 0000000..053a2e5 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/ReminderStatePrefs.kt @@ -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, +) { + + /** 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") + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/AlertHandling.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/AlertHandling.kt deleted file mode 100644 index 9d6cf57..0000000 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/AlertHandling.kt +++ /dev/null @@ -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, - postedIds: Set, - nowMillis: Long, -): List = due - .filter { it.alertId in postedIds || !it.isRelevantAt(nowMillis) } - .map { it.alertId } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/EventReminderReceiver.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/EventReminderReceiver.kt deleted file mode 100644 index 95067e3..0000000 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/EventReminderReceiver.kt +++ /dev/null @@ -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() - } - } - } -} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderActionReceiver.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderActionReceiver.kt index bbe8ea3..53c883b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderActionReceiver.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderActionReceiver.kt @@ -18,14 +18,14 @@ import javax.inject.Inject * intents (notification action buttons and our own [ReminderSnoozeScheduler] * alarm), so the receiver is not exported. * - * - **Dismiss** just cancels the notification — the `CalendarAlerts` row is - * already fired, so nothing re-posts it. + * - **Dismiss** just cancels the notification — the scan's watermark has moved + * past this reminder, so nothing re-posts it. * - **Snooze** cancels the notification and schedules an exact alarm to re-show * it after the user's snooze delay. * - **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 - * snooze, which [ReminderNotifier.post] catches (this alarm is ours, so no - * provider alert row stands between it and the notification). + * snooze, which [ReminderNotifier.post] catches — this alarm is its own + * trigger, outside the ordinary scan. */ @AndroidEntryPoint class ReminderActionReceiver : BroadcastReceiver() { @@ -75,7 +75,14 @@ class ReminderActionReceiver : BroadcastReceiver() { const val ACTION_DISMISS = "de.jeanlucmakiola.calendula.reminders.DISMISS" 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_CALENDAR_ID = "calendar_id" private const val EXTRA_BEGIN = "begin" @@ -88,7 +95,7 @@ class ReminderActionReceiver : BroadcastReceiver() { fun intent(context: Context, action: String, alert: ReminderAlert): Intent = Intent(context, ReminderActionReceiver::class.java).apply { this.action = action - putExtra(EXTRA_ALERT_ID, alert.alertId) + putExtra(EXTRA_ALERT_KEY, alert.key) putExtra(EXTRA_EVENT_ID, alert.eventId) putExtra(EXTRA_CALENDAR_ID, alert.calendarId) putExtra(EXTRA_BEGIN, alert.beginMillis) @@ -99,23 +106,29 @@ class ReminderActionReceiver : BroadcastReceiver() { } /** - * A stable request code per (alert, action) so the three PendingIntents - * of one notification stay distinct and don't clobber each other. + * A stable request code per (alert, action) so one notification's + * 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 { val actionOffset = when (action) { ACTION_SNOOZE -> 1 ACTION_DISMISS -> 2 ACTION_SHOW -> 3 + ACTION_OPEN -> 4 else -> 0 } - return alert.alertId.toInt() * 8 + actionOffset + return (alert.key.toInt() shl 3) + actionOffset } private fun alertFrom(intent: Intent): ReminderAlert? { - if (!intent.hasExtra(EXTRA_ALERT_ID)) return null + if (!intent.hasExtra(EXTRA_ALERT_KEY)) return null return ReminderAlert( - alertId = intent.getLongExtra(EXTRA_ALERT_ID, 0L), + key = intent.getLongExtra(EXTRA_ALERT_KEY, 0L), eventId = intent.getLongExtra(EXTRA_EVENT_ID, 0L), calendarId = intent.getLongExtra(EXTRA_CALENDAR_ID, 0L), beginMillis = intent.getLongExtra(EXTRA_BEGIN, 0L), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderAlarms.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderAlarms.kt new file mode 100644 index 0000000..89a4593 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderAlarms.kt @@ -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() ?: 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() ?: 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 + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderAlert.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderAlert.kt new file mode 100644 index 0000000..2d8c28b --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderAlert.kt @@ -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, +) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderAlertStore.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderAlertStore.kt deleted file mode 100644 index 54f68dc..0000000 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderAlertStore.kt +++ /dev/null @@ -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 - - /** - * 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, nowMillis: Long) -} - -@Singleton -class AndroidReminderAlertStore @Inject constructor( - @ApplicationContext private val context: Context, -) : ReminderAlertStore { - - override fun dueAlerts(nowMillis: Long): List = 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, 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, - ) - } -} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderInstanceSource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderInstanceSource.kt new file mode 100644 index 0000000..7ab93ee --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderInstanceSource.kt @@ -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 + + /** `METHOD_ALERT` reminder offsets per event id, for the given events. */ + fun reminderMinutes(eventIds: Collection): Map> + + /** + * 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 { + 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): Map> { + if (eventIds.isEmpty()) return emptyMap() + val out = mutableMapOf>() + // 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, + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderMaintenanceWorker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderMaintenanceWorker.kt new file mode 100644 index 0000000..7f04042 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderMaintenanceWorker.kt @@ -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(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" + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderNotifier.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderNotifier.kt index 0b16714..7e512db 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderNotifier.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderNotifier.kt @@ -29,11 +29,13 @@ import javax.inject.Inject import javax.inject.Singleton /** - * Posts one notification per due reminder alert on a dedicated channel. - * Tapping opens the event's detail screen; the tag is the alert id, so a - * re-broadcast of an alert we couldn't mark fired replaces its notification - * silently ([NotificationCompat.Builder.setOnlyAlertOnce]) instead of - * duplicating it. + * Posts one notification per due reminder on a dedicated channel. Tapping opens + * the event's detail screen. + * + * The tag is the reminder's stable key, so a scan that posts the same reminder + * 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 class ReminderNotifier @Inject constructor( @@ -52,13 +54,12 @@ class ReminderNotifier @Inject constructor( } /** - * The single choke point for "this calendar is switched off". The provider - * side needs no help — with `VISIBLE = 0` it creates no alert rows at all — - * but two paths reach [post] without one: a snooze we re-show from our own - * exact alarm, scheduled before the calendar was switched off, and a - * read-only install whose switch lives app-side ([CalendarPrefs]) because it - * may not write the flag. Both are covered here rather than in either - * receiver. + * The single choke point for "this calendar is switched off". The scan + * already filters on `Calendars.VISIBLE`, but two paths reach [post] around + * it: a snooze re-shown from its own alarm, armed before the calendar was + * switched off, and a read-only install whose switch lives app-side + * ([CalendarPrefs]) because it may not write the flag. Both are covered here + * rather than in either receiver. */ private suspend fun isSilenced(calendarId: Long): Boolean = calendarId in calendarPrefs.pendingDisabledCalendarIds.first() || @@ -66,8 +67,8 @@ class ReminderNotifier @Inject constructor( /** * Post [alert], unless its calendar is switched off. Returns whether the - * notification was put up: a silenced alert must stay unhandled so that - * switching the calendar back on can still surface it (see [handledAlertIds]). + * notification was put up, which the snooze re-show path uses to tell a + * silenced reminder from a delivered one. */ suspend fun post(alert: ReminderAlert): Boolean { if (isSilenced(alert.calendarId)) return false @@ -117,7 +118,7 @@ class ReminderNotifier @Inject constructor( .build() try { NotificationManagerCompat.from(context) - .notify(alert.alertId.toString(), NOTIFICATION_ID, notification) + .notify(alert.key.toString(), NOTIFICATION_ID, notification) } catch (e: SecurityException) { // POST_NOTIFICATIONS was revoked between canPost() and here. 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). */ 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 = @@ -141,7 +142,11 @@ class ReminderNotifier @Inject constructor( private fun detailIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity( 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), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderRecovery.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderRecovery.kt deleted file mode 100644 index 93bf846..0000000 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderRecovery.kt +++ /dev/null @@ -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) = 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) - } -} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderScanner.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderScanner.kt new file mode 100644 index 0000000..1522850 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderScanner.kt @@ -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( + 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 + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderScheduleReceiver.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderScheduleReceiver.kt new file mode 100644 index 0000000..929b0d7 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderScheduleReceiver.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderSnoozeScheduler.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderSnoozeScheduler.kt index 4bb78f5..ea1ad44 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderSnoozeScheduler.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderSnoozeScheduler.kt @@ -12,9 +12,12 @@ import javax.inject.Singleton /** * Schedules a one-off exact alarm that re-shows a snoozed reminder. * - * The app otherwise relies entirely on the calendar provider's `EVENT_REMINDER` - * broadcast (the Etar model), but a snoozed reminder has no provider backing — - * its `CalendarAlerts` row is already fired — so we must re-fire it ourselves. + * Separate from [ReminderAlarmScheduler]'s scan alarm, and deliberately so: a + * snooze is pinned to one reminder at a time the user chose, while the scan + * 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 * 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). diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/reminders/ReminderPlan.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/reminders/ReminderPlan.kt index d0decb5..e098b3f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/reminders/ReminderPlan.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/reminders/ReminderPlan.kt @@ -195,6 +195,20 @@ fun scheduleReminders( 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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt index fb745ec..3a005c0 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt @@ -13,7 +13,6 @@ import de.jeanlucmakiola.calendula.data.di.IoDispatcher import de.jeanlucmakiola.calendula.data.ics.IcsExporter import de.jeanlucmakiola.calendula.data.prefs.BackupStatus 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.ics.IcsWriter import kotlinx.coroutines.CoroutineDispatcher @@ -44,7 +43,6 @@ class CalendarsViewModel @Inject constructor( private val repository: CalendarRepository, private val icsExporter: IcsExporter, private val settingsPrefs: SettingsPrefs, - private val reminderRecovery: ReminderRecovery, @IoDispatcher private val io: CoroutineDispatcher, ) : ViewModel() { @@ -155,24 +153,21 @@ class CalendarsViewModel @Inject constructor( * reminders. Nothing is patched by hand — the provider notifies and the * observer re-queries. * - * Switching one back on also re-posts the reminders it silenced while it was - * off and that are still relevant — those the app kept app-side because it - * may not write the flag ([ReminderRecovery]). + * Nothing has to be re-posted on the way back on: reminder delivery plans + * from `Instances` and `Reminders` on every scan (#75), and the provider + * change this write makes triggers one. */ fun setCalendarVisible(id: Long, visible: Boolean) = write { repository.setCalendarsVisible(listOf(id), visible) - if (visible) reminderRecovery.rePostFor(listOf(id)) } /** * 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 - * provider only re-arms reminder alarms for a single-id update), in one + * affordance on an account header. Each row is written on its own, in one * coroutine so the writes can't race each other. */ fun setAccountVisible(ids: Collection, visible: Boolean) = write { repository.setCalendarsVisible(ids, visible) - if (visible) reminderRecovery.rePostFor(ids) } // --- Automatic backup (issue #8) ------------------------------------ diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/AlertHandlingTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/AlertHandlingTest.kt deleted file mode 100644 index 9b377e2..0000000 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/AlertHandlingTest.kt +++ /dev/null @@ -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) - } -} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/reminders/ReminderPlanTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/reminders/ReminderPlanTest.kt index 443bbcb..8dcd1c1 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/reminders/ReminderPlanTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/reminders/ReminderPlanTest.kt @@ -437,6 +437,26 @@ class ReminderPlanTest { .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 fun `a negative longest offset does not shrink the query horizon`() { assertThat(reminderQueryHorizon(lookaheadMillis = 7 * day, maxReminderMinutes = -420)) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 629f57f..d84f2eb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -33,7 +33,7 @@ flowchart TD Repo["CalendarRepository\n(interface + impl, Flow-based, io-dispatched)"] DS["CalendarDataSource\n(interface + AndroidCalendarDataSource)"] Prefs["SettingsPrefs / CalendarPrefs\n(DataStore)"] - Rem["reminders/\nReminderAlertStore + ReminderNotifier"] + Rem["reminders/\nReminderScanner + ReminderNotifier"] end Provider[("CalendarContract\n(system calendar provider)")] @@ -137,29 +137,56 @@ can't fake a conflict. ## Reminder delivery -The provider schedules reminder alarms (for `METHOD_ALERT` rows only) and -broadcasts `EVENT_REMINDER` — but posts no notification; a calendar app -must (the Etar model): +Calendula plans and fires its own reminders. It reads the offsets in +`Reminders` as data, works out when each occurrence's reminder is due, and holds +**one** exact alarm for the earliest one still ahead: ```mermaid sequenceDiagram - participant P as CalendarProvider - participant R as EventReminderReceiver - participant S as ReminderAlertStore + participant T as Trigger (alarm / boot / time change / edit / launch / daily worker) + participant Sc as ReminderScanner + participant Src as ReminderInstanceSource + participant P as ReminderPlan (pure) participant N as ReminderNotifier - P->>R: EVENT_REMINDER broadcast (manifest receiver, exported) - R->>S: dueAlerts(now) — CalendarAlerts: SCHEDULED, alarmTime ≤ now - S-->>R: due alerts - R->>N: post(alert) — one notification per alert, tag = alert id - R->>S: markFired(ids) — best effort, needs WRITE_CALENDAR + participant A as ReminderAlarmScheduler + T->>Sc: scan() + Sc->>Src: occurrences(window) + reminderMinutes(ids) + Src-->>Sc: Instances ⋈ Reminders + 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 -tag + `setOnlyAlertOnce`) rather than losing a reminder. Swiped -notifications never return because `FIRED` rows are never re-queried. +**Why not the provider's broadcast.** It used to schedule the alarms, write the +`CalendarAlerts` rows and broadcast `EVENT_REMINDER`, and the app only reacted. +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 -`Calendars.VISIBLE = 1`, so that flag *is* the app's on/off switch: Settings → +**The watermark replaces `CalendarAlerts.STATE`.** A scan posts the reminders +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 `checkNextAlarm()` reschedule for any selection that isn't `_id=`), and every 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, 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 -switched off. Silencing is not handling: an alert the gate drops keeps its -`SCHEDULED` state while its event is still ahead (`handledAlertIds`), so -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. +switched off. 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 -scheduling, `BOOT_COMPLETED`, snooze/dismiss actions, battery-exemption -prompts. +Deliberately absent: a fallback to the provider's `EVENT_REMINDER` broadcast. +Keeping both would double-post wherever the provider works, and Etar's way out — +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