diff --git a/android/app/src/main/java/com/noop/ui/StressCoreRefresh.kt b/android/app/src/main/java/com/noop/ui/StressCoreRefresh.kt new file mode 100644 index 0000000000..5e1b163e8a --- /dev/null +++ b/android/app/src/main/java/com/noop/ui/StressCoreRefresh.kt @@ -0,0 +1,13 @@ +package com.noop.ui + +/** Memoises successful union reads; failed reads remain eligible on the next refresh tick. */ +internal class StressCoreRefresh { + private var lastHrFingerprint: String? = null + + suspend fun loadIfChanged(fingerprint: String?, load: suspend () -> T): T? { + if (fingerprint != null && fingerprint == lastHrFingerprint) return null + val core = StressLoadCancellation.read(load) ?: return null + lastHrFingerprint = fingerprint + return core + } +} diff --git a/android/app/src/main/java/com/noop/ui/StressScreen.kt b/android/app/src/main/java/com/noop/ui/StressScreen.kt index c14620f448..623cded037 100644 --- a/android/app/src/main/java/com/noop/ui/StressScreen.kt +++ b/android/app/src/main/java/com/noop/ui/StressScreen.kt @@ -152,7 +152,7 @@ fun StressScreen(vm: AppViewModel, onBreathe: () -> Unit = {}) { // not: with the strap disconnected, or simply quiet, nothing about today's heart rate has moved // and re-reading produces a result identical to the one already on screen. One indexed count // and max per source id answers that for the price of neither. - var lastHrFingerprint: String? = null + val refresh = StressCoreRefresh() lifecycleOwner.lifecycle.repeatOnLifecycle(androidx.lifecycle.Lifecycle.State.STARTED) { while (true) { val nowSeconds = System.currentTimeMillis() / 1000L @@ -162,19 +162,18 @@ fun StressScreen(vm: AppViewModel, onBreathe: () -> Unit = {}) { } // A failed fingerprint reads as "cannot tell", which loads rather than skips: being // wrong about this costs one pass, being wrong the other way freezes the screen. - if (fingerprint == null || fingerprint != lastHrFingerprint) { - lastHrFingerprint = fingerprint + val personal = NoopPrefs.stressPersonalBaseline(context) + val core = refresh.loadIfChanged(fingerprint) { loadDaytimeCore(vm, personal) } + if (core != null) { // TWO phases, so the chart is not held behind work it does not use (#2181). The // scoring pass publishes first and the line can draw; the two optional HRV lenses, // which live in their own card and include a Lomb-Scargle periodogram over the whole // day of beats, fill in after. Both phases run on Dispatchers.Default rather than on // the LaunchedEffect's main thread, which is what made this a frozen screen instead // of a slow one. - val personal = NoopPrefs.stressPersonalBaseline(context) - val core = runCatching { loadDaytimeCore(vm, personal) }.getOrNull() - daytime = core?.daytime ?: DaytimeStress.Result.EMPTY - daytimeUsesPersonalBaseline = core?.usesPersonalBaseline == true - val beats = core?.rr.orEmpty() + daytime = core.daytime + daytimeUsesPersonalBaseline = core.usesPersonalBaseline + val beats = core.rr val lenses = if (beats.isEmpty()) null else StressLoadCancellation.read { withContext(Dispatchers.Default) { StressIndex.components(beats) to HrvFreqDomain.freqDomain(beats) diff --git a/android/app/src/test/java/com/noop/ui/StressCoreRefreshTest.kt b/android/app/src/test/java/com/noop/ui/StressCoreRefreshTest.kt new file mode 100644 index 0000000000..0ba3af53c8 --- /dev/null +++ b/android/app/src/test/java/com/noop/ui/StressCoreRefreshTest.kt @@ -0,0 +1,98 @@ +package com.noop.ui + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import kotlinx.coroutines.runBlocking +import org.junit.Assert.* +import org.junit.Test + +class StressCoreRefreshTest { + @Test + fun failedFirstReadRetriesUnchangedDataAndThenMemoisesSuccess() = runBlocking { + val refresh = StressCoreRefresh() + val fingerprint = "whoop-active=100:500,my-whoop=0:0" + var attempts = 0 + suspend fun load(): String { + attempts++ + if (attempts == 1) error("temporary read failure") + return "timeline" + } + assertNull(refresh.loadIfChanged(fingerprint, ::load)) + assertEquals("timeline", refresh.loadIfChanged(fingerprint, ::load)) + assertNull(refresh.loadIfChanged(fingerprint, ::load)) + assertEquals(2, attempts) + } + + @Test + fun failedRefreshRetainsDisplayedCoreAndRetriesNewFingerprint() = runBlocking { + val refresh = StressCoreRefresh() + val first = "whoop-active=100:500,my-whoop=0:0" + val next = "whoop-active=101:510,my-whoop=0:0" + var displayed = "loading" + refresh.loadIfChanged(first) { "previous" }?.let { displayed = it } + refresh.loadIfChanged(next) { error("temporary failure") }?.let { displayed = it } + assertEquals("previous", displayed) + refresh.loadIfChanged(next) { "updated" }?.let { displayed = it } + assertEquals("updated", displayed) + } + + @Test + fun unknownFingerprintAlwaysReadsAndSuccessfulEmptyCoreIsMemoised() = runBlocking { + val refresh = StressCoreRefresh>() + assertEquals(emptyList(), refresh.loadIfChanged(null) { emptyList() }) + assertEquals(listOf(1), refresh.loadIfChanged(null) { listOf(1) }) + val emptyFingerprint = "whoop-active=0:0,my-whoop=0:0" + assertEquals(emptyList(), refresh.loadIfChanged(emptyFingerprint) { emptyList() }) + assertNull(refresh.loadIfChanged(emptyFingerprint) { error("must skip successful empty read") }) + } + + @Test + fun lifecycleCancellationPropagatesAndDoesNotConsumeFingerprint() = runBlocking { + val refresh = StressCoreRefresh() + val fingerprint = "whoop-active=100:500,my-whoop=0:0" + val cancellation = CancellationException("screen stopped") + try { + refresh.loadIfChanged(fingerprint) { throw cancellation } + fail("cancellation must propagate") + } catch (actual: CancellationException) { + assertSame(cancellation, actual) + } + assertEquals("resumed", refresh.loadIfChanged(fingerprint) { "resumed" }) + } + + @Test + fun aliasBackfillChangesTheUnionFingerprintAndRetriesWithoutChangingActiveHr() = runBlocking { + val refresh = StressCoreRefresh() + val before = "whoop-active=100:500,whoop-archived=1:400,my-whoop=0:0" + val after = "whoop-active=100:500,whoop-archived=2:400,my-whoop=0:0" + assertEquals("previous", refresh.loadIfChanged(before) { "previous" }) + assertNull(refresh.loadIfChanged(after) { error("temporary read failure") }) + assertEquals("backfilled", refresh.loadIfChanged(after) { "backfilled" }) + assertNull(refresh.loadIfChanged(after) { error("must skip successful union read") }) + } + + @Test + fun lateCancelledCoreCannotPublishOrConsumeTheUnionFingerprint() = runTest { + val refresh = StressCoreRefresh() + val fingerprint = "whoop-active=100:500,my-whoop=0:0" + val entered = CompletableDeferred() + val released = CompletableDeferred() + val published = mutableListOf() + val old = launch { + refresh.loadIfChanged(fingerprint) { + withContext(NonCancellable) { entered.complete(Unit); released.await() } + "stale" + }?.let { published += it } + } + entered.await() + old.cancel() + released.complete(Unit) + old.join() + assertEquals(emptyList(), published) + assertEquals("resumed", refresh.loadIfChanged(fingerprint) { "resumed" }) + } +}