Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 48 additions & 12 deletions app/src/main/java/com/immortal/launcher/SleepScheduler.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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
}

Expand All @@ -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.
Expand Down
40 changes: 40 additions & 0 deletions app/src/test/java/com/immortal/launcher/SleepSchedulerTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading