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/ui/Components.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
12 changes: 6 additions & 6 deletions android/app/src/main/java/com/noop/ui/LiquidPrimitives.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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().
Expand Down Expand Up @@ -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) }

Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions android/app/src/main/java/com/noop/ui/LiquidSky.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
4 changes: 2 additions & 2 deletions android/app/src/main/java/com/noop/ui/LiveSessionScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
80 changes: 80 additions & 0 deletions android/app/src/main/java/com/noop/ui/NoopMotion.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions android/app/src/main/java/com/noop/ui/TimeOfDayBackground.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
123 changes: 123 additions & 0 deletions android/app/src/test/java/com/noop/ui/PoseStillCoverageTest.kt
Original file line number Diff line number Diff line change
@@ -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<File> =
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<String>(),
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"),
)
}
}