sync: reminders survive the alarm ceiling, and a save is not undone by one
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.
This commit is contained in:
@@ -93,12 +93,24 @@ class ReminderScheduler @Inject constructor(
|
|||||||
// setExactAndAllowWhileIdle delivers a past trigger immediately. Anything
|
// setExactAndAllowWhileIdle delivers a past trigger immediately. Anything
|
||||||
// already armed stays armed (the diff below), so it can't re-fire.
|
// already armed stays armed (the diff below), so it can't re-fire.
|
||||||
.filter { it.triggerAt in (now - MISSED_GRACE_MS)..horizon }
|
.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()
|
.toSet()
|
||||||
|
|
||||||
val previous = store.all()
|
val previous = store.all()
|
||||||
(previous - desired).forEach { cancel(it) }
|
(previous - desired).forEach { cancel(it) }
|
||||||
(desired - previous).forEach { schedule(it) }
|
// ⚠️ Only what was actually armed. A throw mid-loop used to skip the
|
||||||
store.replace(desired)
|
// 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)
|
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 triggerAt = reminder.triggerAt
|
||||||
val pi = pendingIntent(reminder, create = true) ?: return
|
val pi = pendingIntent(reminder, create = true) ?: return false
|
||||||
val am = alarmManager()
|
val am = alarmManager()
|
||||||
val canExact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || am.canScheduleExactAlarms()
|
val canExact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || am.canScheduleExactAlarms()
|
||||||
if (canExact) {
|
return try {
|
||||||
am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pi)
|
if (canExact) {
|
||||||
} else {
|
am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pi)
|
||||||
am.set(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
|
const val WINDOW_MS = 30L * 24 * 60 * 60 * 1000 // 30 days
|
||||||
/** How long after its trigger a missed reminder is still worth firing. */
|
/** How long after its trigger a missed reminder is still worth firing. */
|
||||||
const val MISSED_GRACE_MS = 6L * 60 * 60 * 1000 // 6 hours
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -227,11 +227,20 @@ class TaskEditViewModel @Inject constructor(
|
|||||||
runCatching {
|
runCatching {
|
||||||
val id = editingTaskId
|
val id = editingTaskId
|
||||||
if (id == null) {
|
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 {
|
} else {
|
||||||
repository.updateTask(id, form, expectedLastModified = if (force) null else baselineLastModified)
|
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 {
|
}.onSuccess {
|
||||||
_state.value = _state.value.copy(saved = true, saveFailed = false, saveConflict = false)
|
_state.value = _state.value.copy(saved = true, saveFailed = false, saveConflict = false)
|
||||||
}.onFailure { error ->
|
}.onFailure { error ->
|
||||||
|
|||||||
Reference in New Issue
Block a user