From 2b3ab03937589b85ead377137bfa40983eec8ca8 Mon Sep 17 00:00:00 2001 From: ryanbr Date: Mon, 27 Jul 2026 17:03:33 -0700 Subject: [PATCH] perf(ui): pose the animating surfaces still in battery saver (#909) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android half of #909. Apple honours Low Power Mode for its live gauges; here the equivalent flag was read nowhere. `PowerManager` appears once in the tree, in AndroidDiagnostics, and for `isIgnoringBatteryOptimizations` — a different question entirely. `isPowerSaveMode` appeared nowhere at all. The parallel with the Apple side was exact: LiquidSky.kt already carried the comment "Under Reduce Motion it collapses to the static picture (no frame loop)", the same half-implemented pairing #909 found on the sky branch. Battery-saver state is a single process-wide monitor, registered once, read through `rememberPowerSaveMode()`. One receiver rather than one per composable: the liquid primitives alone have 26 call sites, and a DisposableEffect in each would register and unregister a BroadcastReceiver as gauges scroll in and out of composition — churn, in the change that exists to remove per-frame work. The Apple twin is a singleton for the same reason, and like it this is never unregistered. It stays live on ACTION_POWER_SAVE_MODE_CHANGED, so toggling battery saver takes effect without leaving the screen, and registers RECEIVER_NOT_EXPORTED through ContextCompat as WhoopConnectionService does. `rememberPoseStill()` is that OR reduce-motion. Gated: LiquidSky, the three LiquidPrimitives frame loops, TimeOfDayBackground's atmosphere transition, the LiveSessionScreen guardian breath, and the pulsing ConnectionDot halo. The last had no motion gate of any kind — it pulsed under "Remove animations" too, which was already a bug. NOT gated, deliberately: - One-shot animations. CountUpText, staggeredAppear and the 160 ms liquidPress tween settle and stop, so they are not the cost battery saver asks an app to avoid, and #909 gated only its TimelineViews. - TodayScreen's drag-reorder auto-scroll. It is a withFrameNanos loop, but it runs only while the user's finger is down. There is no idle to save, and quieting it would strip the auto-scroll out from under an active drag. `PoseStillCoverageTest` censuses withFrameNanos / rememberInfiniteTransition across com/noop/ui and fails on any file that does not consult the gate, with the drag loop exempted by name and by reason. That census is how three surfaces I had missed were found: my own survey used `head -8` and truncated, and I would have shipped LiveSessionScreen, Components and TodayScreen ungated on the strength of it. The test also pins that the gate reads both signals and stays live, since losing either half is invisible — the screen looks right in whichever mode still works. Named `renderStill` at the call sites rather than `posed`, because LiquidPrimitives already binds `posed` to `LiquidSim.posed(...)` in its static branches and the first version of this shadowed it. The ConnectionDot gate is hoisted to a local rather than called inside `if (pulsing && !rememberPoseStill())`, so the composable is not invoked from the right-hand side of a short-circuit. Verified: the coverage guard passes here, fails against the ungated tree, and fails rather than skips when the source is unlocatable; exactly one registerReceiver remains under com/noop/ui. No measurement on Android — the Apple numbers (~18% of a core idle, 0.0% posed) are cited as the shape, not restated as this platform's. --- .../src/main/java/com/noop/ui/Components.kt | 6 +- .../main/java/com/noop/ui/LiquidPrimitives.kt | 12 +- .../src/main/java/com/noop/ui/LiquidSky.kt | 6 +- .../java/com/noop/ui/LiveSessionScreen.kt | 4 +- .../src/main/java/com/noop/ui/NoopMotion.kt | 80 ++++++++++++ .../java/com/noop/ui/TimeOfDayBackground.kt | 4 +- .../java/com/noop/ui/PoseStillCoverageTest.kt | 123 ++++++++++++++++++ 7 files changed, 221 insertions(+), 14 deletions(-) create mode 100644 android/app/src/test/java/com/noop/ui/PoseStillCoverageTest.kt diff --git a/android/app/src/main/java/com/noop/ui/Components.kt b/android/app/src/main/java/com/noop/ui/Components.kt index 9ef310a758..45e30efb4e 100644 --- a/android/app/src/main/java/com/noop/ui/Components.kt +++ b/android/app/src/main/java/com/noop/ui/Components.kt @@ -309,7 +309,11 @@ fun ConnectionDot( // are several on Today (each StatePill, source pill, etc.) — kept a running animation-clock // subscription invalidating the frame, for a halo that wasn't even drawn. Hoisting it into a child // that's composed only when `pulsing` means a still dot does zero per-frame work. Identical visuals. - if (pulsing) { + // ...and not at all under Reduce Motion or battery saver: the halo is decoration, and an + // infinite transition per live dot is exactly the idle per-frame work battery saver asks an app + // to stop. A still dot still reads as live — the colour carries that. (#909) + val renderStill = rememberPoseStill() + if (pulsing && !renderStill) { PulsingDotHalo(tone = tone, size = size) } Box( diff --git a/android/app/src/main/java/com/noop/ui/LiquidPrimitives.kt b/android/app/src/main/java/com/noop/ui/LiquidPrimitives.kt index f026a6b556..0b5679d80f 100644 --- a/android/app/src/main/java/com/noop/ui/LiquidPrimitives.kt +++ b/android/app/src/main/java/com/noop/ui/LiquidPrimitives.kt @@ -77,9 +77,9 @@ fun LiquidVessel( animated: Boolean = true, modifier: Modifier = Modifier, ) { - val reduced = rememberReduceMotion() + val renderStill = rememberPoseStill() - if (animated && !reduced) { + if (animated && !renderStill) { val view = LocalView.current // The mutable physics, remembered across recompositions so the slosh is continuous. Seeded at the // fill line (iOS `LiquidSim(target: value ?? 0)`); the target is re-supplied every frame in step(). @@ -153,10 +153,10 @@ fun LiquidTube( animated: Boolean = true, modifier: Modifier = Modifier, ) { - val reduced = rememberReduceMotion() + val renderStill = rememberPoseStill() val clamped = frac.coerceIn(0.0, 1.0) - if (animated && !reduced) { + if (animated && !renderStill) { // iOS seeds the tube sim at target 0 (`LiquidSim(target: 0)`) and lets step() chase `frac`. val sim = remember { LiquidSim(target = 0.0) } @@ -211,9 +211,9 @@ fun LiquidThread( animated: Boolean = true, modifier: Modifier = Modifier, ) { - val reduced = rememberReduceMotion() + val renderStill = rememberPoseStill() - if (animated && !reduced) { + if (animated && !renderStill) { var seconds by remember { mutableDoubleStateOf(0.0) } LaunchedEffect(Unit) { var last = 0L diff --git a/android/app/src/main/java/com/noop/ui/LiquidSky.kt b/android/app/src/main/java/com/noop/ui/LiquidSky.kt index 2fc685a786..98a0751aaf 100644 --- a/android/app/src/main/java/com/noop/ui/LiquidSky.kt +++ b/android/app/src/main/java/com/noop/ui/LiquidSky.kt @@ -257,12 +257,12 @@ private val liquidSettleColor: Color */ @Composable fun LiquidSky(hour: Double? = null, modifier: Modifier = Modifier) { - val reduced = rememberReduceMotion() + val renderStill = rememberPoseStill() val settle = liquidSettleColor val h = hour ?: liquidLiveHour() - if (reduced) { - // No frame loop under Reduce Motion — pose the static picture once. + if (renderStill) { + // No frame loop under Reduce Motion or battery saver — pose the static picture once (#909). Canvas(modifier = modifier) { renderLiquidSky(hour = h, now = 0.0, settle = settle, animate = false) } return } diff --git a/android/app/src/main/java/com/noop/ui/LiveSessionScreen.kt b/android/app/src/main/java/com/noop/ui/LiveSessionScreen.kt index f241f6dd99..e9441779df 100644 --- a/android/app/src/main/java/com/noop/ui/LiveSessionScreen.kt +++ b/android/app/src/main/java/com/noop/ui/LiveSessionScreen.kt @@ -283,8 +283,8 @@ private fun GuardianRing( } val ringColor by animateColorAsState(target, animationSpec = tween(600), label = uiString(R.string.l10n_live_session_screen_guardianringcolor_f20ad757)) - val reduced = rememberReduceMotion() - val breathing = !stale && settled && position == LiveSessionEngine.Position.IN_BAND && !reduced + val renderStill = rememberPoseStill() + val breathing = !stale && settled && position == LiveSessionEngine.Position.IN_BAND && !renderStill val stateLabel = when { stale -> "Signal lost, coaching paused" diff --git a/android/app/src/main/java/com/noop/ui/NoopMotion.kt b/android/app/src/main/java/com/noop/ui/NoopMotion.kt index c9166d0dd4..bf255db439 100644 --- a/android/app/src/main/java/com/noop/ui/NoopMotion.kt +++ b/android/app/src/main/java/com/noop/ui/NoopMotion.kt @@ -1,6 +1,11 @@ package com.noop.ui import com.noop.R +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.PowerManager import android.provider.Settings import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.animateFloatAsState @@ -25,6 +30,7 @@ import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat import kotlinx.coroutines.delay // MARK: - NoopMotion — the "Design Reset" motion set (WHOOP design language, 2026-06-22) @@ -68,6 +74,80 @@ fun rememberReduceMotion(): Boolean { } } +/** + * Process-wide battery-saver state, registered once and read by every animating surface. + * + * One receiver, not one per composable: the liquid primitives alone have 26 call sites, and a + * `DisposableEffect` inside each would register and unregister a `BroadcastReceiver` as gauges scroll in + * and out of composition — churn, in the change that exists to remove per-frame work. The Apple twin is a + * singleton (`LiquidPowerMonitor.shared`) for the same reason, and like it this is never unregistered: + * the state is process-lifetime and so is the observer. + * + * Backed by Compose snapshot state, so a write from the receiver recomposes exactly the surfaces that + * read it. + */ +private object PowerSaveMonitor { + val isSaving = mutableStateOf(false) + private var registered = false + + /** Idempotent; safe to call from every composition. Called on the main thread by construction. */ + fun ensureStarted(context: Context) { + if (registered) return + val app = context.applicationContext + val pm = app.getSystemService(Context.POWER_SERVICE) as? PowerManager ?: return + isSaving.value = pm.isPowerSaveMode + val receiver = object : BroadcastReceiver() { + override fun onReceive(unused: Context?, intent: Intent?) { isSaving.value = pm.isPowerSaveMode } + } + // ACTION_POWER_SAVE_MODE_CHANGED is a protected system broadcast; RECEIVER_NOT_EXPORTED stops + // other apps sending it while the system still delivers. Same shape as the Bluetooth state + // receiver in WhoopConnectionService. + val ok = runCatching { + ContextCompat.registerReceiver( + app, + receiver, + IntentFilter(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED), + ContextCompat.RECEIVER_NOT_EXPORTED, + ) + }.isSuccess + if (ok) registered = true + } +} + +/** + * True while the system is in battery saver. The Android analogue of iOS `isLowPowerModeEnabled`, which + * [rememberPoseStill] pairs with reduce-motion so the continuously-animating surfaces pose still (#909). + * + * Live, not read-once: toggling battery saver takes effect without leaving the screen, matching the Apple + * side's `NSProcessInfoPowerStateDidChange` observer. Previews (inspection mode) always report `false`, + * like [rememberReduceMotion], so design tooling shows the animated state. + */ +@Composable +fun rememberPowerSaveMode(): Boolean { + if (LocalInspectionMode.current) return false + val context = LocalContext.current + // `remember` so the idempotent start runs once per composition rather than on every recomposition. + remember(context) { PowerSaveMonitor.ensureStarted(context) } + return PowerSaveMonitor.isSaving.value +} + +/** + * True when a continuously-animating surface should render its single posed frame instead of running + * a frame loop — either the user has turned system animations off, or the system is in battery saver. + * + * **For frame loops only.** The one-shot helpers in this file (`CountUpText`, `staggeredAppear`, the + * 160 ms tweens in `LiquidPrimitives`) keep asking [rememberReduceMotion] on its own: those settle and + * stop, so they are not the cost battery saver is asking to avoid, and the Apple change this mirrors + * (#909) gated only its `TimelineView`s for the same reason. + * + * The measured cost on the Apple side was ~18% of a CPU core for the idle Today screen, identical in + * Debug and Release because the per-frame work is canvas drawing rather than arithmetic. Android runs + * the same picture through `withFrameNanos` / `rememberInfiniteTransition`, so the shape holds even + * though the number is unmeasured here. + */ +@Composable +fun rememberPoseStill(): Boolean = rememberReduceMotion() || rememberPowerSaveMode() + // MARK: - NoopMotion springs / tokens object NoopMotion { diff --git a/android/app/src/main/java/com/noop/ui/TimeOfDayBackground.kt b/android/app/src/main/java/com/noop/ui/TimeOfDayBackground.kt index b84faf1a86..0b6c43b07b 100644 --- a/android/app/src/main/java/com/noop/ui/TimeOfDayBackground.kt +++ b/android/app/src/main/java/com/noop/ui/TimeOfDayBackground.kt @@ -145,8 +145,8 @@ fun Modifier.timeOfDayBackground( * Reduce Motion, so the whole layer is static. One slow loop (~80s) so the system has near-zero work. */ @Composable private fun atmospherePhase(animated: Boolean): Float { - val reduced = rememberReduceMotion() - if (!animated || reduced) return 0f + val renderStill = rememberPoseStill() + if (!animated || renderStill) return 0f val transition = rememberInfiniteTransition(label = "atmosphere") val phase by transition.animateFloat( initialValue = 0f, diff --git a/android/app/src/test/java/com/noop/ui/PoseStillCoverageTest.kt b/android/app/src/test/java/com/noop/ui/PoseStillCoverageTest.kt new file mode 100644 index 0000000000..10aabc4861 --- /dev/null +++ b/android/app/src/test/java/com/noop/ui/PoseStillCoverageTest.kt @@ -0,0 +1,123 @@ +package com.noop.ui + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +/** + * #909 — every continuously-animating surface must consult [rememberPoseStill], so battery saver and + * "Remove animations" both collapse it to its single posed frame. + * + * The Apple measurement behind this: an idle Today screen costs ~18% of a CPU core with the live gauges + * running and 0.0% posed still, the same in Debug and Release because the per-frame work is canvas + * drawing rather than arithmetic. A frame loop that forgets the gate keeps burning that in battery saver, + * and nothing about the screen looks wrong — which is exactly why it needs a test rather than review. + * + * The census is `withFrameNanos` and `rememberInfiniteTransition`, the two ways this app runs an + * unbounded frame loop. A file containing either must also read the gate. That is coarser than checking + * each call site's control flow, deliberately: it cannot be fooled by a loop that is gated in a helper, + * and it fails loudly the moment a NEW animating file appears ungated, which is the case worth catching. + * + * One-shot animations are out of scope on purpose. `CountUpText`, `staggeredAppear` and the 160 ms + * `liquidPress` tween settle and stop, so they are not the cost battery saver is asking to avoid, and the + * Apple change this mirrors gated only its `TimelineView`s. They keep asking `rememberReduceMotion` alone. + * + * Fails rather than skips when it cannot find the source, for the reason + * [com.noop.data.HrFrontierQueryShapeTest] documents: a guard whose absence reads as a pass is not a guard. + */ +class PoseStillCoverageTest { + + private fun uiDir(): File { + val userDir = File(System.getProperty("user.dir") ?: ".") + val rel = "src/main/java/com/noop/ui" + val found = listOf(File(userDir, rel), File(userDir, "app/$rel"), File(userDir, "android/app/$rel")) + .firstOrNull { it.isDirectory } + assertNotNull( + "com/noop/ui not found from user.dir=$userDir — this guard cannot run, and a skip reads as a " + + "pass. Add this host's working dir to the list rather than letting it slide.", + found, + ) + return found!! + } + + /** Source with `//` and block comments blanked, so prose about a frame loop is not a frame loop. */ + private fun stripComments(src: String): String { + val out = StringBuilder(src.length) + var i = 0 + while (i < src.length) { + when { + src.startsWith("//", i) -> { while (i < src.length && src[i] != '\n') i++ } + src.startsWith("/*", i) -> { + val end = src.indexOf("*/", i + 2) + i = if (end < 0) src.length else end + 2 + } + else -> { out.append(src[i]); i++ } + } + } + return out.toString() + } + + /** + * Frame loops that are DIRECT MANIPULATION, not idle animation, and must keep running in battery saver. + * + * `TodayScreen.kt` drives drag-reorder auto-scroll from `withFrameNanos`, deliberately time-based so a + * 120 Hz panel does not scroll twice as fast. It runs only `while (sectionDrag.key != null)` — while the + * user's finger is down. Quieting it would not save idle power (there is no idle: the user is dragging); + * it would break the interaction by stripping the auto-scroll out from under them. + * + * The distinction this file draws is idle-vs-driven, not animated-vs-still. Anything added here needs a + * reason of that shape. + */ + private val directManipulation = setOf("TodayScreen.kt") + + private fun animatingFiles(): List = + uiDir().walkTopDown() + .filter { it.isFile && it.extension == "kt" } + .filterNot { it.name in directManipulation } + .filter { f -> + val code = stripComments(f.readText()) + code.contains("withFrameNanos") || code.contains("rememberInfiniteTransition") + } + .toList() + + @Test + fun everyContinuouslyAnimatingFileConsultsThePoseStillGate() { + val files = animatingFiles() + assertTrue( + "no frame-loop sites found at all — the census is looking in the wrong place", + files.isNotEmpty(), + ) + val ungated = files.filterNot { stripComments(it.readText()).contains("rememberPoseStill()") } + assertEquals( + "these run an unbounded frame loop without consulting rememberPoseStill(), so battery saver " + + "will not quiet them: ${ungated.map { it.name }}", + emptyList(), + ungated.map { it.name }, + ) + } + + /** + * The gate is reduce-motion OR battery saver. Losing either half is silent — the screen looks right + * in whichever mode still works — so both reads are pinned here rather than left to the composable. + */ + @Test + fun poseStillGateCombinesReduceMotionAndBatterySaver() { + val motion = File(uiDir(), "NoopMotion.kt") + assertTrue("NoopMotion.kt missing", motion.isFile) + val code = stripComments(motion.readText()).replace(Regex("\\s+"), " ") + assertTrue( + "rememberPoseStill must be the OR of both signals: $code", + code.contains("fun rememberPoseStill(): Boolean = rememberReduceMotion() || rememberPowerSaveMode()"), + ) + assertTrue( + "battery saver must be read from PowerManager.isPowerSaveMode", + code.contains("isPowerSaveMode"), + ) + assertTrue( + "and kept live — a read-once value would strand the screen animating after the user flips it", + code.contains("ACTION_POWER_SAVE_MODE_CHANGED"), + ) + } +}