From 80aaaeea3e13d59036493256b6b0dcc6b19df4af Mon Sep 17 00:00:00 2001 From: WhatsSkiLL Date: Wed, 22 Jul 2026 15:22:29 +0200 Subject: [PATCH] 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 --- .../java/ai/openclaw/wear/WearTalkAvatar.kt | 235 ++++++++++++-- .../ai/openclaw/wear/WearTalkAvatarTest.kt | 303 ++++++++++++++++++ 2 files changed, 519 insertions(+), 19 deletions(-) diff --git a/apps/android/wear/src/main/java/ai/openclaw/wear/WearTalkAvatar.kt b/apps/android/wear/src/main/java/ai/openclaw/wear/WearTalkAvatar.kt index 7c8db2d9ef41..4bc654cdc989 100644 --- a/apps/android/wear/src/main/java/ai/openclaw/wear/WearTalkAvatar.kt +++ b/apps/android/wear/src/main/java/ai/openclaw/wear/WearTalkAvatar.kt @@ -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)) diff --git a/apps/android/wear/src/test/java/ai/openclaw/wear/WearTalkAvatarTest.kt b/apps/android/wear/src/test/java/ai/openclaw/wear/WearTalkAvatarTest.kt index 1882fb0488f0..fb92852830c6 100644 --- a/apps/android/wear/src/test/java/ai/openclaw/wear/WearTalkAvatarTest.kt +++ b/apps/android/wear/src/test/java/ai/openclaw/wear/WearTalkAvatarTest.kt @@ -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() + + 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() + 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() + 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() + 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(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 + } }