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