mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 20:35:39 -06:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user