From 4edbca7456f7b118e1a560fe004d7509d2d7e99c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 05:01:16 +0000 Subject: [PATCH] Overnight dark window: a deliberate wake keeps control (fixes #138) In the dark overnight window the screen is off via lockNow. A tap to wake fires ACTION_DREAMING_STOPPED; classifyDreamStop sees the device is interactive and returns REDREAM, so handleRedreamDuringOvernight runs. With no night session active it hit REBLANK and slept the screen again the instant it lit up. The catch: every redream that reaches this path is already interactive (that is what makes the verdict REDREAM), so interactivity alone cannot tell a real user wake from a stray sibling/system dream cycle (issue #73). Use the real user-input signal instead: - onInteraction (USER_PRESENT / a launcher touch) now STARTS a night session when inside the window with none active, instead of only renewing an existing one - handing the user the device on a dark-window wake. - A lastUserWakeAt timestamp feeds a new userWokeRecently input to classifyOvernightRedream: a redream within USER_WAKE_GRACE_MS of a real wake LEAVEs (and the caller starts the session) rather than reblanking, covering the race where the dream-stop broadcast beats USER_PRESENT. Stray cycles (no recent user wake) still REBLANK, so #73 stays fixed. Pure-classifier unit tests cover both the #138 wake case and the #73 stray-cycle case. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01H4SmhtgSd5B5sdTiV216XM --- .../com/immortal/launcher/SleepScheduler.kt | 60 +++++++++++++++---- .../immortal/launcher/SleepSchedulerTest.kt | 40 +++++++++++++ 2 files changed, 88 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/immortal/launcher/SleepScheduler.kt b/app/src/main/java/com/immortal/launcher/SleepScheduler.kt index 471a33f..c8a5d23 100644 --- a/app/src/main/java/com/immortal/launcher/SleepScheduler.kt +++ b/app/src/main/java/com/immortal/launcher/SleepScheduler.kt @@ -57,11 +57,18 @@ object SleepScheduler { // any idle device instead of snapping back to the rest state. private const val OVERNIGHT_SESSION_DEFAULT_MS = 5L * 60_000 + // How recently a real user wake (USER_PRESENT / a touch) must have happened for a redream landing + // inside the dark window to be read as "the user woke the device" rather than a stray dream cycle + // (issue #138). Covers the brief race where the dream-stop broadcast beats USER_PRESENT. + private const val USER_WAKE_GRACE_MS = 4000L + // The renewable overnight "you have the device" session, owned here (was HomeActivity's Handler). // Lazy so loading this object (e.g. for the pure inWindow tests) doesn't touch the main Looper. private val main by lazy { Handler(Looper.getMainLooper()) } @Volatile private var nightSessionActive = false @Volatile private var nightSessionCtx: Context? = null + // When the user last unambiguously woke/touched the device (USER_PRESENT or a launcher touch). + @Volatile private var lastUserWakeAt = 0L private val nightSessionElapsed = Runnable { nightSessionActive = false nightSessionCtx?.let { if (isOvernightNow(it)) enterOvernightRest(it) } @@ -127,11 +134,22 @@ object SleepScheduler { } } - /** A touch on the launcher: renew the overnight session so interaction keeps the screen up. */ + /** + * A real user wake or touch (USER_PRESENT, or a launcher touch). Renews the overnight session so + * interaction keeps the screen up — and, crucially, *starts* one if we're inside the dark + * overnight window with none active yet. Without that start, a deliberate 3am tap wakes the + * screen and a racing redream immediately reblanks it (issue #138), because nothing had handed + * the user the device. The timestamp also lets [handleRedreamDuringOvernight] recognise a wake + * whose USER_PRESENT lands just after the dream-stop. + */ fun onInteraction(context: Context) { - if (!nightSessionActive) return - main.removeCallbacks(nightSessionElapsed) - main.postDelayed(nightSessionElapsed, overnightSessionMs(context)) + lastUserWakeAt = System.currentTimeMillis() + if (nightSessionActive) { + main.removeCallbacks(nightSessionElapsed) + main.postDelayed(nightSessionElapsed, overnightSessionMs(context)) + } else if (isOvernightNow(context)) { + startNightSession(context) + } } /** The launcher is no longer foreground: drop any pending overnight re-sleep. */ @@ -196,28 +214,37 @@ object SleepScheduler { internal enum class OvernightRedream { /** Not in the window (or night-clock mode): let the normal frame relaunch proceed. */ RELAUNCH, - /** The user deliberately woke the device (a session is live): leave the screen alone. */ + /** The user deliberately woke the device (a session is live, or one just started): leave it. */ LEAVE, - /** Dark window, no live session: re-blank directly, without launching an Activity. */ + /** Dark window, no user around: re-blank directly, without launching an Activity. */ REBLANK, } /** - * Pure (unit-tested) decision for [handleRedreamDuringOvernight]. A stray dream stop inside the - * dark window must not relaunch [PhotoFramePreviewActivity]: launching an Activity wakes the - * screen, and the activity then immediately blanks it again — a brief flash every time a - * sibling/system dream cycles overnight (issue #73). Night-clock mode still relaunches, because - * there the frame *is* the dimmed clock the window is meant to show. + * Pure (unit-tested) decision for [handleRedreamDuringOvernight]. Two things must both hold: + * + * - A *stray* dream stop inside the dark window must not relaunch [PhotoFramePreviewActivity]: + * launching an Activity wakes the screen, and the activity then immediately blanks it again — + * a brief flash every time a sibling/system dream cycles overnight (issue #73). So with no + * user around we [REBLANK] in place. Night-clock mode still relaunches, because there the + * frame *is* the dimmed clock the window is meant to show. + * - A *deliberate* dark-window wake must NOT be reblanked (issue #138). Every redream that + * reaches here is already `interactive` (that is what makes the verdict REDREAM), so + * interactivity alone can't tell a real wake from a stray cycle. [userWokeRecently] — a real + * USER_PRESENT/touch within [USER_WAKE_GRACE_MS] — is the signal that distinguishes them, and + * it means [LEAVE] (the caller then starts the renewable session). */ internal fun classifyOvernightRedream( inWindow: Boolean, nightSessionActive: Boolean, nightClock: Boolean, + userWokeRecently: Boolean = false, ): OvernightRedream = when { !inWindow -> OvernightRedream.RELAUNCH nightSessionActive -> OvernightRedream.LEAVE nightClock -> OvernightRedream.RELAUNCH + userWokeRecently -> OvernightRedream.LEAVE else -> OvernightRedream.REBLANK } @@ -244,15 +271,24 @@ object SleepScheduler { * relaunch (outside the window, or night-clock mode where the relaunch renders the clock). */ fun handleRedreamDuringOvernight(context: Context): Boolean { + val userWokeRecently = + lastUserWakeAt > 0L && System.currentTimeMillis() - lastUserWakeAt in 0..USER_WAKE_GRACE_MS val decision = classifyOvernightRedream( inWindow = isOvernightNow(context), nightSessionActive = nightSessionActive, nightClock = ScreensaverConfig.load(context).overnightNightClock, + userWokeRecently = userWokeRecently, ) return when (decision) { OvernightRedream.RELAUNCH -> false - OvernightRedream.LEAVE -> true + OvernightRedream.LEAVE -> { + // A deliberate dark-window wake whose USER_PRESENT raced behind this dream-stop: start the + // renewable session now so the screen returns to rest after idle instead of staying lit. + // A no-op when a session is already active (the other LEAVE case). + if (!nightSessionActive) startNightSession(context) + true + } OvernightRedream.REBLANK -> { // Keep the system Dream suppressed for the window, then blank without an Activity launch // (no flash). Mirrors enterOvernightRest's dark path minus the screen-on round-trip. diff --git a/app/src/test/java/com/immortal/launcher/SleepSchedulerTest.kt b/app/src/test/java/com/immortal/launcher/SleepSchedulerTest.kt index b46e0aa..723e3b5 100644 --- a/app/src/test/java/com/immortal/launcher/SleepSchedulerTest.kt +++ b/app/src/test/java/com/immortal/launcher/SleepSchedulerTest.kt @@ -57,6 +57,46 @@ class SleepSchedulerTest { inWindow = true, nightSessionActive = false, nightClock = true)) } + @Test + fun redream_darkWindow_afterUserWake_leavesScreenAlone() { + // Issue #138: a deliberate dark-window wake whose USER_PRESENT raced behind this dream-stop. + // Every redream reaching here is already interactive, so the recent-user-wake signal is what + // distinguishes a real wake from a stray cycle — hand the user the device, don't reblank. + assertEquals( + SleepScheduler.OvernightRedream.LEAVE, + SleepScheduler.classifyOvernightRedream( + inWindow = true, + nightSessionActive = false, + nightClock = false, + userWokeRecently = true)) + } + + @Test + fun redream_darkWindow_strayCycle_stillReblanks() { + // Issue #73 stays fixed: with no recent user wake, an interactive stray dream cycle reblanks + // in place rather than flashing an Activity. + assertEquals( + SleepScheduler.OvernightRedream.REBLANK, + SleepScheduler.classifyOvernightRedream( + inWindow = true, + nightSessionActive = false, + nightClock = false, + userWokeRecently = false)) + } + + @Test + fun redream_nightClockWindow_afterUserWake_stillRelaunchesTheClock() { + // Night-clock precedence is unchanged: the clock Activity holds the screen either way, so a + // recent wake doesn't need the dark-window LEAVE path here. + assertEquals( + SleepScheduler.OvernightRedream.RELAUNCH, + SleepScheduler.classifyOvernightRedream( + inWindow = true, + nightSessionActive = false, + nightClock = true, + userWokeRecently = true)) + } + // ----- immediate overnight application (issue #73 intentional wake) ----- @Test