From 1f2f71d655cb81975b852405e188d26f3222adad Mon Sep 17 00:00:00 2001 From: Kaveman <66108693+kavemang@users.noreply.github.com> Date: Mon, 20 Jul 2026 10:37:39 -0500 Subject: [PATCH] Allow hiding stale sleep recompute entries --- .../src/main/java/com/noop/data/Entities.kt | 6 +- .../src/main/java/com/noop/data/WhoopDao.kt | 8 ++ .../main/java/com/noop/data/WhoopDatabase.kt | 17 +++- .../java/com/noop/data/WhoopRepository.kt | 7 ++ .../src/main/java/com/noop/ui/AppViewModel.kt | 8 ++ .../src/main/java/com/noop/ui/SleepScreen.kt | 35 +++++++++ .../app/src/main/res/values-de/strings.xml | 4 + .../app/src/main/res/values-es/strings.xml | 4 + .../app/src/main/res/values-fr/strings.xml | 4 + .../app/src/main/res/values-zh/strings.xml | 4 + android/app/src/main/res/values/strings.xml | 4 + .../DismissedSleepVisibilityMigrationTest.kt | 77 +++++++++++++++++++ 12 files changed, 175 insertions(+), 3 deletions(-) create mode 100644 android/app/src/test/java/com/noop/data/DismissedSleepVisibilityMigrationTest.kt diff --git a/android/app/src/main/java/com/noop/data/Entities.kt b/android/app/src/main/java/com/noop/data/Entities.kt index 528171184f..9ad6ff359d 100644 --- a/android/app/src/main/java/com/noop/data/Entities.kt +++ b/android/app/src/main/java/com/noop/data/Entities.kt @@ -452,13 +452,17 @@ data class DismissedWorkout( * re-derived by the recompute, mirroring [DismissedWorkout] (#107). PK (deviceId, startTs), keyed on * the deleted session's start; `endTs` is the span the recompute's overlap test uses (a re-detected * onset can drift second-to-second). iOS has the twin sleep-delete path since #68 (its tombstones live in - * UserDefaults, not a table); the undo lifts a tombstone by (deviceId, startTs) (#65). Added by MIGRATION_9_10. + * UserDefaults, not a table); the undo lifts a tombstone by (deviceId, startTs) (#65). + * [managementVisible] controls only whether the Android Sleep screen offers this marker for + * recomputation. Hiding that row must never weaken the tombstone's suppression of re-detection (#515). + * Added by MIGRATION_9_10; managementVisible by MIGRATION_21_22. */ @Entity(tableName = "dismissedSleep", primaryKeys = ["deviceId", "startTs"]) data class DismissedSleep( val deviceId: String, val startTs: Long, val endTs: Long, + val managementVisible: Boolean = true, ) /** diff --git a/android/app/src/main/java/com/noop/data/WhoopDao.kt b/android/app/src/main/java/com/noop/data/WhoopDao.kt index db7e0e751b..d11fe1e7f8 100644 --- a/android/app/src/main/java/com/noop/data/WhoopDao.kt +++ b/android/app/src/main/java/com/noop/data/WhoopDao.kt @@ -661,6 +661,14 @@ interface WhoopDao : DeviceRegistryDao { @Query("SELECT * FROM dismissedSleep WHERE deviceId = :deviceId") suspend fun dismissedSleeps(deviceId: String): List + /** Hide one tombstone from the Sleep screen's recompute list while leaving the row in place so the + * detector continues to suppress the sleep the user deliberately deleted (#515). */ + @Query( + "UPDATE dismissedSleep SET managementVisible = 0 " + + "WHERE deviceId = :deviceId AND startTs = :startTs", + ) + suspend fun hideDismissedSleepFromManagement(deviceId: String, startTs: Long): Int + /** Lift ONE deleted-sleep tombstone (#65 undo / "allow re-detection"): removes the marker so the * night is re-detected from raw on the next analyze pass. Keyed by (deviceId, startTs): the same * natural key the insert uses, so it removes exactly the tombstone [deleteSleepSession] wrote. */ diff --git a/android/app/src/main/java/com/noop/data/WhoopDatabase.kt b/android/app/src/main/java/com/noop/data/WhoopDatabase.kt index d44365740f..b013e28d70 100644 --- a/android/app/src/main/java/com/noop/data/WhoopDatabase.kt +++ b/android/app/src/main/java/com/noop/data/WhoopDatabase.kt @@ -50,7 +50,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase PpgWaveformSampleEntity::class, RawImuSampleEntity::class, ], - version = 21, + version = 22, exportSchema = false, ) abstract class WhoopDatabase : RoomDatabase() { @@ -568,6 +568,19 @@ abstract class WhoopDatabase : RoomDatabase() { } } + /** #515 follow-up: keep deleted-sleep suppression independent from the Android Sleep screen's + * recompute list. Existing markers remain visible after upgrade; hiding one only flips this + * additive flag and never removes the tombstone that protects against re-detection. */ + internal val DISMISSED_SLEEP_VISIBILITY_MIGRATION_SQL: List = listOf( + "ALTER TABLE `dismissedSleep` ADD COLUMN `managementVisible` INTEGER NOT NULL DEFAULT 1", + ) + + internal val MIGRATION_21_22 = object : Migration(21, 22) { + override fun migrate(db: SupportSQLiteDatabase) { + for (stmt in DISMISSED_SLEEP_VISIBILITY_MIGRATION_SQL) db.execSQL(stmt) + } + } + private fun build(appContext: Context): WhoopDatabase = Room.databaseBuilder(appContext, WhoopDatabase::class.java, DB_NAME) // #1014: replace ONLY the corruption handling of the default open-helper. The @@ -583,7 +596,7 @@ abstract class WhoopDatabase : RoomDatabase() { MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16, MIGRATION_16_17, MIGRATION_17_18, - MIGRATION_18_19, MIGRATION_19_20, MIGRATION_20_21, + MIGRATION_18_19, MIGRATION_19_20, MIGRATION_20_21, MIGRATION_21_22, ) // #1037: a FRESH install builds the schema straight at the current version and runs NO // migrations, so the MIGRATION_7_8 "my-whoop" registry seed never fires and the WHOOP, diff --git a/android/app/src/main/java/com/noop/data/WhoopRepository.kt b/android/app/src/main/java/com/noop/data/WhoopRepository.kt index 2ab4ce423d..75974954fe 100644 --- a/android/app/src/main/java/com/noop/data/WhoopRepository.kt +++ b/android/app/src/main/java/com/noop/data/WhoopRepository.kt @@ -372,6 +372,12 @@ class WhoopRepository(private val dao: WhoopDao) { suspend fun allowSleepReDetection(deviceId: String, startTs: Long) = dao.deleteDismissedSleep(deviceId, startTs) + /** Remove one deletion marker from the Sleep screen's management list without deleting the marker + * itself. The detector-facing [dismissedSleeps] read intentionally still returns hidden markers, so + * a mistaken sleep stays deleted after its now-irrelevant recompute row is dismissed (#515). */ + suspend fun hideDeletedSleepWindow(deviceId: String, startTs: Long): Boolean = + dao.hideDismissedSleepFromManagement(deviceId, startTs) > 0 + /** #899 dedup heal: remove ONE sleep-session row WITHOUT the #33 dismissal tombstone. The heal * deletes stale timebase-shifted duplicates of a night whose canonical copy is STAYING; a tombstone * here would overlap the surviving night's window and permanently suppress its re-detection. Only @@ -768,6 +774,7 @@ class WhoopRepository(private val dao: WhoopDao) { suspend fun dismissedSleepsUnion(activeDeviceId: String): List = (importedSourceIds(activeDeviceId) + computedSourceIds(activeDeviceId)) .flatMap { dao.dismissedSleeps(it) } + .filter { it.managementVisible } .distinctBy { it.deviceId to it.startTs } .sortedByDescending { it.endTs } 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 6c92e92cd1..18b9ab2af0 100644 --- a/android/app/src/main/java/com/noop/ui/AppViewModel.kt +++ b/android/app/src/main/java/com/noop/ui/AppViewModel.kt @@ -1358,6 +1358,14 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { return true } + /** Hide an unwanted deleted-sleep row from the persistent recompute card while preserving its + * detector tombstone. This is deliberately separate from [recomputeDeletedSleep], which removes the + * tombstone and can therefore allow that mistaken sleep to return (#515). */ + suspend fun hideDeletedSleepWindow(marker: com.noop.data.DismissedSleep): Boolean = + runCatching { + repository.hideDeletedSleepWindow(marker.deviceId, marker.startTs) + }.getOrDefault(false) + /** Manually add a missed nap as its OWN session (#508) — staged from raw, written under the computed * source with userEdited=true so the recompute guard keeps it and it's never folded into main sleep — * then re-score the affected day immediately so the day's aggregates pick up the new session, matching diff --git a/android/app/src/main/java/com/noop/ui/SleepScreen.kt b/android/app/src/main/java/com/noop/ui/SleepScreen.kt index bbb4c6bf39..5c1d0222eb 100644 --- a/android/app/src/main/java/com/noop/ui/SleepScreen.kt +++ b/android/app/src/main/java/com/noop/ui/SleepScreen.kt @@ -426,6 +426,25 @@ fun SleepScreen( DeletedSleepWindowsCard( windows = dismissedSleeps, recomputing = recomputingSleep, + onHide = { marker -> + scope.launch { + val hidden = vm.hideDeletedSleepWindow(marker) + if (hidden) { + dismissedSleeps = dismissedSleeps.filterNot { + it.deviceId == marker.deviceId && it.startTs == marker.startTs + } + } + Toast.makeText( + context, + if (hidden) { + uiString(R.string.l10n_sleep_screen_deleted_sleep_window_hidden_5c848a32) + } else { + uiString(R.string.l10n_sleep_screen_couldn_t_hide_this_deleted_sleep_bbedac55) + }, + Toast.LENGTH_SHORT, + ).show() + } + }, onRecompute = { marker -> val key = marker.deviceId to marker.startTs recomputingSleep = key @@ -720,6 +739,7 @@ private fun SleepUndoBanner(session: SleepSession, onUndo: () -> Unit) { private fun DeletedSleepWindowsCard( windows: List, recomputing: Pair?, + onHide: (DismissedSleep) -> Unit, onRecompute: (DismissedSleep) -> Unit, ) { val dateFmt = remember { SimpleDateFormat("MMM d, HH:mm", Locale.getDefault()) } @@ -755,6 +775,21 @@ private fun DeletedSleepWindowsCard( color = Palette.textSecondary, modifier = Modifier.weight(1f), ) + TextButton( + enabled = recomputing == null, + onClick = { onHide(marker) }, + modifier = Modifier.semantics { + contentDescription = uiString( + R.string.l10n_sleep_screen_hide_this_deleted_sleep_window_c349003f, + ) + }, + ) { + Text( + uiString(R.string.l10n_sleep_screen_hide_7aee4b04), + style = NoopType.subhead, + color = if (recomputing == null) Palette.textSecondary else Palette.textTertiary, + ) + } TextButton( enabled = recomputing == null, onClick = { onRecompute(marker) }, diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index 8ee14f900f..d83b13b8bb 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -1528,7 +1528,11 @@ Diese Schlafsitzung löschen? %1$s – %2$s Gelöschte Schlafzeiträume + Gelöschtes Schlaffenster ausgeblendet. Es bleibt gelöscht. Guten Morgen! + Dieses gelöschte Schlaffenster konnte nicht ausgeblendet werden. Versuche es erneut. + Ausblenden + Dieses gelöschte Schlaffenster ausblenden Diese Nacht konnte nicht wieder geöffnet werden. Versuche es erneut. Wenn dieser Schlaf nur aus einem Import stammt und keine Rohdaten gespeichert sind, wird er möglicherweise nicht wieder angezeigt. Vielleicht später diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml index 7ba7100a60..56acf559bb 100644 --- a/android/app/src/main/res/values-es/strings.xml +++ b/android/app/src/main/res/values-es/strings.xml @@ -1368,7 +1368,11 @@ ¿Eliminar esta sesión de sueño? %1$s – %2$s Periodos de sueño eliminados + Periodo de sueño eliminado ocultado. Seguirá eliminado. ¡Buenos días! + No se pudo ocultar este periodo de sueño eliminado. Inténtalo de nuevo. + Ocultar + Ocultar este periodo de sueño eliminado No se pudo volver a abrir esta noche. Inténtalo de nuevo. Si este sueño procede únicamente de una importación y no hay muestras sin procesar guardadas, es posible que no vuelva a aparecer. Quizás más tarde. diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml index 8aed5f15bc..a4077802fc 100644 --- a/android/app/src/main/res/values-fr/strings.xml +++ b/android/app/src/main/res/values-fr/strings.xml @@ -1368,7 +1368,11 @@ Supprimer cette session de sommeil ? %1$s – %2$s Périodes de sommeil supprimées + Période de sommeil supprimée masquée. Elle restera supprimée. Bonjour ! + Impossible de masquer cette période de sommeil supprimée. Réessayez. + Masquer + Masquer cette période de sommeil supprimée Impossible de rouvrir cette nuit. Réessayez. Si ce sommeil provient uniquement d’un import et qu’aucun échantillon brut n’est enregistré, il peut ne pas réapparaître. Peut-être plus tard diff --git a/android/app/src/main/res/values-zh/strings.xml b/android/app/src/main/res/values-zh/strings.xml index 85ae492d95..a818a53a41 100644 --- a/android/app/src/main/res/values-zh/strings.xml +++ b/android/app/src/main/res/values-zh/strings.xml @@ -1493,7 +1493,11 @@ 要删除这条睡眠记录吗? %1$s – %2$s 已删除的睡眠时间段 + 已隐藏已删除的睡眠时段。它仍会保持删除状态。 早上好! + 无法隐藏这个已删除的睡眠时段。请重试。 + 隐藏 + 隐藏这个已删除的睡眠时段 无法恢复该晚的睡眠数据,请重试。 如果这段睡眠仅仅来源于外部导入,且手机内没有留存原始心率样本,它被删除后可能就无法恢复了。 以后再说 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 4b7e107556..293bbe97d0 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1537,7 +1537,11 @@ Delete this sleep session? %1$s – %2$s Deleted sleep windows + Deleted sleep window hidden. It will stay deleted. Good morning! + Couldn\'t hide this deleted sleep window. Try again. + Hide + Hide this deleted sleep window Couldn\'t reopen this night. Try again. If this sleep came only from an import and no raw samples are stored, it may not reappear. Maybe later diff --git a/android/app/src/test/java/com/noop/data/DismissedSleepVisibilityMigrationTest.kt b/android/app/src/test/java/com/noop/data/DismissedSleepVisibilityMigrationTest.kt new file mode 100644 index 0000000000..6e13c79fa1 --- /dev/null +++ b/android/app/src/test/java/com/noop/data/DismissedSleepVisibilityMigrationTest.kt @@ -0,0 +1,77 @@ +package com.noop.data + +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.lang.reflect.Proxy + +/** Guards the additive #515 follow-up that lets the Sleep screen hide stale recompute rows without + * deleting the tombstones that keep mistaken sleep from being detected again. */ +class DismissedSleepVisibilityMigrationTest { + + @Test + fun migrationAddsVisibleFlagWithoutTouchingRows() { + val sql = WhoopDatabase.DISMISSED_SLEEP_VISIBILITY_MIGRATION_SQL + assertEquals( + listOf( + "ALTER TABLE `dismissedSleep` ADD COLUMN `managementVisible` INTEGER NOT NULL DEFAULT 1", + ), + sql, + ) + for (statement in sql) { + val upper = statement.uppercase() + assertTrue(upper.startsWith("ALTER TABLE") && upper.contains("ADD COLUMN")) + for (banned in listOf("DROP ", "DELETE ", "UPDATE ", "INSERT ")) { + assertFalse("migration must not contain $banned", upper.contains(banned)) + } + } + assertEquals(21, WhoopDatabase.MIGRATION_21_22.startVersion) + assertEquals(22, WhoopDatabase.MIGRATION_21_22.endVersion) + } + + @Test + fun newTombstonesRemainVisibleByDefault() { + assertTrue(DismissedSleep("my-whoop-noop", 100L, 200L).managementVisible) + } + + @Test + fun hiddenMarkerStillReachesDetectorButNotManagementList() = runBlocking { + val hidden = DismissedSleep("my-whoop", 100L, 200L, managementVisible = false) + val shown = DismissedSleep("my-whoop-noop", 300L, 400L) + val dao = proxyDao { method, _ -> + when (method) { + "dismissedSleeps" -> listOf(hidden, shown) + else -> throw UnsupportedOperationException(method) + } + } + val repo = WhoopRepository(dao) + + assertTrue("detector-facing read must retain hidden tombstones", repo.dismissedSleeps().contains(hidden)) + assertEquals(listOf(shown), repo.dismissedSleepsUnion("my-whoop")) + } + + @Test + fun hideUpdatesOnlyTheSelectedTombstone() = runBlocking { + var key: Pair? = null + val dao = proxyDao { method, args -> + when (method) { + "hideDismissedSleepFromManagement" -> { + key = args[0] as String to args[1] as Long + 1 + } + else -> throw UnsupportedOperationException(method) + } + } + + assertTrue(WhoopRepository(dao).hideDeletedSleepWindow("strap-noop", 123L)) + assertEquals("strap-noop" to 123L, key) + } + + private fun proxyDao(call: (String, Array) -> Any?): WhoopDao = + Proxy.newProxyInstance( + WhoopDao::class.java.classLoader, + arrayOf(WhoopDao::class.java), + ) { _, method, args -> call(method.name, args ?: emptyArray()) } as WhoopDao +}