1 /*
2 * Copyright 2019 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 androidx.compose.testutils
18
19 import android.app.Activity
20 import android.graphics.Bitmap
21 import android.graphics.Canvas
22 import android.graphics.Picture
23 import android.graphics.RenderNode
24 import android.os.Build
25 import android.util.DisplayMetrics
26 import android.view.DisplayListCanvas
27 import android.view.View
28 import android.view.ViewGroup
29 import android.widget.ImageView
30 import androidx.activity.ComponentActivity
31 import androidx.activity.compose.setContent
32 import androidx.annotation.RequiresApi
33 import androidx.compose.runtime.Recomposer
34 import androidx.compose.runtime.snapshots.Snapshot
35 import androidx.compose.ui.platform.ViewRootForTest
36 import androidx.compose.ui.test.ExperimentalTestApi
37 import androidx.compose.ui.test.InternalTestApi
38 import androidx.compose.ui.test.TestMonotonicFrameClock
39 import androidx.compose.ui.test.frameDelayMillis
40 import androidx.compose.ui.test.internal.DelayPropagatingContinuationInterceptorWrapper
41 import kotlin.coroutines.Continuation
42 import kotlin.coroutines.ContinuationInterceptor
43 import kotlinx.coroutines.CoroutineScope
44 import kotlinx.coroutines.ExperimentalCoroutinesApi
45 import kotlinx.coroutines.Job
46 import kotlinx.coroutines.launch
47 import kotlinx.coroutines.test.UnconfinedTestDispatcher
48
49 /** Factory method to provide implementation of [ComposeBenchmarkScope]. */
createAndroidComposeBenchmarkRunnernull50 fun <T : ComposeTestCase> createAndroidComposeBenchmarkRunner(
51 testCaseFactory: () -> T,
52 activity: ComponentActivity
53 ): ComposeBenchmarkScope<T> {
54 return AndroidComposeTestCaseRunner(testCaseFactory, activity)
55 }
56
57 @OptIn(ExperimentalCoroutinesApi::class, ExperimentalTestApi::class)
58 internal class AndroidComposeTestCaseRunner<T : ComposeTestCase>(
59 private val testCaseFactory: () -> T,
60 private val activity: ComponentActivity
61 ) : ComposeBenchmarkScope<T> {
62
63 override val measuredWidth: Int
64 get() = view!!.measuredWidth
65
66 override val measuredHeight: Int
67 get() = view!!.measuredHeight
68
69 internal var view: View? = null
70 private set
71
getHostViewnull72 override fun getHostView(): View = view!!
73
74 override var didLastRecomposeHaveChanges = false
75 private set
76
77 private val supportsRenderNode = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
78 private val supportsMRenderNode =
79 Build.VERSION.SDK_INT < Build.VERSION_CODES.P &&
80 Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
81
82 private val screenWithSpec: Int
83 private val screenHeightSpec: Int
84
85 @Suppress("NewApi") // NewApi doesn't understand Kotlin `when` (b/189459502)
86 private val capture =
87 when {
88 supportsRenderNode -> RenderNodeCapture()
89 supportsMRenderNode -> MRenderNodeCapture()
90 else -> PictureCapture()
91 }
92
93 private var canvas: Canvas? = null
94
95 private val testCoroutineDispatcher = UnconfinedTestDispatcher()
96 private val frameClock =
97 TestMonotonicFrameClock(
98 CoroutineScope(testCoroutineDispatcher + testCoroutineDispatcher.scheduler)
99 )
100
101 private val continuationCountInterceptor =
102 ContinuationCountInterceptor(frameClock.continuationInterceptor)
103
104 @OptIn(ExperimentalTestApi::class)
105 private val recomposerApplyCoroutineScope =
106 CoroutineScope(continuationCountInterceptor + frameClock + Job())
107 private val recomposer: Recomposer =
<lambda>null108 Recomposer(recomposerApplyCoroutineScope.coroutineContext).also {
109 recomposerApplyCoroutineScope.launch { it.runRecomposeAndApplyChanges() }
110 }
111
112 private var simulationState: SimulationState = SimulationState.Initialized
113
114 private var testCase: T? = null
115
116 private val owner: ViewRootForTest?
117 get() = findViewRootForTest(activity)
118
119 init {
120 val displayMetrics = DisplayMetrics()
121 @Suppress("DEPRECATION") /* defaultDisplay + getMetrics() */
122 activity.windowManager.defaultDisplay.getMetrics(displayMetrics)
123 val height = displayMetrics.heightPixels
124 val width = displayMetrics.widthPixels
125
126 screenWithSpec = View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.AT_MOST)
127 screenHeightSpec = View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.AT_MOST)
128 }
129
createTestCasenull130 override fun createTestCase() {
131 require(view == null) { "Content was already set!" }
132 require(testCase == null) { "Content was already set!" }
133 testCase = testCaseFactory()
134 simulationState = SimulationState.TestCaseCreated
135 }
136
emitContentnull137 override fun emitContent() {
138 require(view == null) { "Content was already set!" }
139 require(testCase != null && simulationState == SimulationState.TestCaseCreated) {
140 "Need to call onPreEmitContent before emitContent!"
141 }
142
143 continuationCountInterceptor.reset()
144 activity.setContent(recomposer) { testCase!!.Content() }
145 view = owner!!.view
146 Snapshot.notifyObjectsInitialized()
147 simulationState = SimulationState.EmitContentDone
148 }
149
150 // TODO: This method may advance the global snapshot and should be just a getter
hasPendingChangesnull151 override fun hasPendingChanges(): Boolean {
152 if (recomposer.hasPendingWork || hasPendingChangesInFrame()) {
153 Snapshot.sendApplyNotifications()
154 }
155
156 return recomposer.hasPendingWork
157 }
158
hasPendingMeasureOrLayoutnull159 override fun hasPendingMeasureOrLayout(): Boolean {
160 return owner?.hasPendingMeasureOrLayout ?: false
161 }
162
hasPendingDrawnull163 override fun hasPendingDraw(): Boolean {
164 return view?.isDirty ?: false
165 }
166
167 /**
168 * The reason we have this method is that if a model gets changed in the same frame as created
169 * it won'd trigger pending frame. So [Recompose#hasPendingChanges] stays false. Committing the
170 * current frame does not help either. So we need to check this in order to know if we need to
171 * recompose.
172 */
hasPendingChangesInFramenull173 private fun hasPendingChangesInFrame(): Boolean {
174 return Snapshot.current.hasPendingChanges()
175 }
176
measurenull177 override fun measure() {
178 getView().measure(screenWithSpec, screenHeightSpec)
179 simulationState = SimulationState.MeasureDone
180 }
181
measureWithSpecnull182 override fun measureWithSpec(widthSpec: Int, heightSpec: Int) {
183 getView().measure(widthSpec, heightSpec)
184 simulationState = SimulationState.MeasureDone
185 }
186
drawPreparenull187 override fun drawPrepare() {
188 require(
189 simulationState == SimulationState.LayoutDone ||
190 simulationState == SimulationState.DrawDone
191 ) {
192 "Draw can be only executed after layout or draw, current state is '$simulationState'"
193 }
194 canvas = capture.beginRecording(getView().width, getView().height)
195 simulationState = SimulationState.DrawPrepared
196 }
197
drawnull198 override fun draw() {
199 require(simulationState == SimulationState.DrawPrepared) {
200 "You need to call 'drawPrepare' before calling 'draw'."
201 }
202 getView().draw(canvas!!)
203 simulationState = SimulationState.DrawInProgress
204 }
205
drawFinishnull206 override fun drawFinish() {
207 require(simulationState == SimulationState.DrawInProgress) {
208 "You need to call 'draw' before calling 'drawFinish'."
209 }
210 capture.endRecording()
211 simulationState = SimulationState.DrawDone
212 }
213
drawToBitmapnull214 override fun drawToBitmap() {
215 drawPrepare()
216 draw()
217 drawFinish()
218 }
219
requestLayoutnull220 override fun requestLayout() {
221 getView().requestLayout()
222 }
223
layoutnull224 override fun layout() {
225 require(simulationState == SimulationState.MeasureDone) {
226 "Layout can be only executed after measure, current state is '$simulationState'"
227 }
228 val view = getView()
229 view.layout(
230 /* l= */ 0,
231 /* t= */ 0,
232 /* r= */ view.measuredWidth,
233 /* b= */ view.measuredHeight
234 )
235 simulationState = SimulationState.LayoutDone
236 }
237
recomposenull238 override fun recompose() {
239 if (hasPendingChanges()) {
240 didLastRecomposeHaveChanges = true
241 testCoroutineDispatcher.scheduler.advanceTimeBy(frameClock.frameDelayMillis)
242 testCoroutineDispatcher.scheduler.runCurrent()
243 } else {
244 didLastRecomposeHaveChanges = false
245 }
246 simulationState = SimulationState.RecomposeDone
247 }
248
doFramenull249 override fun doFrame() {
250 if (view == null) {
251 setupContent()
252 }
253
254 recompose()
255
256 measure()
257 layout()
258 drawToBitmap()
259 }
260
invalidateViewsnull261 override fun invalidateViews() {
262 invalidateViews(getView())
263 }
264
disposeContentnull265 override fun disposeContent() {
266 if (view == null) {
267 // Already disposed or never created any content
268 return
269 }
270
271 // Clear the view; this will also dispose the underlying composition
272 // by the default disposal policy. This happens **before** advanceUntilIdle.
273 val rootView = activity.findViewById(android.R.id.content) as ViewGroup
274 rootView.removeAllViews()
275
276 // Dispatcher will clean up the cancelled coroutines when it advances to them
277 testCoroutineDispatcher.scheduler.advanceUntilIdle()
278
279 // Important so we can set the content again.
280 view = null
281 testCase = null
282 simulationState = SimulationState.Initialized
283 }
284
closenull285 override fun close() {
286 recomposer.close()
287 }
288
capturePreviewPictureToActivitynull289 override fun capturePreviewPictureToActivity() {
290 require(measuredWidth > 0 && measuredHeight > 0) {
291 "Preview can't be used on empty view. Did you run measure & layout before calling it?"
292 }
293
294 val picture = Picture()
295 val canvas = picture.beginRecording(getView().measuredWidth, getView().measuredHeight)
296 getView().draw(canvas)
297 picture.endRecording()
298 val imageView = ImageView(activity)
299 val bitmap: Bitmap
300 if (Build.VERSION.SDK_INT >= 28) {
301 bitmap = BitmapHelper.createBitmap(picture)
302 } else {
303 val width = picture.width.coerceAtLeast(1)
304 val height = picture.height.coerceAtLeast(1)
305 bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
306 Canvas(bitmap).drawPicture(picture)
307 }
308 imageView.setImageBitmap(bitmap)
309 activity.setContentView(imageView)
310 }
311
getViewnull312 private fun getView(): View {
313 require(view != null) { "View was not set! Call setupContent first!" }
314 return view!!
315 }
316
getTestCasenull317 override fun getTestCase(): T {
318 return testCase!!
319 }
320
getCoroutineLaunchedCountnull321 override fun getCoroutineLaunchedCount(): Int {
322 return continuationCountInterceptor.continuationCount - InternallyLaunchedCoroutines
323 }
324 }
325
326 private enum class SimulationState {
327 Initialized,
328 TestCaseCreated,
329 EmitContentDone,
330 MeasureDone,
331 LayoutDone,
332 DrawPrepared,
333 DrawInProgress,
334 DrawDone,
335 RecomposeDone
336 }
337
findViewRootForTestnull338 private fun findViewRootForTest(activity: Activity): ViewRootForTest? {
339 return findViewRootForTest(activity.findViewById(android.R.id.content) as ViewGroup)
340 }
341
findViewRootForTestnull342 private fun findViewRootForTest(view: View): ViewRootForTest? {
343 if (view is ViewRootForTest) {
344 return view
345 }
346
347 if (view is ViewGroup) {
348 for (i in 0 until view.childCount) {
349 val composeView = findViewRootForTest(view.getChildAt(i))
350 if (composeView != null) {
351 return composeView
352 }
353 }
354 }
355 return null
356 }
357
invalidateViewsnull358 private fun invalidateViews(view: View) {
359 view.invalidate()
360 if (view is ViewGroup) {
361 for (i in 0 until view.childCount) {
362 val child = view.getChildAt(i)
363 invalidateViews(child)
364 }
365 }
366 }
367
368 // We must separate the use of RenderNode so that it isn't referenced in any
369 // way on platforms that don't have it. This extracts RenderNode use to a
370 // potentially unloaded class, RenderNodeCapture.
371 private interface DrawCapture {
beginRecordingnull372 fun beginRecording(width: Int, height: Int): Canvas
373
374 fun endRecording()
375 }
376
377 @RequiresApi(Build.VERSION_CODES.Q)
378 private class RenderNodeCapture : DrawCapture {
379 private val renderNode = RenderNode("Test")
380
381 override fun beginRecording(width: Int, height: Int): Canvas {
382 renderNode.setPosition(0, 0, width, height)
383 return renderNode.beginRecording()
384 }
385
386 override fun endRecording() {
387 renderNode.endRecording()
388 }
389 }
390
391 private class PictureCapture : DrawCapture {
392 private val picture = Picture()
393
beginRecordingnull394 override fun beginRecording(width: Int, height: Int): Canvas {
395 return picture.beginRecording(width, height)
396 }
397
endRecordingnull398 override fun endRecording() {
399 picture.endRecording()
400 }
401 }
402
403 private class MRenderNodeCapture : DrawCapture {
404 private var renderNode = android.view.RenderNode.create("Test", null)
405
406 private var canvas: DisplayListCanvas? = null
407
beginRecordingnull408 override fun beginRecording(width: Int, height: Int): Canvas {
409 renderNode.setLeftTopRightBottom(0, 0, width, height)
410 canvas = renderNode.start(width, height)
411 return canvas!!
412 }
413
endRecordingnull414 override fun endRecording() {
415 renderNode.end(canvas!!)
416 canvas = null
417 }
418 }
419
420 @RequiresApi(28)
421 private object BitmapHelper {
createBitmapnull422 fun createBitmap(picture: Picture): Bitmap {
423 return Bitmap.createBitmap(picture)
424 }
425 }
426
427 @OptIn(InternalTestApi::class)
428 private class ContinuationCountInterceptor(private val parentInterceptor: ContinuationInterceptor) :
429 DelayPropagatingContinuationInterceptorWrapper(parentInterceptor) {
430 var continuationCount = 0
431 private set
432
interceptContinuationnull433 override fun <T> interceptContinuation(continuation: Continuation<T>): Continuation<T> {
434 continuationCount++
435 return parentInterceptor.interceptContinuation(continuation)
436 }
437
releaseInterceptedContinuationnull438 override fun releaseInterceptedContinuation(continuation: Continuation<*>) {
439 parentInterceptor.releaseInterceptedContinuation(continuation)
440 }
441
resetnull442 fun reset() {
443 continuationCount = 0
444 }
445 }
446
447 private const val InternallyLaunchedCoroutines = 4
448