From 8154a9df36abc25469ed18583ca563d9b171a1a3 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 9 Sep 2026 12:19:04 +0200 Subject: [PATCH] sync: reminders survive the alarm ceiling, and a save is not undone by one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setExactAndAllowWhileIdle throws at 500 concurrent alarms per uid, which the per-occurrence model reaches at roughly seventeen daily recurring tasks over a thirty-day window. When it threw mid-loop store.replace never ran, so every alarm armed on that pass went unrecorded — uncancellable, and firing for tasks that no longer exist — and the exception escaped into BootReceiver's goAsync(). The set is bounded soonest-first well below the ceiling, since the far edge of the window is what the next sync arms anyway, and a single refusal now costs that one alarm rather than the pass. And the reminder sync shared the write's runCatching in the edit screen, so a scheduling failure reported a task that *was* written as unsaved. The user taps Save again on a screen whose editingTaskId is still null and gets a second task — the reminder would have been re-synced on the next data change regardless. The created id is also remembered now, so a second Save updates rather than duplicates whatever sent them back. --- .../data/reminders/ReminderScheduler.kt | 49 ++++++++++++++++--- .../agendula/ui/edit/TaskEditViewModel.kt | 13 ++++- 2 files changed, 52 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderScheduler.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderScheduler.kt index 58467e0..afcf7ba 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderScheduler.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderScheduler.kt @@ -93,12 +93,24 @@ class ReminderScheduler @Inject constructor( // setExactAndAllowWhileIdle delivers a past trigger immediately. Anything // already armed stays armed (the diff below), so it can't re-fire. .filter { it.triggerAt in (now - MISSED_GRACE_MS)..horizon } + // ⚠️ Bounded, soonest first. Android 12+ throws at 500 concurrent + // exact alarms per app, and the per-occurrence model reaches that + // with about seventeen daily recurring tasks over this window. What + // falls off the end is the far edge of a thirty-day horizon, which + // the next sync arms as it comes closer; what an exception would + // cost is every alarm this pass touched. + .sortedBy { it.triggerAt } + .take(MAX_ALARMS) .toSet() val previous = store.all() (previous - desired).forEach { cancel(it) } - (desired - previous).forEach { schedule(it) } - store.replace(desired) + // ⚠️ Only what was actually armed. A throw mid-loop used to skip the + // write below entirely, so every alarm set on that pass went unrecorded + // — uncancellable, and firing for tasks that no longer exist — and the + // exception escaped into BootReceiver's goAsync(). + val armed = (desired - previous).filter { schedule(it) } + store.replace(desired.intersect(previous) + armed) } private fun alarmManager(): AlarmManager = context.getSystemService(AlarmManager::class.java) @@ -114,15 +126,26 @@ class ReminderScheduler @Inject constructor( ) } - private fun schedule(reminder: ScheduledReminder) { + /** @return whether the alarm is now armed, and so worth recording. */ + private fun schedule(reminder: ScheduledReminder): Boolean { val triggerAt = reminder.triggerAt - val pi = pendingIntent(reminder, create = true) ?: return + val pi = pendingIntent(reminder, create = true) ?: return false val am = alarmManager() val canExact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || am.canScheduleExactAlarms() - if (canExact) { - am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pi) - } else { - am.set(AlarmManager.RTC_WAKEUP, triggerAt, pi) + return try { + if (canExact) { + am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pi) + } else { + am.set(AlarmManager.RTC_WAKEUP, triggerAt, pi) + } + true + } catch (_: IllegalStateException) { + // The concurrent-alarm ceiling, which [MAX_ALARMS] keeps us under — + // but the count is per uid and nothing here owns all of it. + false + } catch (_: SecurityException) { + // Exact-alarm permission revoked between the check and the call. + false } } @@ -142,5 +165,15 @@ class ReminderScheduler @Inject constructor( const val WINDOW_MS = 30L * 24 * 60 * 60 * 1000 // 30 days /** How long after its trigger a missed reminder is still worth firing. */ const val MISSED_GRACE_MS = 6L * 60 * 60 * 1000 // 6 hours + + /** + * Alarms this app will hold at once. + * + * Android 12+ throws at 500 per uid. Well under it, because the count is + * per *uid* and this is not the only thing in the process that can arm + * one — and because the alarms nearest in time are the ones that matter, + * while the far edge of the window is re-armed by the next sync. + */ + const val MAX_ALARMS = 400 } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditViewModel.kt index 03d5e63..f7f0d77 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditViewModel.kt @@ -227,11 +227,20 @@ class TaskEditViewModel @Inject constructor( runCatching { val id = editingTaskId if (id == null) { - repository.createTask(form) + // ⚠️ Remembered. A second Save on this screen would otherwise + // take the create path again and write a second task — + // whatever sent the user back to it. + editingTaskId = repository.createTask(form) } else { repository.updateTask(id, form, expectedLastModified = if (force) null else baselineLastModified) } - reminderScheduler.sync() + // ⚠️ Outside the write's own result. The task is saved by this + // point, and reporting a scheduling failure as a *save* failure + // sends the user back to tap Save again — on a screen whose + // editingTaskId is still null, which creates a second task. The + // reminder is re-synced on the next data change and on launch; + // the duplicate is forever. + runCatching { reminderScheduler.sync() } }.onSuccess { _state.value = _state.value.copy(saved = true, saveFailed = false, saveConflict = false) }.onFailure { error ->