xref: /aosp_15_r20/frameworks/base/tests/testables/src/android/animation/AnimatorTestRuleToolkit.kt (revision d57664e9bc4670b3ecf6748a746a57c557b6bc9e)
1 /*
<lambda>null2  * Copyright (C) 2024 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package android.animation
18 
19 import android.animation.AnimatorTestRuleToolkit.Companion.TAG
20 import android.graphics.Bitmap
21 import android.graphics.drawable.Drawable
22 import android.util.Log
23 import android.view.View
24 import androidx.core.graphics.drawable.toBitmap
25 import androidx.test.core.app.ActivityScenario
26 import java.util.concurrent.TimeUnit
27 import kotlinx.coroutines.ExperimentalCoroutinesApi
28 import kotlinx.coroutines.Job
29 import kotlinx.coroutines.flow.MutableStateFlow
30 import kotlinx.coroutines.flow.asStateFlow
31 import kotlinx.coroutines.flow.take
32 import kotlinx.coroutines.flow.takeWhile
33 import kotlinx.coroutines.launch
34 import kotlinx.coroutines.test.TestScope
35 import kotlinx.coroutines.test.runCurrent
36 import platform.test.motion.MotionTestRule
37 import platform.test.motion.RecordedMotion
38 import platform.test.motion.RecordedMotion.Companion.create
39 import platform.test.motion.golden.DataPoint
40 import platform.test.motion.golden.Feature
41 import platform.test.motion.golden.FrameId
42 import platform.test.motion.golden.TimeSeries
43 import platform.test.motion.golden.TimeSeriesCaptureScope
44 import platform.test.motion.golden.TimestampFrameId
45 import platform.test.screenshot.captureToBitmapAsync
46 
47 class AnimatorTestRuleToolkit(
48     internal val animatorTestRule: AnimatorTestRule,
49     internal val testScope: TestScope,
50     internal val currentActivityScenario: () -> ActivityScenario<*>,
51 ) {
52     internal companion object {
53         const val TAG = "AnimatorRuleToolkit"
54     }
55 }
56 
57 /** Capture utility to extract a [Bitmap] from a [drawable]. */
captureDrawablenull58 fun captureDrawable(drawable: Drawable): Bitmap {
59     val width = drawable.bounds.right - drawable.bounds.left
60     val height = drawable.bounds.bottom - drawable.bounds.top
61 
62     // If either dimension is 0 this will fail, so we set it to 1 pixel instead.
63     return drawable.toBitmap(
64         width =
65         if (width > 0) {
66             width
67         } else {
68             1
69         },
70         height =
71         if (height > 0) {
72             height
73         } else {
74             1
75         },
76     )
77 }
78 
79 /** Capture utility to extract a [Bitmap] from a [view]. */
captureViewnull80 fun captureView(view: View): Bitmap {
81     return view.captureToBitmapAsync().get(10, TimeUnit.SECONDS)
82 }
83 
84 /**
85  * Controls the timing of the motion recording.
86  *
87  * The time series is recorded while the [recording] function is running.
88  */
89 class MotionControl(val recording: MotionControlFn)
90 
91 typealias MotionControlFn = suspend MotionControlScope.() -> Unit
92 
93 interface MotionControlScope {
94     /** Waits until [check] returns true. Invoked on each frame. */
awaitConditionnull95     suspend fun awaitCondition(check: () -> Boolean)
96 
97     /** Waits for [count] frames to be processed. */
98     suspend fun awaitFrames(count: Int = 1)
99 }
100 
101 /** Defines the sampling of features during a test run. */
102 data class AnimatorRuleRecordingSpec<T>(
103     /** The root `observing` object, available in [timeSeriesCapture]'s [TimeSeriesCaptureScope]. */
104     val captureRoot: T,
105 
106     /** The timing for the recording. */
107     val motionControl: MotionControl,
108 
109     /** Time interval between frame captures, in milliseconds. */
110     val frameDurationMs: Long = 16L,
111 
112     /** Whether a sequence of screenshots should also be recorded. */
113     val visualCapture: ((captureRoot: T) -> Bitmap)? = null,
114 
115     /** Produces the time-series, invoked on each animation frame. */
116     val timeSeriesCapture: TimeSeriesCaptureScope<T>.() -> Unit,
117 )
118 
119 /** Records the time-series of the features specified in [recordingSpec]. */
120 fun <T> MotionTestRule<AnimatorTestRuleToolkit>.recordMotion(
121     recordingSpec: AnimatorRuleRecordingSpec<T>
122 ): RecordedMotion {
123     with(toolkit.animatorTestRule) {
124         val activityScenario = toolkit.currentActivityScenario()
125         val frameIdCollector = mutableListOf<FrameId>()
126         val propertyCollector = mutableMapOf<String, MutableList<DataPoint<*>>>()
127         val screenshotCollector =
128             if (recordingSpec.visualCapture != null) {
129                 mutableListOf<Bitmap>()
130             } else {
131                 null
132             }
133 
134         fun recordFrame(frameId: FrameId) {
135             Log.i(TAG, "recordFrame($frameId)")
136             frameIdCollector.add(frameId)
137             activityScenario.onActivity {
138                 recordingSpec.timeSeriesCapture.invoke(
139                     TimeSeriesCaptureScope(recordingSpec.captureRoot, propertyCollector)
140                 )
141             }
142 
143             val bitmap = recordingSpec.visualCapture?.invoke(recordingSpec.captureRoot)
144             if (bitmap != null) screenshotCollector!!.add(bitmap)
145         }
146 
147         val motionControl =
148             MotionControlImpl(
149                 toolkit.animatorTestRule,
150                 toolkit.testScope,
151                 recordingSpec.frameDurationMs,
152                 recordingSpec.motionControl,
153             )
154 
155         Log.i(TAG, "recordMotion() begin recording")
156 
157         var startFrameTime: Long? = null
158         toolkit.currentActivityScenario().onActivity { startFrameTime = currentTime }
159         while (!motionControl.recordingEnded) {
160             var time: Long? = null
161             toolkit.currentActivityScenario().onActivity { time = currentTime }
162             recordFrame(TimestampFrameId(time!! - startFrameTime!!))
163             toolkit.currentActivityScenario().onActivity { motionControl.nextFrame() }
164         }
165 
166         Log.i(TAG, "recordMotion() end recording")
167 
168         val timeSeries =
169             TimeSeries(
170                 frameIdCollector.toList(),
171                 propertyCollector.entries.map { entry -> Feature(entry.key, entry.value) },
172             )
173 
174         return create(timeSeries, screenshotCollector)
175     }
176 }
177 
178 @OptIn(ExperimentalCoroutinesApi::class)
179 private class MotionControlImpl(
180     val animatorTestRule: AnimatorTestRule,
181     val testScope: TestScope,
182     val frameMs: Long,
183     motionControl: MotionControl,
184 ) : MotionControlScope {
185     private val recordingJob = motionControl.recording.launch()
186 
187     private val frameEmitter = MutableStateFlow<Long>(0)
188     private val onFrame = frameEmitter.asStateFlow()
189 
190     var recordingEnded: Boolean = false
191 
nextFramenull192     fun nextFrame() {
193         animatorTestRule.advanceTimeBy(frameMs)
194 
195         frameEmitter.tryEmit(animatorTestRule.currentTime)
196         testScope.runCurrent()
197 
198         if (recordingJob.isCompleted) {
199             recordingEnded = true
200         }
201     }
202 
awaitConditionnull203     override suspend fun awaitCondition(check: () -> Boolean) {
204         onFrame.takeWhile { !check() }.collect {}
205     }
206 
awaitFramesnull207     override suspend fun awaitFrames(count: Int) {
208         onFrame.take(count).collect {}
209     }
210 
launchnull211     private fun MotionControlFn.launch(): Job {
212         val function = this
213         return testScope.launch { function() }
214     }
215 }
216