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
6 changes: 5 additions & 1 deletion android/app/src/main/java/com/noop/data/Entities.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

/**
Expand Down
8 changes: 8 additions & 0 deletions android/app/src/main/java/com/noop/data/WhoopDao.kt
Original file line number Diff line number Diff line change
Expand Up @@ -661,6 +661,14 @@ interface WhoopDao : DeviceRegistryDao {
@Query("SELECT * FROM dismissedSleep WHERE deviceId = :deviceId")
suspend fun dismissedSleeps(deviceId: String): List<DismissedSleep>

/** 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. */
Expand Down
17 changes: 15 additions & 2 deletions android/app/src/main/java/com/noop/data/WhoopDatabase.kt
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
PpgWaveformSampleEntity::class,
RawImuSampleEntity::class,
],
version = 21,
version = 22,
exportSchema = false,
)
abstract class WhoopDatabase : RoomDatabase() {
Expand Down Expand Up @@ -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<String> = 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
Expand All @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions android/app/src/main/java/com/noop/data/WhoopRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -768,6 +774,7 @@ class WhoopRepository(private val dao: WhoopDao) {
suspend fun dismissedSleepsUnion(activeDeviceId: String): List<DismissedSleep> =
(importedSourceIds(activeDeviceId) + computedSourceIds(activeDeviceId))
.flatMap { dao.dismissedSleeps(it) }
.filter { it.managementVisible }
.distinctBy { it.deviceId to it.startTs }
.sortedByDescending { it.endTs }

Expand Down
8 changes: 8 additions & 0 deletions android/app/src/main/java/com/noop/ui/AppViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions android/app/src/main/java/com/noop/ui/SleepScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -720,6 +739,7 @@ private fun SleepUndoBanner(session: SleepSession, onUndo: () -> Unit) {
private fun DeletedSleepWindowsCard(
windows: List<DismissedSleep>,
recomputing: Pair<String, Long>?,
onHide: (DismissedSleep) -> Unit,
onRecompute: (DismissedSleep) -> Unit,
) {
val dateFmt = remember { SimpleDateFormat("MMM d, HH:mm", Locale.getDefault()) }
Expand Down Expand Up @@ -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) },
Expand Down
4 changes: 4 additions & 0 deletions android/app/src/main/res/values-de/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1528,7 +1528,11 @@
<string name="l10n_sleep_screen_delete_this_sleep_session_c347b909">Diese Schlafsitzung löschen?</string>
<string name="l10n_sleep_screen_deleted_sleep_window_range_7bc5f027">%1$s – %2$s</string>
<string name="l10n_sleep_screen_deleted_sleep_windows_46fea77a">Gelöschte Schlafzeiträume</string>
<string name="l10n_sleep_screen_deleted_sleep_window_hidden_5c848a32">Gelöschtes Schlaffenster ausgeblendet. Es bleibt gelöscht.</string>
<string name="l10n_sleep_screen_good_morning_33e88869">Guten Morgen!</string>
<string name="l10n_sleep_screen_couldn_t_hide_this_deleted_sleep_bbedac55">Dieses gelöschte Schlaffenster konnte nicht ausgeblendet werden. Versuche es erneut.</string>
<string name="l10n_sleep_screen_hide_7aee4b04">Ausblenden</string>
<string name="l10n_sleep_screen_hide_this_deleted_sleep_window_c349003f">Dieses gelöschte Schlaffenster ausblenden</string>
<string name="l10n_sleep_screen_couldn_t_reopen_this_night_try_88265690">Diese Nacht konnte nicht wieder geöffnet werden. Versuche es erneut.</string>
<string name="l10n_sleep_screen_if_this_sleep_came_only_from_d0892088">Wenn dieser Schlaf nur aus einem Import stammt und keine Rohdaten gespeichert sind, wird er möglicherweise nicht wieder angezeigt.</string>
<string name="l10n_sleep_screen_maybe_later_27ad1d83">Vielleicht später</string>
Expand Down
4 changes: 4 additions & 0 deletions android/app/src/main/res/values-es/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1368,7 +1368,11 @@
<string name="l10n_sleep_screen_delete_this_sleep_session_c347b909">¿Eliminar esta sesión de sueño?</string>
<string name="l10n_sleep_screen_deleted_sleep_window_range_7bc5f027">%1$s – %2$s</string>
<string name="l10n_sleep_screen_deleted_sleep_windows_46fea77a">Periodos de sueño eliminados</string>
<string name="l10n_sleep_screen_deleted_sleep_window_hidden_5c848a32">Periodo de sueño eliminado ocultado. Seguirá eliminado.</string>
<string name="l10n_sleep_screen_good_morning_33e88869">¡Buenos días!</string>
<string name="l10n_sleep_screen_couldn_t_hide_this_deleted_sleep_bbedac55">No se pudo ocultar este periodo de sueño eliminado. Inténtalo de nuevo.</string>
<string name="l10n_sleep_screen_hide_7aee4b04">Ocultar</string>
<string name="l10n_sleep_screen_hide_this_deleted_sleep_window_c349003f">Ocultar este periodo de sueño eliminado</string>
<string name="l10n_sleep_screen_couldn_t_reopen_this_night_try_88265690">No se pudo volver a abrir esta noche. Inténtalo de nuevo.</string>
<string name="l10n_sleep_screen_if_this_sleep_came_only_from_d0892088">Si este sueño procede únicamente de una importación y no hay muestras sin procesar guardadas, es posible que no vuelva a aparecer.</string>
<string name="l10n_sleep_screen_maybe_later_27ad1d83">Quizás más tarde.</string>
Expand Down
4 changes: 4 additions & 0 deletions android/app/src/main/res/values-fr/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1368,7 +1368,11 @@
<string name="l10n_sleep_screen_delete_this_sleep_session_c347b909">Supprimer cette session de sommeil ?</string>
<string name="l10n_sleep_screen_deleted_sleep_window_range_7bc5f027">%1$s – %2$s</string>
<string name="l10n_sleep_screen_deleted_sleep_windows_46fea77a">Périodes de sommeil supprimées</string>
<string name="l10n_sleep_screen_deleted_sleep_window_hidden_5c848a32">Période de sommeil supprimée masquée. Elle restera supprimée.</string>
<string name="l10n_sleep_screen_good_morning_33e88869">Bonjour !</string>
<string name="l10n_sleep_screen_couldn_t_hide_this_deleted_sleep_bbedac55">Impossible de masquer cette période de sommeil supprimée. Réessayez.</string>
<string name="l10n_sleep_screen_hide_7aee4b04">Masquer</string>
<string name="l10n_sleep_screen_hide_this_deleted_sleep_window_c349003f">Masquer cette période de sommeil supprimée</string>
<string name="l10n_sleep_screen_couldn_t_reopen_this_night_try_88265690">Impossible de rouvrir cette nuit. Réessayez.</string>
<string name="l10n_sleep_screen_if_this_sleep_came_only_from_d0892088">Si ce sommeil provient uniquement d’un import et qu’aucun échantillon brut n’est enregistré, il peut ne pas réapparaître.</string>
<string name="l10n_sleep_screen_maybe_later_27ad1d83">Peut-être plus tard</string>
Expand Down
4 changes: 4 additions & 0 deletions android/app/src/main/res/values-zh/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1493,7 +1493,11 @@
<string name="l10n_sleep_screen_delete_this_sleep_session_c347b909">要删除这条睡眠记录吗?</string>
<string name="l10n_sleep_screen_deleted_sleep_window_range_7bc5f027">%1$s – %2$s</string>
<string name="l10n_sleep_screen_deleted_sleep_windows_46fea77a">已删除的睡眠时间段</string>
<string name="l10n_sleep_screen_deleted_sleep_window_hidden_5c848a32">已隐藏已删除的睡眠时段。它仍会保持删除状态。</string>
<string name="l10n_sleep_screen_good_morning_33e88869">早上好!</string>
<string name="l10n_sleep_screen_couldn_t_hide_this_deleted_sleep_bbedac55">无法隐藏这个已删除的睡眠时段。请重试。</string>
<string name="l10n_sleep_screen_hide_7aee4b04">隐藏</string>
<string name="l10n_sleep_screen_hide_this_deleted_sleep_window_c349003f">隐藏这个已删除的睡眠时段</string>
<string name="l10n_sleep_screen_couldn_t_reopen_this_night_try_88265690">无法恢复该晚的睡眠数据,请重试。</string>
<string name="l10n_sleep_screen_if_this_sleep_came_only_from_d0892088">如果这段睡眠仅仅来源于外部导入,且手机内没有留存原始心率样本,它被删除后可能就无法恢复了。</string>
<string name="l10n_sleep_screen_maybe_later_27ad1d83">以后再说</string>
Expand Down
4 changes: 4 additions & 0 deletions android/app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1537,7 +1537,11 @@
<string name="l10n_sleep_screen_delete_this_sleep_session_c347b909">Delete this sleep session?</string>
<string name="l10n_sleep_screen_deleted_sleep_window_range_7bc5f027">%1$s – %2$s</string>
<string name="l10n_sleep_screen_deleted_sleep_windows_46fea77a">Deleted sleep windows</string>
<string name="l10n_sleep_screen_deleted_sleep_window_hidden_5c848a32">Deleted sleep window hidden. It will stay deleted.</string>
<string name="l10n_sleep_screen_good_morning_33e88869">Good morning!</string>
<string name="l10n_sleep_screen_couldn_t_hide_this_deleted_sleep_bbedac55">Couldn\'t hide this deleted sleep window. Try again.</string>
<string name="l10n_sleep_screen_hide_7aee4b04">Hide</string>
<string name="l10n_sleep_screen_hide_this_deleted_sleep_window_c349003f">Hide this deleted sleep window</string>
<string name="l10n_sleep_screen_couldn_t_reopen_this_night_try_88265690">Couldn\'t reopen this night. Try again.</string>
<string name="l10n_sleep_screen_if_this_sleep_came_only_from_d0892088">If this sleep came only from an import and no raw samples are stored, it may not reappear.</string>
<string name="l10n_sleep_screen_maybe_later_27ad1d83">Maybe later</string>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, Long>? = 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<out Any?>) -> Any?): WhoopDao =
Proxy.newProxyInstance(
WhoopDao::class.java.classLoader,
arrayOf(WhoopDao::class.java),
) { _, method, args -> call(method.name, args ?: emptyArray()) } as WhoopDao
}
Loading