From 44ad3e6e775deab5f1e6a5bf0e52799978b470c0 Mon Sep 17 00:00:00 2001 From: kavemang Date: Sun, 27 Sep 2026 14:27:00 -0500 Subject: [PATCH] android: defer morning recap until recorded wake Keep the early-morning fallback for score-only nights and recheck deferred recaps from WorkManager when no new data arrives. Reuse the existing per-night notification marker across the UI and worker. Refs #2289 --- .../src/main/java/com/noop/NoopApplication.kt | 1 + .../com/noop/notif/MorningRecapRefresh.kt | 53 ++++++++++++ .../com/noop/notif/ScheduledReportNotifier.kt | 86 +++++++++++-------- .../src/main/java/com/noop/ui/AppViewModel.kt | 2 + .../noop/ui/NotificationsSettingsScreen.kt | 1 + .../noop/notif/ScheduledReportPolicyTest.kt | 22 +++++ 6 files changed, 128 insertions(+), 37 deletions(-) create mode 100644 android/app/src/main/java/com/noop/notif/MorningRecapRefresh.kt diff --git a/android/app/src/main/java/com/noop/NoopApplication.kt b/android/app/src/main/java/com/noop/NoopApplication.kt index 11706b062c..869d0c86fa 100644 --- a/android/app/src/main/java/com/noop/NoopApplication.kt +++ b/android/app/src/main/java/com/noop/NoopApplication.kt @@ -59,6 +59,7 @@ class NoopApplication : Application() { // case it exists for is a strap that stopped talking. Scheduled here rather than beside a widget // so it does not inherit that widget's lifecycle. KEEP, so this is a no-op once scheduled. com.noop.notif.StaleBatteryWorker.ensureScheduled(this) + com.noop.notif.MorningRecapRefresh.reschedule(this) } /** Process-wide Room-backed store. One instance shared by the UI and the background service. */ diff --git a/android/app/src/main/java/com/noop/notif/MorningRecapRefresh.kt b/android/app/src/main/java/com/noop/notif/MorningRecapRefresh.kt new file mode 100644 index 0000000000..6b5da472d8 --- /dev/null +++ b/android/app/src/main/java/com/noop/notif/MorningRecapRefresh.kt @@ -0,0 +1,53 @@ +package com.noop.notif + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.noop.NoopApplication +import com.noop.analytics.RestScorer +import com.noop.ui.NoopPrefs +import com.noop.ui.logicalDayKeyNow +import com.noop.ui.resolveTodayRow +import kotlinx.coroutines.flow.first +import java.time.LocalDate +import java.util.concurrent.TimeUnit + +/** Recheck a deferred recap without depending on a database write or an open Today screen. */ +object MorningRecapRefresh { + private const val WORK = "noop.morningRecapRefresh" + + fun reschedule(context: Context) { + runCatching { + val manager = WorkManager.getInstance(context.applicationContext) + if (!NoopPrefs.morningReportEnabled(context)) { + manager.cancelUniqueWork(WORK) + } else { + val request = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES).build() + manager.enqueueUniquePeriodicWork(WORK, ExistingPeriodicWorkPolicy.KEEP, request) + } + } + } +} + +class MorningRecapRefreshWorker(context: Context, params: WorkerParameters) : + CoroutineWorker(context, params) { + override suspend fun doWork(): Result { + if (!NoopPrefs.morningReportEnabled(applicationContext)) return Result.success() + val app = applicationContext as? NoopApplication ?: return Result.success() + return runCatching { + val deviceId = app.sourceCoordinator.activeDeviceId.value ?: app.activeDeviceId + val days = app.repository.recentDaysMergedFlow(deviceId).first() + val row = resolveTodayRow(days, logicalDayKeyNow(), LocalDate.now().toString()) + if (row?.totalSleepMin != null) { + ScheduledReportNotifier.onMorning( + applicationContext, row.day, row.recovery.scorePctOrNull(), + RestScorer.restFromDaily(row).scorePctOrNull(), app.repository, deviceId, + ) + } + Result.success() + }.getOrElse { Result.success() } + } +} diff --git a/android/app/src/main/java/com/noop/notif/ScheduledReportNotifier.kt b/android/app/src/main/java/com/noop/notif/ScheduledReportNotifier.kt index a8d92a6b7e..cb10222841 100644 --- a/android/app/src/main/java/com/noop/notif/ScheduledReportNotifier.kt +++ b/android/app/src/main/java/com/noop/notif/ScheduledReportNotifier.kt @@ -9,8 +9,14 @@ import android.os.Build import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import com.noop.R +import com.noop.data.WhoopRepository import com.noop.ui.NoopPrefs +import com.noop.ui.mainSleepGroup import com.noop.ui.appLaunchIntent +import java.time.Instant +import java.time.ZoneId +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlin.math.roundToInt // MARK: - Scheduled report notifications (#517) @@ -45,6 +51,7 @@ object ScheduledReportPolicy { * recap for the night they slept, rather than silence. */ const val EARLIEST_MORNING_MINUTE = 5 * 60 + const val WAKE_SETTLE_SECONDS = 30 * 60L /** Fire the morning recap at most once per REPORTED NIGHT: only when enabled, a recap value exists, and * we haven't already posted for [reportDay]. [reportDay] is the day of the banked night the recap is @@ -52,31 +59,23 @@ object ScheduledReportPolicy { * it re-fire at midnight for anyone up late, since the row still resolves to last night's until a new * night is banked (#567). * - * [nowMinuteOfDay] holds the recap until [earliestMinuteOfDay]. A suppressed recap posts on the next - * evaluation of this gate, and the once-per-night key is untouched while it waits, so deferring cannot - * produce a second copy. - * - * What deferring CAN do is lose one, and the bound is worth knowing rather than discovering. This gate - * runs from `AppViewModel`'s `recentDays` collector, a Room-backed StateFlow with - * `WhileSubscribed(5_000)`: it re-evaluates on a database write or on re-subscription, NOT on a clock. - * A connected strap writes rows through the night, and backgrounding the app drops the subscription so - * reopening replays, which covers the ordinary paths. But a process that stays alive from 00:40 past - * the floor with no write in between never re-evaluates, and that day's recap does not arrive at all, - * where before it would have arrived at 00:40 to someone asleep. - * - * That is the second cost of a floor, beside a late recap still calling itself "Good morning". Both - * are why this is a stopgap and the wake-based version is the real answer (#2289). */ + * [wakeEndTs] is the recorded end of the main sleep group for [reportDay]. Wait until it has ended + * and settled for [WAKE_SETTLE_SECONDS]; a banked partial night must not immediately announce a + * morning. A missing session keeps the 05:00 fallback for score-only imports. */ fun shouldNotifyMorning( enabled: Boolean, chargeOrRestPresent: Boolean, lastNotifiedDay: String?, reportDay: String, nowMinuteOfDay: Int, + nowEpochSec: Long = Long.MAX_VALUE, + wakeEndTs: Long? = null, earliestMinuteOfDay: Int = EARLIEST_MORNING_MINUTE, ): Boolean = enabled && chargeOrRestPresent && lastNotifiedDay != reportDay && - nowMinuteOfDay >= earliestMinuteOfDay + nowMinuteOfDay >= earliestMinuteOfDay && + (wakeEndTs == null || nowEpochSec >= wakeEndTs + WAKE_SETTLE_SECONDS) /** Fire the post-workout summary only for a workout STRICTLY newer than the last one summarised, so a * re-sync of the same backlog never re-notifies. [lastWorkoutTs] is 0 before the first ever. */ @@ -130,6 +129,7 @@ object ScheduledReportPolicy { } object ScheduledReportNotifier { + private val morningMutex = Mutex() private const val CHANNEL_ID = "noop_scheduled_reports" // #297: distinct ids so a report never silently replaces another notifier's (tagless notify()). // Map: 4201 connection, 4202 illness, 4203 inactivity, 4204 smart alarm, 4205/4206/4207 battery. @@ -142,28 +142,40 @@ object ScheduledReportNotifier { * policy, so the caller can fire it freely each time the days collector republishes. */ @SuppressLint("MissingPermission") // guarded by areNotificationsEnabled() + runCatching - fun onMorning(context: Context, reportDay: String, chargePct: Int?, restPct: Int?) { - // reportDay is the banked night's day (the resolved today-row's `day`), NOT LocalDate.now() — the - // calendar day rolls at midnight while the row still resolves to last night's until a new night is - // banked, which re-fired the recap at the start of a new day for late-nighters (#567). - if (!ScheduledReportPolicy.shouldNotifyMorning( - enabled = NoopPrefs.morningReportEnabled(context), - chargeOrRestPresent = chargePct != null || restPct != null, - lastNotifiedDay = NoopPrefs.reportMorningDay(context), - reportDay = reportDay, - // The clock the floor compares against. Read here rather than inside the policy, so the policy - // stays pure and the test can pin 00:40 and 07:00 without a fake clock. - nowMinuteOfDay = java.time.LocalTime.now().let { it.hour * 60 + it.minute }, - ) - ) return - val copy = ScheduledReportPolicy.morningCopy(chargePct, restPct) ?: return - runCatching { - if (!NotificationManagerCompat.from(context).areNotificationsEnabled()) return - ensureChannel(context) - post(context, MORNING_NOTIF_ID, copy.first, copy.second) - // Mark fired only after a successful post, so a notifications-disabled night still notifies - // once they're re-enabled while the same night's row is showing. - NoopPrefs.setReportMorningDay(context, reportDay) + suspend fun onMorning( + context: Context, reportDay: String, chargePct: Int?, restPct: Int?, + repository: WhoopRepository, deviceId: String, + ) { + if (!NoopPrefs.morningReportEnabled(context) || NoopPrefs.reportMorningDay(context) == reportDay || + (chargePct == null && restPct == null)) return + val zone = ZoneId.systemDefault() + val now = System.currentTimeMillis() + val nowMinuteOfDay = Instant.ofEpochMilli(now).atZone(zone).let { it.hour * 60 + it.minute } + if (nowMinuteOfDay < ScheduledReportPolicy.EARLIEST_MORNING_MINUTE) return + val wakeEndTs = repository.allSleepSessionsUnion(deviceId, days = 2) + .filter { Instant.ofEpochSecond(it.endTs).atZone(zone).toLocalDate().toString() == reportDay } + .let { mainSleepGroup(it).maxOfOrNull { block -> block.endTs } } + morningMutex.withLock { + // reportDay is the banked night's day, not the calendar day; the persisted marker prevents + // a late-night republish from posting the same night twice (#567). + if (!ScheduledReportPolicy.shouldNotifyMorning( + enabled = NoopPrefs.morningReportEnabled(context), + chargeOrRestPresent = chargePct != null || restPct != null, + lastNotifiedDay = NoopPrefs.reportMorningDay(context), + reportDay = reportDay, + nowMinuteOfDay = nowMinuteOfDay, + nowEpochSec = now / 1000, + wakeEndTs = wakeEndTs, + ) + ) return@withLock + val copy = ScheduledReportPolicy.morningCopy(chargePct, restPct) ?: return@withLock + runCatching { + if (!NotificationManagerCompat.from(context).areNotificationsEnabled()) return@runCatching + ensureChannel(context) + post(context, MORNING_NOTIF_ID, copy.first, copy.second) + // Mark only after a successful post; both the UI and the worker share this lock. + NoopPrefs.setReportMorningDay(context, reportDay) + } } } diff --git a/android/app/src/main/java/com/noop/ui/AppViewModel.kt b/android/app/src/main/java/com/noop/ui/AppViewModel.kt index ef3792174f..64aca936cd 100644 --- a/android/app/src/main/java/com/noop/ui/AppViewModel.kt +++ b/android/app/src/main/java/com/noop/ui/AppViewModel.kt @@ -1026,6 +1026,8 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { reportDay = todayRow.day, chargePct = todayRow.recovery.scorePctOrNull(), restPct = RestScorer.restFromDaily(todayRow).scorePctOrNull(), + repository = repository, + deviceId = activeStrapId, ) } // #593: once-a-day optimal-strain-reached nudge. Convert the stored 0-100 Effort to the diff --git a/android/app/src/main/java/com/noop/ui/NotificationsSettingsScreen.kt b/android/app/src/main/java/com/noop/ui/NotificationsSettingsScreen.kt index 01c47370ef..bc7ccfdb3a 100644 --- a/android/app/src/main/java/com/noop/ui/NotificationsSettingsScreen.kt +++ b/android/app/src/main/java/com/noop/ui/NotificationsSettingsScreen.kt @@ -491,6 +491,7 @@ fun NotificationsSettingsScreen(vm: AppViewModel) { onChange = { morningReport = it NoopPrefs.setMorningReportEnabled(context, it) + com.noop.notif.MorningRecapRefresh.reschedule(context) }, ) RowDivider() diff --git a/android/app/src/test/java/com/noop/notif/ScheduledReportPolicyTest.kt b/android/app/src/test/java/com/noop/notif/ScheduledReportPolicyTest.kt index 2fa81b5425..11b78ec108 100644 --- a/android/app/src/test/java/com/noop/notif/ScheduledReportPolicyTest.kt +++ b/android/app/src/test/java/com/noop/notif/ScheduledReportPolicyTest.kt @@ -49,6 +49,28 @@ class ScheduledReportPolicyTest { ) } + @Test fun scoredNightWaitsForRecordedWakeToSettle() { + val wake = 1_800_000_000L + assertFalse(ScheduledReportPolicy.shouldNotifyMorning( + enabled = true, chargeOrRestPresent = true, lastNotifiedDay = null, + reportDay = "2026-06-21", nowMinuteOfDay = 7 * 60, + nowEpochSec = wake + ScheduledReportPolicy.WAKE_SETTLE_SECONDS - 1, wakeEndTs = wake, + )) + assertTrue(ScheduledReportPolicy.shouldNotifyMorning( + enabled = true, chargeOrRestPresent = true, lastNotifiedDay = null, + reportDay = "2026-06-21", nowMinuteOfDay = 7 * 60, + nowEpochSec = wake + ScheduledReportPolicy.WAKE_SETTLE_SECONDS, wakeEndTs = wake, + )) + } + + @Test fun scoreOnlyNightUsesMorningFloorWithoutARecordedWake() { + assertTrue(ScheduledReportPolicy.shouldNotifyMorning( + enabled = true, chargeOrRestPresent = true, lastNotifiedDay = null, + reportDay = "2026-06-21", nowMinuteOfDay = 7 * 60, + nowEpochSec = 1_800_000_000L, wakeEndTs = null, + )) + } + @Test fun aLateSyncStillDelivers() { // A floor, not a window: someone who has not synced all day still gets last night's recap when they // finally do, rather than silence because "morning" had passed.