fix(bedtime): keep the slot off the allow-while-idle ration

Android rations allow-while-idle wake-ups per app in doze, and the timer's
expiry slot and the alarm's fallback share that ration, so a 07:00
wake-up fire could hold back a timer due at 07:04. The bedtime slot is
now a plain exact alarm that doze may defer, which can move a mode
boundary but never a ring. A restore of unchanged bedtime settings no
longer forces the mode, and the screen skips its rule check while bedtime
is off.

Refs #1
This commit is contained in:
makiolaj committed 2026-10-22 12:20:25 +02:00
1 parent 8b182c999b
commit 1a5552a221
4 files changed
+31 -16

No files matched your search

@@ -99,7 +99,12 @@ class BackupEngine @Inject constructor(
// with them), so the slot and the Do Not Disturb rule follow.
// Its platform calls are guarded already; a failure here is
// not a failed import. Nothing to follow if they never changed.
if (settingsApplied) runCatching { bedtimeEngine.resync(refreshRule = true) }
// Forced only when the bedtime settings really changed, so a
// restore of the same schedule leaves a hand-switched mode be.
if (settingsApplied) {
val changed = previousSettings.bedtime != content.settings.bedtime
runCatching { bedtimeEngine.resync(refreshRule = changed) }
}
}
}
failure?.let { throw it }
@@ -15,11 +15,17 @@ import javax.inject.Singleton
/**
* One wall-clock slot for the schedule's next event. Never `setAlarmClock`:
* that slot is the status bar's "next alarm", and bedtime is not an alarm.
* Exact while idle where the platform allows it, the same two branches the
* timer's slot uses: a wake-up time that ends the mode an hour late, after a
* doze maintenance window, is the failure this avoids. It is its own
* PendingIntent (its own receiver, action and request code), so arming or
* cancelling it can never touch an alarm's or a timer's registration.
*
* And never *allow-while-idle*. Android rations allow-while-idle wake-ups
* per app while the device dozes (one per nine minutes on Android 10 and 11,
* and for the inexact fallback without exact-alarm access), and the timer's
* expiry slot and the alarm's fallback draw on the same ration. A 07:00
* wake-up fire would hold back a timer due at 07:04 or a fallback alarm at
* 07:05. So the slot is exact but doze-deferrable: in deep doze it waits for
* the next maintenance window or for the next alarm clock to wake the device,
* which only moves a mode boundary, never a ring. It is its own PendingIntent
* (its own receiver, action and request code), so arming or cancelling it can
* never touch an alarm's or a timer's registration.
*/
@Singleton
class AndroidBedtimeScheduler @Inject constructor(
@@ -31,12 +37,10 @@ class AndroidBedtimeScheduler @Inject constructor(
override fun schedule(at: Instant) {
val millis = at.toEpochMilliseconds()
val pendingIntent = slotPendingIntent()
val exact = runCatching {
manager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, millis, pendingIntent)
}.isSuccess
val exact = runCatching { manager.setExact(AlarmManager.RTC_WAKEUP, millis, pendingIntent) }.isSuccess
// Guarded too: bedtime losing its slot is survivable, a crash in the
// receiver's process is not.
if (!exact) runCatching { manager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, millis, pendingIntent) }
if (!exact) runCatching { manager.set(AlarmManager.RTC_WAKEUP, millis, pendingIntent) }
}
override fun cancel() = manager.cancel(slotPendingIntent())
@@ -54,7 +54,8 @@ class BedtimeViewModel @Inject internal constructor(
appScope.launch {
// Also when access was granted while this screen was closed and
// the system's broadcast did not reach the app: no rule yet.
if (changed || (granted && engine.ruleId() == null)) {
val enabled = (state.value as? BedtimeUiState.Loaded)?.settings?.enabled == true
if (changed || (granted && enabled && engine.ruleId() == null)) {
runCatching { engine.resync(refreshRule = true) }
}
ruleId.value = engine.ruleId()