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
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -946,7 +946,8 @@ things to preserve:
- **The hypnogram draws the counted gaps** (`hypnogramBlocks`), display-only. Never feed those
blocks to `awakeMinutes`, which counts the gaps itself.
- **An hour-plus gap inside the wearer's Bedtime window stays one night** (`SleepGapBridge`). Off
unless quiet hours are on; Bedtime windows decide when the night has any, else the clock window.
unless the schedule or Bedtime tracking is on — the two are independent since #126; Bedtime
windows decide when the night has any, else the clock window if the schedule is on.
The sync's reconcile and `SleepRecordDeletion`'s restate must segment with the same bridge, or a
record delete would split a night the next sync merges back.

Expand Down
3 changes: 3 additions & 0 deletions app/src/main/java/com/pulseloop/PulseLoopApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ class PulseLoopApplication : Application() {
override fun onCreate() {
super.onCreate()
CrashLogger.install(this)
// Before any sync can read them: Bedtime tracking no longer needs the schedule (issue #126),
// so a Bedtime flag left on under a switched-off schedule is cleared rather than reread.
QuietHoursPrefs.migrateIndependentBedtime(this)
// Application-scoped so capture runs whether or not the Debug screen is open.
DiagnosticsSubscriber(PulseLoopDatabase.getInstance(this)).start(appScope)
// One-time cleanup of activity totals corrupted by the old bucket handling; runs off the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -581,13 +581,12 @@ class EventPersistenceSubscriber(
// judged by its start, because the ring often runs the sofa hour and the real night as one
// record. Already-imported nights are untouched (this never deletes), and the gate is read
// per record so a settings change takes effect on the next packet, not the next launch.
val quiet = QuietHoursPrefs(context)
if (quiet.enabled) {
val kept = quiet.keptMinutesForRecord(effectiveTs, effectiveStages.size, context)
?: return
effectiveTs = effectiveTs + kept.first * 60_000L
effectiveStages = effectiveStages.subList(kept.first, kept.last + 1)
}
// The schedule and Bedtime tracking are independent (issue #126); with neither on this
// keeps the whole record.
val kept = QuietHoursPrefs(context).keptMinutesForRecord(effectiveTs, effectiveStages.size, context)
?: return
effectiveTs = effectiveTs + kept.first * 60_000L
effectiveStages = effectiveStages.subList(kept.first, kept.last + 1)

// Sleep record deletion gate: drop if the user deleted this entire record (issue #78, #82)
val dayStart = com.pulseloop.util.TimeUtil.wakingDayLocal(effectiveTs)
Expand Down
48 changes: 18 additions & 30 deletions app/src/main/java/com/pulseloop/service/SleepGapBridge.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,7 @@ package com.pulseloop.service
import android.content.Context
import com.pulseloop.data.entity.SleepStageBlockEntity
import com.pulseloop.settings.QuietHoursPrefs
import com.pulseloop.settings.ZenModeListener
import com.pulseloop.settings.ZenModeTracker
import com.pulseloop.settings.ZenWindow
import com.pulseloop.util.TimeUtil
import java.time.ZoneId

/**
Expand Down Expand Up @@ -38,52 +35,43 @@ fun interface SleepGapBridge {
/** The bridge for the user's current quiet-hours settings. Reads them once. */
fun fromSettings(context: Context): SleepGapBridge {
val prefs = QuietHoursPrefs(context)
val zenWindows = if (prefs.gateByZenMode && ZenModeListener.isAccessGranted(context)) {
ZenModeTracker.getWindows(context)
} else {
null
}
return forWindows(prefs.enabled, zenWindows, prefs.startMinutes, prefs.endMinutes)
return forWindows(
prefs.enabled,
QuietHoursPrefs.activeBedtimeWindows(prefs, context),
prefs.startMinutes,
prefs.endMinutes,
)
}

/**
* The same source order as the import gate ([QuietHoursPrefs.keptMinutesForRecord]): off
* unless quiet hours are on; the recorded Bedtime/DND windows when that gating is on and the
* night has any; otherwise the clock window. A night that has Bedtime windows is judged by
* them alone, so a gap after Bedtime ended is not bridged by a wider clock schedule.
* The same source order as the import gate ([QuietHoursPrefs.resolveKeptMinutes]): the
* recorded Bedtime/DND windows when that tracking is on and the night has any; otherwise
* the clock window if the schedule is on; otherwise nothing is bridged. A night that has
* Bedtime windows is judged by them alone, so a gap after Bedtime ended is not bridged by a
* wider clock schedule. Bedtime tracking bridges with the schedule off (issue #126), as
* the import gate trims with it off.
*
* [zenWindows] is null when Bedtime gating is off.
* [zenWindows] is null when Bedtime tracking is off.
*/
fun forWindows(
enabled: Boolean,
scheduleEnabled: Boolean,
zenWindows: List<ZenWindow>?,
startMinutes: Int,
endMinutes: Int,
zone: ZoneId = ZoneId.systemDefault(),
): SleepGapBridge {
if (!enabled) return NONE
if (!scheduleEnabled && zenWindows == null) return NONE
return SleepGapBridge { gapStart, gapEnd ->
if (gapEnd <= gapStart) return@SleepGapBridge false
val night = zenWindows?.let { nightWindows(it, gapStart, zone) }.orEmpty()
val night = zenWindows?.let { QuietHoursPrefs.nightWindows(it, gapStart, zone) }.orEmpty()
if (night.isNotEmpty()) {
night.any { w -> w.start <= gapStart && gapEnd <= effectiveEnd(w) }
night.any { w -> w.start <= gapStart && gapEnd <= QuietHoursPrefs.effectiveEnd(w) }
} else {
clockCovers(gapStart, gapEnd, startMinutes, endMinutes, zone)
scheduleEnabled && clockCovers(gapStart, gapEnd, startMinutes, endMinutes, zone)
}
}
}

/** The windows of the waking day [ts] falls in — the span the import gate reads too. */
private fun nightWindows(windows: List<ZenWindow>, ts: Long, zone: ZoneId): List<ZenWindow> {
val wakingDay = TimeUtil.wakingDayLocal(ts, zone)
val dayStart = wakingDay - 12 * 3_600_000L
val dayEnd = wakingDay + 36 * 3_600_000L
return windows.filter { w -> effectiveEnd(w) > dayStart && w.start < dayEnd }
}

private fun effectiveEnd(w: ZenWindow): Long =
w.end ?: (w.start + ZenModeTracker.MAX_WINDOW_DURATION_MS)

/**
* Every minute of the gap inside the daily clock window. A gap of a day or more never is, and
* neither is anything under a window whose start equals its end: for the import gate that
Expand Down
131 changes: 105 additions & 26 deletions app/src/main/java/com/pulseloop/settings/QuietHoursPrefs.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,9 @@ import java.time.ZoneId
* window. A still wrist reads as sleep, so an evening on the sofa lands as a phantom session and
* the night runs an hour high; the wearer knows their bedtime, the sensor guesses.
*
* Deliberately a time window rather than Android's quiet Modes (the reporter's first choice):
* a Mode gate needs notification-policy access plus a recorded interruption-filter history to
* compare imports against, and the sofa case is mostly caught by a plain window. The Modes
* version stays open as a follow-up — the reporter offered to build it.
* Two sources, each opt-in and independent of the other (issue #126): the daily clock window
* ([enabled]) and the wearer's recorded Android Bedtime / DND windows ([gateByZenMode], issue #83,
* which needs Notification access). [resolveKeptMinutes] is how they combine.
*
* **Minute-level, not record-level.** A record is trimmed to the minutes that fall inside the
* window ([keptMinutes]) rather than accepted or declined by its start minute. The ring often
Expand All @@ -29,7 +28,10 @@ class QuietHoursPrefs(context: Context) {

private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)

/** Off by default: the ring's own behaviour is the right default for everyone else. */
/**
* The daily clock schedule ("Sleep quiet hours"). Off by default: the ring's own behaviour is
* the right default for everyone else. Independent of [gateByZenMode] since issue #126.
*/
var enabled: Boolean
get() = prefs.getBoolean(KEY_ENABLED, false)
set(value) { prefs.edit().putBoolean(KEY_ENABLED, value).apply() }
Expand All @@ -44,7 +46,10 @@ class QuietHoursPrefs(context: Context) {
get() = prefs.getInt(KEY_END, DEFAULT_END)
set(value) { prefs.edit().putInt(KEY_END, value.coerceIn(0, 24 * 60)).apply() }

/** Opt-in: gate quiet hours on Android Bedtime / DND mode (issue #83). */
/**
* Opt-in: trim to Android Bedtime / DND windows (issue #83). Works on its own since issue #126
* — before that it was read only while [enabled] was on, and the settings row was hidden with it.
*/
var gateByZenMode: Boolean
get() = prefs.getBoolean(KEY_GATE_BY_ZEN_MODE, false)
set(value) { prefs.edit().putBoolean(KEY_GATE_BY_ZEN_MODE, value).apply() }
Expand All @@ -62,6 +67,7 @@ class QuietHoursPrefs(context: Context) {
private const val KEY_START = "startMinutes"
private const val KEY_END = "endMinutes"
private const val KEY_GATE_BY_ZEN_MODE = "gate_by_zen_mode"
private const val KEY_INDEPENDENT_BEDTIME_MIGRATED = "independentBedtime.v1"
const val DEFAULT_START = 22 * 60
const val DEFAULT_END = 7 * 60

Expand All @@ -70,33 +76,109 @@ class QuietHoursPrefs(context: Context) {
QuietHoursPrefs(context).gateByZenMode && ZenModeListener.isAccessGranted(context)

/**
* Which minutes of a ring record to keep given user preferences.
* If [gateByZenMode] is enabled and Notification access is granted, filters against recorded
* Bedtime/DND quiet windows. If no quiet windows exist for the waking day, falls back
* to the configured clock window.
* The recorded Bedtime/DND windows when that tracking is active, else null — the
* `bedtimeWindows` argument of [resolveKeptMinutes] and [com.pulseloop.service.SleepGapBridge.forWindows].
*/
fun activeBedtimeWindows(prefs: QuietHoursPrefs, context: Context): List<ZenWindow>? =
if (prefs.gateByZenMode && ZenModeListener.isAccessGranted(context)) {
ZenModeTracker.getWindows(context)
} else {
null
}

/**
* One-time upgrade step for issue #126, run from [com.pulseloop.PulseLoopApplication] before
* anything reads these prefs. Bedtime=on with the schedule off was reachable before #126
* (turn both on, then the schedule off: the Bedtime row hid but kept its value) and meant
* *no trimming at all*. Read under the new rules it would mean "Bedtime only" and start
* trimming on the first sync after the update — and trimmed minutes are never imported.
* So that state is cleared to "neither", which is what it did; the wearer re-enables
* Bedtime tracking if they want it. Every other state already means what it meant.
*/
fun migrateIndependentBedtime(context: Context) {
val prefs = QuietHoursPrefs(context)
val raw = prefs.prefs
if (raw.getBoolean(KEY_INDEPENDENT_BEDTIME_MIGRATED, false)) return
val gate = migratedGateByZenMode(prefs.enabled, prefs.gateByZenMode)
if (gate != prefs.gateByZenMode) {
prefs.gateByZenMode = gate
ZenModeListener.setEnabled(context, false)
}
raw.edit().putBoolean(KEY_INDEPENDENT_BEDTIME_MIGRATED, true).apply()
}

/** The stored Bedtime flag after the #126 upgrade: kept only while the schedule is on. */
fun migratedGateByZenMode(scheduleEnabled: Boolean, gateByZenMode: Boolean): Boolean =
gateByZenMode && scheduleEnabled

/** Which minutes of a ring record to keep under the user's current settings; see [resolveKeptMinutes]. */
fun keptMinutesForRecord(
ts: Long,
minutes: Int,
prefs: QuietHoursPrefs,
context: Context,
zone: ZoneId = ZoneId.systemDefault(),
): IntRange? = resolveKeptMinutes(
ts,
minutes,
scheduleEnabled = prefs.enabled,
bedtimeWindows = activeBedtimeWindows(prefs, context),
startMinutes = prefs.startMinutes,
endMinutes = prefs.endMinutes,
zone = zone,
)

/**
* Which minutes of a ring record to keep (issue #126: the two sources are independent).
* [bedtimeWindows] is null when Bedtime tracking is off or lacks Notification access.
*
* - **Neither:** the whole record.
* - **Schedule only:** trimmed to the clock window ([keptMinutes]).
* - **Bedtime only:** trimmed to the Bedtime/DND windows it overlaps; a record that overlaps
* none — a night Bedtime was forgotten — is kept whole. Forgetting Bedtime must not cost
* the night: the #126 reporter lost 07:00–08:10 to the clock fallback, permanently.
* "Overlaps", not "the waking day has a window": the filter cannot tell Bedtime from a
* DND meeting the next afternoon (same waking day), and dropping the night over that
* would be unrecoverable. The cost is a separate sofa record before Bedtime is kept; the
* hour gap rule still keeps it apart from the night.
* - **Both:** the Bedtime windows when the night has any and they overlap the record;
* otherwise the clock window (unchanged from #83).
*
* Null drops the record.
*/
fun resolveKeptMinutes(
ts: Long,
minutes: Int,
scheduleEnabled: Boolean,
bedtimeWindows: List<ZenWindow>?,
startMinutes: Int,
endMinutes: Int,
zone: ZoneId = ZoneId.systemDefault(),
): IntRange? {
if (!prefs.enabled) return 0 until minutes
if (prefs.gateByZenMode && ZenModeListener.isAccessGranted(context)) {
val wakingDay = com.pulseloop.util.TimeUtil.wakingDayLocal(ts)
val dayStart = wakingDay - 12 * 3600_000L
val dayEnd = wakingDay + 36 * 3600_000L
val windows = ZenModeTracker.getWindows(context, since = dayStart)
.filter { w -> (w.end ?: (w.start + ZenModeTracker.MAX_WINDOW_DURATION_MS)) > dayStart && w.start < dayEnd }
if (windows.isNotEmpty()) {
val kept = keptMinutesByWindows(ts, minutes, windows)
if (kept != null) return kept
}
if (minutes <= 0) return null
val night = bedtimeWindows?.let { nightWindows(it, ts, zone) }.orEmpty()
if (night.isNotEmpty()) {
val kept = keptMinutesByWindows(ts, minutes, night)
if (kept != null) return kept
}
return keptMinutes(ts, minutes, prefs.startMinutes, prefs.endMinutes, zone)
if (scheduleEnabled) return keptMinutes(ts, minutes, startMinutes, endMinutes, zone)
return 0 until minutes
}

/**
* The windows of the waking day [ts] falls in — the span both the import gate and
* [com.pulseloop.service.SleepGapBridge] judge a night by.
*/
fun nightWindows(windows: List<ZenWindow>, ts: Long, zone: ZoneId = ZoneId.systemDefault()): List<ZenWindow> {
val wakingDay = com.pulseloop.util.TimeUtil.wakingDayLocal(ts, zone)
val dayStart = wakingDay - 12 * 3_600_000L
val dayEnd = wakingDay + 36 * 3_600_000L
return windows.filter { w -> effectiveEnd(w) > dayStart && w.start < dayEnd }
}

/** A still-open window counts as running for at most [ZenModeTracker.MAX_WINDOW_DURATION_MS]. */
fun effectiveEnd(w: ZenWindow): Long = w.end ?: (w.start + ZenModeTracker.MAX_WINDOW_DURATION_MS)

/**
* Longest contiguous stretch of minutes falling inside any of [windows].
*/
Expand All @@ -110,10 +192,7 @@ class QuietHoursPrefs(context: Context) {
var runStart = -1
for (i in 0..minutes) {
val minuteTs = ts + i * 60_000L
val inside = i < minutes && windows.any { w ->
val effectiveEnd = w.end ?: (w.start + ZenModeTracker.MAX_WINDOW_DURATION_MS)
minuteTs >= w.start && minuteTs < effectiveEnd
}
val inside = i < minutes && windows.any { w -> minuteTs >= w.start && minuteTs < effectiveEnd(w) }
if (inside && runStart < 0) runStart = i
if (!inside && runStart >= 0) {
val run = runStart until i
Expand Down
Loading
Loading