fix(android): respect reduced motion in Wear avatar (#112245)

* fix(android): respect reduced motion in Wear avatar

* fix(android): observe effective Wear animation scale

* fix(android): refresh legacy Wear motion scale

* fix(android): initialize Wear motion scale before snapshot observation

---------

Co-authored-by: Colin <colin@solvely.net>
This commit is contained in:
WhatsSkiLL
2026-07-22 15:22:29 +02:00
committed by GitHub
parent df3ff35277
commit 80aaaeea3e
2 changed files with 519 additions and 19 deletions
@@ -1,16 +1,27 @@
package ai.openclaw.wear
import android.provider.Settings
import android.animation.ValueAnimator
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.os.PowerManager
import androidx.annotation.RequiresApi
import androidx.compose.foundation.Canvas
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier
import androidx.compose.ui.MotionDurationScale
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
@@ -22,6 +33,11 @@ import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.withTransform
import androidx.compose.ui.graphics.vector.PathParser
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.compose.LocalLifecycleOwner
import kotlinx.coroutines.flow.collect
import kotlin.coroutines.coroutineContext
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.exp
@@ -58,7 +74,7 @@ private val RightAntennaPivot = Offset(82.5f, 11f)
private val LeftEyeCenter = Offset(45f, 35f)
private val RightEyeCenter = Offset(75f, 35f)
private data class WearAvatarPose(
internal data class WearAvatarPose(
val floatOffset: Float,
val bodyTilt: Float,
val bodyStretch: Float,
@@ -80,19 +96,20 @@ internal fun WearTalkAvatar(
accent: Color,
danger: Color,
modifier: Modifier = Modifier,
animatorScaleSource: WearAnimatorScaleSource? = null,
motionDurationScale: MotionDurationScale? = null,
frameClock: WearAvatarFrameClock = ComposeWearAvatarFrameClock,
onAnimationStateChanged: ((WearAvatarAnimationState) -> Unit)? = null,
) {
val context = LocalContext.current
val animationsEnabled =
remember(context) {
Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) > 0f
}
val animationScale = rememberAnimatorDurationScale(animatorScaleSource, motionDurationScale)
val animationsEnabled = animationScale > 0f
val latestState by rememberUpdatedState(state)
val latestMouthLevel by rememberUpdatedState(mouthLevel)
val latestSyntheticSpeech by rememberUpdatedState(syntheticSpeech)
var animationSeconds by remember { mutableFloatStateOf(0f) }
var smoothedMouth by remember { mutableFloatStateOf(0f) }
LaunchedEffect(animationsEnabled) {
LaunchedEffect(animationScale, frameClock) {
if (!animationsEnabled) {
animationSeconds = 0f
smoothedMouth = 0f
@@ -100,9 +117,13 @@ internal fun WearTalkAvatar(
}
var lastFrameNanos = 0L
while (true) {
withFrameNanos { frameNanos ->
frameClock.awaitFrame { frameNanos ->
if (lastFrameNanos != 0L) {
val deltaSeconds = ((frameNanos - lastFrameNanos) / 1_000_000_000f).coerceIn(0f, 0.05f)
val deltaSeconds =
scaledAvatarDeltaSeconds(
deltaSeconds = (frameNanos - lastFrameNanos) / 1_000_000_000f,
durationScale = animationScale,
)
animationSeconds = (animationSeconds + deltaSeconds) % AVATAR_ANIMATION_CYCLE_SECONDS
val targetMouth =
if (latestState == RealtimeVoiceButtonState.SPEAKING) {
@@ -120,15 +141,20 @@ internal fun WearTalkAvatar(
}
}
val staticMouth =
if (!animationsEnabled && state == RealtimeVoiceButtonState.SPEAKING) {
mouthLevel.coerceIn(0f, 1f)
} else {
smoothedMouth
}
val pose = avatarPoseAt(state, animationSeconds, staticMouth)
val motionInputs = avatarMotionInputs(animationsEnabled, animationSeconds, smoothedMouth)
val pose = avatarPoseAt(state, motionInputs.animationSeconds, motionInputs.mouthLevel)
val stateColor = if (state == RealtimeVoiceButtonState.ERROR) danger else accent
SideEffect {
onAnimationStateChanged?.invoke(
WearAvatarAnimationState(
durationScale = animationScale,
animationSeconds = motionInputs.animationSeconds,
mouthLevel = motionInputs.mouthLevel,
),
)
}
Canvas(modifier = modifier) {
val unit = size.minDimension
val center = Offset(size.width / 2f, size.height / 2f)
@@ -144,12 +170,175 @@ internal fun WearTalkAvatar(
val artTop = center.y - ((CANONICAL_ART_SIZE * artScale) / 2f) + (unit * 0.025f)
withTransform({ translate(left = artLeft, top = artTop) }) {
withTransform({ scale(artScale, artScale, pivot = Offset.Zero) }) {
drawCanonicalAvatar(pose, state, animationSeconds)
drawCanonicalAvatar(pose, state, motionInputs.animationSeconds)
}
}
}
}
@Composable
internal fun rememberAnimatorDurationScale(
animatorScaleSource: WearAnimatorScaleSource? = null,
motionDurationScale: MotionDurationScale? = null,
): Float {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val effectiveScaleSource =
animatorScaleSource
?: remember(context, lifecycleOwner) {
AndroidWearAnimatorScaleSource(context.applicationContext, lifecycleOwner)
}
val effectiveScale = rememberEffectiveAnimatorScale(effectiveScaleSource)
var canonicalScale by remember(motionDurationScale) {
mutableFloatStateOf(motionDurationScale?.scaleFactor?.coerceAtLeast(0f) ?: 1f)
}
LaunchedEffect(motionDurationScale) {
val composeScale = motionDurationScale ?: coroutineContext[MotionDurationScale]
if (composeScale == null) {
canonicalScale = 1f
return@LaunchedEffect
}
// Compose lazily starts its Android scale observer from this getter, which
// may write snapshot state and therefore must run before snapshotFlow.
canonicalScale = composeScale.scaleFactor.coerceAtLeast(0f)
snapshotFlow { composeScale.scaleFactor.coerceAtLeast(0f) }
.collect { scale -> canonicalScale = scale }
}
return resolvedAvatarAnimationScale(canonicalScale, effectiveScale)
}
@Composable
private fun rememberEffectiveAnimatorScale(source: WearAnimatorScaleSource): Float {
var effectiveScale by remember(source) { mutableFloatStateOf(source.currentScale()) }
DisposableEffect(source) {
effectiveScale = source.currentScale()
val subscription = source.subscribe { scale -> effectiveScale = scale.coerceAtLeast(0f) }
onDispose { subscription.dispose() }
}
return effectiveScale
}
internal fun resolvedAvatarAnimationScale(
canonicalScale: Float,
effectiveScale: Float,
): Float = if (canonicalScale > 0f && effectiveScale > 0f) canonicalScale else 0f
internal fun interface WearAnimatorScaleSubscription {
fun dispose()
}
internal interface WearAnimatorScaleSource {
fun currentScale(): Float
fun subscribe(onScaleChanged: (Float) -> Unit): WearAnimatorScaleSubscription
}
internal class AndroidWearAnimatorScaleSource(
private val context: Context,
private val lifecycleOwner: LifecycleOwner,
) : WearAnimatorScaleSource {
private val powerManager = context.getSystemService(PowerManager::class.java)
override fun currentScale(): Float =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
ValueAnimator.getDurationScale().coerceAtLeast(0f)
} else {
// Compose owns the user duration scale. Legacy Android exposes no listener
// for Battery Saver's separate override, so keep only that signal here.
if (powerManager.isPowerSaveMode) 0f else 1f
}
override fun subscribe(onScaleChanged: (Float) -> Unit): WearAnimatorScaleSubscription =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
subscribeToDurationScale(onScaleChanged)
} else {
subscribeToLegacyEffectiveScale(onScaleChanged)
}
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
private fun subscribeToDurationScale(
onScaleChanged: (Float) -> Unit,
): WearAnimatorScaleSubscription {
val listener =
ValueAnimator.DurationScaleChangeListener { scale ->
onScaleChanged(scale.coerceAtLeast(0f))
}
ValueAnimator.registerDurationScaleChangeListener(listener)
onScaleChanged(currentScale())
return WearAnimatorScaleSubscription {
ValueAnimator.unregisterDurationScaleChangeListener(listener)
}
}
@Suppress("UnspecifiedRegisterReceiverFlag")
private fun subscribeToLegacyEffectiveScale(onScaleChanged: (Float) -> Unit): WearAnimatorScaleSubscription {
val refresh = { onScaleChanged(currentScale()) }
val receiver =
object : BroadcastReceiver() {
override fun onReceive(
context: Context?,
intent: Intent?,
) {
refresh()
}
}
val lifecycleObserver =
object : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) {
refresh()
}
override fun onResume(owner: LifecycleOwner) {
refresh()
}
}
context.registerReceiver(receiver, IntentFilter(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED))
lifecycleOwner.lifecycle.addObserver(lifecycleObserver)
refresh()
return WearAnimatorScaleSubscription {
context.unregisterReceiver(receiver)
lifecycleOwner.lifecycle.removeObserver(lifecycleObserver)
}
}
}
internal fun interface WearAvatarFrameClock {
suspend fun awaitFrame(onFrame: (Long) -> Unit)
}
private val ComposeWearAvatarFrameClock = WearAvatarFrameClock { onFrame -> withFrameNanos(onFrame) }
internal data class WearAvatarAnimationState(
val durationScale: Float,
val animationSeconds: Float,
val mouthLevel: Float,
)
internal data class WearAvatarMotionInputs(
val animationSeconds: Float,
val mouthLevel: Float,
)
internal fun avatarMotionInputs(
animationsEnabled: Boolean,
animationSeconds: Float,
mouthLevel: Float,
): WearAvatarMotionInputs =
if (animationsEnabled) {
WearAvatarMotionInputs(
animationSeconds = animationSeconds,
mouthLevel = mouthLevel.coerceIn(0f, 1f),
)
} else {
WearAvatarMotionInputs(animationSeconds = 0f, mouthLevel = 0f)
}
private fun DrawScope.drawCanonicalAvatar(
pose: WearAvatarPose,
state: RealtimeVoiceButtonState,
@@ -274,7 +463,7 @@ private fun DrawScope.drawCanonicalMouth(
}
}
private fun avatarPoseAt(
internal fun avatarPoseAt(
state: RealtimeVoiceButtonState,
animationSeconds: Float,
mouthLevel: Float,
@@ -373,6 +562,14 @@ internal fun smoothAvatarMouth(
return (safeCurrent + ((safeTarget - safeCurrent) * blend)).coerceIn(0f, 1f)
}
internal fun scaledAvatarDeltaSeconds(
deltaSeconds: Float,
durationScale: Float,
): Float {
if (durationScale <= 0f) return 0f
return (deltaSeconds / durationScale).coerceIn(0f, 0.05f)
}
private fun syntheticSpeechMouth(animationSeconds: Float): Float {
val tau = 2f * PI.toFloat()
val syllable = 0.5f + (0.5f * sin(animationSeconds * tau / 0.19f))
@@ -2,16 +2,34 @@ package ai.openclaw.wear
import ai.openclaw.wear.shared.WearProtocol
import ai.openclaw.wear.shared.WearRpcMethod
import android.animation.ValueAnimator
import android.content.Intent
import android.os.Looper
import android.os.PowerManager
import android.provider.Settings
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.MotionDurationScale
import androidx.compose.ui.graphics.Color
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import kotlinx.coroutines.channels.Channel
import kotlinx.serialization.json.JsonObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import org.robolectric.shadows.ShadowSystemClock
import org.robolectric.shadows.ShadowValueAnimator
import java.time.Duration
@RunWith(RobolectricTestRunner::class)
@@ -97,6 +115,225 @@ class WearTalkAvatarTest {
assertTrue(level < 0.001f)
}
@Test
fun avatarFrameDeltaHonorsAnimatorDurationScale() {
val frameDelta = 1f / 60f
assertEquals(1f / 30f, scaledAvatarDeltaSeconds(frameDelta, durationScale = 0.5f), 0.000_001f)
assertEquals(frameDelta, scaledAvatarDeltaSeconds(frameDelta, durationScale = 1f), 0.000_001f)
assertEquals(1f / 120f, scaledAvatarDeltaSeconds(frameDelta, durationScale = 2f), 0.000_001f)
}
@Test
fun zeroAnimatorDurationScaleStopsAvatarTime() {
assertEquals(0f, scaledAvatarDeltaSeconds(deltaSeconds = 1f / 60f, durationScale = 0f), 0f)
}
@Test
fun effectiveScaleTransitionsStopAndRestartTheClockWhileComposed() {
val controller = Robolectric.buildActivity(ComponentActivity::class.java).setup()
val scaleSource = FakeWearAnimatorScaleSource(initialScale = 1f)
val motionDurationScale = FakeMotionDurationScale(initialScale = 1f)
val frameClock = FakeWearAvatarFrameClock()
val observedStates = mutableListOf<WearAvatarAnimationState>()
controller.get().setContent {
WearTalkAvatar(
state = RealtimeVoiceButtonState.SPEAKING,
mouthLevel = 1f,
syntheticSpeech = false,
accent = Color.Cyan,
danger = Color.Red,
animatorScaleSource = scaleSource,
motionDurationScale = motionDurationScale,
frameClock = frameClock,
onAnimationStateChanged = observedStates::add,
)
}
idleMainLooper()
assertEquals(1, scaleSource.subscriptionCount)
assertEquals(1f, observedStates.last().durationScale, 0f)
frameClock.sendFrame(1_000_000_000L)
idleMainLooper()
frameClock.sendFrame(1_016_666_667L)
idleMainLooper()
assertTrue(observedStates.last().animationSeconds > 0f)
scaleSource.emit(0f)
idleMainLooper()
assertEquals(0f, observedStates.last().durationScale, 0f)
assertEquals(0f, observedStates.last().animationSeconds, 0f)
assertEquals(0f, observedStates.last().mouthLevel, 0f)
val frameRequestsAtZero = frameClock.awaitCount
idleMainLooper(Duration.ofMillis(100))
assertEquals(frameRequestsAtZero, frameClock.awaitCount)
scaleSource.emit(1f)
idleMainLooper()
assertEquals(1f, observedStates.last().durationScale, 0f)
assertTrue(frameClock.awaitCount > frameRequestsAtZero)
motionDurationScale.scaleFactor = 2f
idleMainLooper()
assertEquals(2f, observedStates.last().durationScale, 0f)
controller.pause().stop().destroy()
idleMainLooper()
assertEquals(1, scaleSource.disposeCount)
assertEquals(0, scaleSource.activeSubscriptionCount)
}
@Test
@Config(sdk = [32])
fun api31And32CanonicalScaleRestartsClockWithoutEffectiveScaleCallback() {
val controller = Robolectric.buildActivity(ComponentActivity::class.java).setup()
val lifecycleOwner = TestLifecycleOwner()
val scaleSource = AndroidWearAnimatorScaleSource(RuntimeEnvironment.getApplication(), lifecycleOwner)
val motionDurationScale = FakeMotionDurationScale(initialScale = 0f)
val frameClock = FakeWearAvatarFrameClock()
val observedStates = mutableListOf<WearAvatarAnimationState>()
setRobolectricAnimatorDurationScale(0f)
try {
assertEquals(false, ValueAnimator.areAnimatorsEnabled())
controller.get().setContent {
WearTalkAvatar(
state = RealtimeVoiceButtonState.IDLE,
mouthLevel = 0f,
syntheticSpeech = false,
accent = Color.Cyan,
danger = Color.Red,
animatorScaleSource = scaleSource,
motionDurationScale = motionDurationScale,
frameClock = frameClock,
onAnimationStateChanged = observedStates::add,
)
}
idleMainLooper()
assertEquals(0f, observedStates.last().durationScale, 0f)
assertEquals(0, frameClock.awaitCount)
motionDurationScale.scaleFactor = 1f
idleMainLooper()
assertEquals(1f, observedStates.last().durationScale, 0f)
assertTrue(frameClock.awaitCount > 0)
} finally {
controller.pause().stop().destroy()
idleMainLooper()
setRobolectricAnimatorDurationScale(1f)
}
}
@Test
@Config(sdk = [33])
fun zeroSystemScaleColdStartUsesTheComposeMotionScaleWithoutCrashing() {
val context = RuntimeEnvironment.getApplication()
val originalScale =
Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f)
val controller = Robolectric.buildActivity(ComponentActivity::class.java)
val observedStates = mutableListOf<WearAvatarAnimationState>()
Settings.Global.putFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 0f)
setRobolectricAnimatorDurationScale(0f)
try {
controller.setup()
controller.get().setContent {
WearTalkAvatar(
state = RealtimeVoiceButtonState.LISTENING,
mouthLevel = 0f,
syntheticSpeech = false,
accent = Color.Cyan,
danger = Color.Red,
animatorScaleSource = FakeWearAnimatorScaleSource(initialScale = 1f),
onAnimationStateChanged = observedStates::add,
)
}
idleMainLooper()
assertEquals(0f, observedStates.last().durationScale, 0f)
assertEquals(0f, observedStates.last().animationSeconds, 0f)
} finally {
controller.pause().stop().destroy()
idleMainLooper()
Settings.Global.putFloat(
context.contentResolver,
Settings.Global.ANIMATOR_DURATION_SCALE,
originalScale,
)
setRobolectricAnimatorDurationScale(originalScale)
}
}
@Test
@Config(sdk = [32])
fun api31And32RefreshEffectiveScaleOnLifecycleAndPowerChangesAndCleanUp() {
val context = RuntimeEnvironment.getApplication()
val lifecycleOwner = TestLifecycleOwner()
val source = AndroidWearAnimatorScaleSource(context, lifecycleOwner)
val observedScales = mutableListOf<Float>()
val subscription = source.subscribe(observedScales::add)
val countAfterSubscribe = observedScales.size
lifecycleOwner.registry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
lifecycleOwner.registry.handleLifecycleEvent(Lifecycle.Event.ON_START)
assertTrue(observedScales.size > countAfterSubscribe)
val countAfterStart = observedScales.size
context.sendBroadcast(Intent(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED))
idleMainLooper()
assertTrue(observedScales.size > countAfterStart)
subscription.dispose()
val countAfterDispose = observedScales.size
lifecycleOwner.registry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME)
context.sendBroadcast(Intent(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED))
idleMainLooper()
assertEquals(countAfterDispose, observedScales.size)
}
@Test
fun zeroMotionKeepsEveryVoiceStateStaticAndVisuallyDistinct() {
val poses =
RealtimeVoiceButtonState.entries.map { state ->
avatarPoseAt(
state = state,
animationSeconds = 0f,
mouthLevel = 0f,
)
}
assertEquals(RealtimeVoiceButtonState.entries.size, poses.distinct().size)
assertEquals(0f, poses.first().floatOffset, 0f)
assertTrue(poses.last().antennaDroop > 0f)
}
@Test
fun disabledAnimationsSuppressClockAndAudioMotionInputs() {
val inputs =
avatarMotionInputs(
animationsEnabled = false,
animationSeconds = 12.5f,
mouthLevel = 1f,
)
assertEquals(WearAvatarMotionInputs(animationSeconds = 0f, mouthLevel = 0f), inputs)
}
@Test
fun enabledAnimationsPreserveClockAndBoundAudioMotionInput() {
val inputs =
avatarMotionInputs(
animationsEnabled = true,
animationSeconds = 12.5f,
mouthLevel = 1.5f,
)
assertEquals(WearAvatarMotionInputs(animationSeconds = 12.5f, mouthLevel = 1f), inputs)
}
private fun samplesForFrames(frameCount: Int): Int = WEAR_REALTIME_SAMPLE_RATE_HZ * MOUTH_FRAME_MILLIS / 1_000 * frameCount
private fun pcm16Le(
@@ -130,6 +367,17 @@ class WearTalkAvatarTest {
assertEquals(false, client.isPlaying.value)
}
private fun idleMainLooper(duration: Duration = Duration.ZERO) {
shadowOf(Looper.getMainLooper()).idleFor(duration)
}
private fun setRobolectricAnimatorDurationScale(scale: Float) {
ShadowValueAnimator::class.java
.getDeclaredMethod("setDurationScale", java.lang.Float.TYPE)
.apply { isAccessible = true }
.invoke(null, scale)
}
private fun Any.setPrivateField(
name: String,
value: Any,
@@ -143,4 +391,59 @@ class WearTalkAvatarTest {
private companion object {
const val WEAR_REALTIME_SAMPLE_RATE_HZ = 24_000
}
private class FakeMotionDurationScale(
initialScale: Float,
) : MotionDurationScale {
override var scaleFactor by mutableFloatStateOf(initialScale)
}
private class FakeWearAnimatorScaleSource(
initialScale: Float,
) : WearAnimatorScaleSource {
private var scale = initialScale
private var listener: ((Float) -> Unit)? = null
var subscriptionCount = 0
private set
var disposeCount = 0
private set
val activeSubscriptionCount: Int
get() = if (listener == null) 0 else 1
override fun currentScale(): Float = scale
override fun subscribe(onScaleChanged: (Float) -> Unit): WearAnimatorScaleSubscription {
subscriptionCount += 1
listener = onScaleChanged
return WearAnimatorScaleSubscription {
if (listener === onScaleChanged) listener = null
disposeCount += 1
}
}
fun emit(newScale: Float) {
scale = newScale
listener?.invoke(newScale)
}
}
private class FakeWearAvatarFrameClock : WearAvatarFrameClock {
private val frames = Channel<Long>(Channel.UNLIMITED)
var awaitCount = 0
private set
override suspend fun awaitFrame(onFrame: (Long) -> Unit) {
awaitCount += 1
onFrame(frames.receive())
}
fun sendFrame(frameNanos: Long) {
assertTrue(frames.trySend(frameNanos).isSuccess)
}
}
private class TestLifecycleOwner : LifecycleOwner {
val registry = LifecycleRegistry(this)
override val lifecycle: Lifecycle = registry
}
}