1 /*
<lambda>null2  * Copyright 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 @file:JvmName("ViewCapture")
17 
18 package platform.test.screenshot
19 
20 import android.annotation.SuppressLint
21 import android.graphics.Bitmap
22 import android.graphics.Canvas
23 import android.graphics.Rect
24 import android.os.Build
25 import android.os.Handler
26 import android.os.Looper
27 import android.util.Log
28 import android.view.PixelCopy
29 import android.view.Surface
30 import android.view.SurfaceView
31 import android.view.View
32 import android.view.ViewTreeObserver.OnDrawListener
33 import android.view.WindowManager
34 import androidx.annotation.RequiresApi
35 import androidx.concurrent.futures.SuspendToFutureAdapter
36 import androidx.test.core.internal.os.HandlerExecutor
37 import androidx.test.internal.platform.ServiceLoaderWrapper
38 import androidx.test.internal.platform.os.ControlledLooper
39 import androidx.test.internal.platform.os.ControlledLooperSingleton
40 import androidx.test.internal.platform.reflect.ReflectiveField
41 import androidx.test.internal.platform.reflect.ReflectiveMethod
42 import androidx.test.internal.util.Checks.checkState
43 import androidx.test.platform.graphics.HardwareRendererCompat
44 import com.google.common.util.concurrent.ListenableFuture
45 import java.util.function.Consumer
46 import kotlin.coroutines.resume
47 import kotlin.coroutines.resumeWithException
48 import kotlinx.coroutines.CoroutineScope
49 import kotlinx.coroutines.Dispatchers
50 import kotlinx.coroutines.android.asCoroutineDispatcher
51 import kotlinx.coroutines.job
52 import kotlinx.coroutines.launch
53 import kotlinx.coroutines.suspendCancellableCoroutine
54 
55 /**
56  * Suspend function for capturing an image of the underlying view into a [Bitmap].
57  *
58  * For devices below [Build.VERSION_CODES#O] (or if the view's window cannot be determined), the
59  * image is obtained using [View#draw]. Otherwise, [PixelCopy] is used.
60  *
61  * This method will also enable [HardwareRendererCompat#setDrawingEnabled(boolean)] if required.
62  *
63  * This API is primarily intended for use in lower layer libraries or frameworks. For test authors,
64  * it's recommended to use Espresso or Compose's captureToImage.
65  *
66  * If a rect is supplied, this will further crop locally from the bounds of the given view. For
67  * example, if the given view is at (10, 10 - 30, 30) and the rect is (5, 5 - 10, 10), the final
68  * bitmap will be a 5x5 bitmap that spans (15, 15 - 20, 20). This is particularly useful for
69  * Compose, which only has a singular view that contains a hierarchy of nodes.
70  *
71  * This API must be called on the View's handler thread. If you're calling this from another
72  * context, eg directly from the test thread, you can use something like
73  * <pre>{@code
74  *   runBlocking(view.handler.asCoroutineDispatcher()) {
75  *     withTimeout(10.seconds) {
76  *       view.captureToBitmap(rect)
77  *     }
78  *   }
79  * }</pre>
80  *
81  * This API is currently experimental and subject to change or removal.
82  */
83 suspend fun View.captureToBitmap(rect: Rect? = null): Bitmap {
84     val mainHandlerDispatcher = Handler(Looper.getMainLooper()).asCoroutineDispatcher()
85     var bitmap: Bitmap? = null
86     ControlledLooperSingleton.getInstance().drainMainThreadUntilIdle()
87     val job =
88         CoroutineScope(mainHandlerDispatcher).launch {
89             val hardwareDrawingEnabled = HardwareRendererCompat.isDrawingEnabled()
90             HardwareRendererCompat.setDrawingEnabled(true)
91             // TODO(b/238919862#comment2): temporarily skip forceRedraw on robolectric until
92             // robolectric
93             //  adds proper support for listing to draw events
94             if (!Build.FINGERPRINT.contains("robolectric")) {
95                 forceRedraw()
96             }
97             bitmap = generateBitmap(rect)
98             HardwareRendererCompat.setDrawingEnabled(hardwareDrawingEnabled)
99         }
100 
101     ControlledLooperSingleton.getInstance().drainMainThreadUntilIdle()
102     job.join()
103 
104     return bitmap!!
105 }
106 
getControlledLoopernull107 private fun getControlledLooper(): ControlledLooper {
108     return ServiceLoaderWrapper.loadSingleService(ControlledLooper::class.java) {
109         ControlledLooper.NO_OP_CONTROLLED_LOOPER
110     }
111 }
112 
113 /** A ListenableFuture variant of captureToBitmap intended for use from Java. */
Viewnull114 fun View.captureToBitmapAsync(rect: Rect? = null): ListenableFuture<Bitmap> {
115     return SuspendToFutureAdapter.launchFuture(Dispatchers.Main) { captureToBitmap(rect) }
116 }
117 
118 /**
119  * Trigger a redraw of the given view.
120  *
121  * Should only be called on UI thread.
122  */
123 // TODO(b/316921934): uncomment once @ExperimentalTestApi is removed
124 // @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
forceRedrawnull125 suspend fun View.forceRedraw() {
126     checkState(handler.looper.isCurrentThread, "Must be called from view's handler thread")
127     if (Build.FINGERPRINT.contains("robolectric")) {
128         Log.i("ViewCapture", "Skipping forceRedraw as it is not supported")
129         return
130     }
131 
132     var drawListener: OnDrawListener? = null
133     try {
134         return suspendCancellableCoroutine<Unit> { cont ->
135             if (Build.VERSION.SDK_INT >= 29 && isHardwareAccelerated) {
136                 viewTreeObserver.registerFrameCommitCallback() { cont.resume(Unit) }
137             } else {
138                 drawListener =
139                     object : OnDrawListener {
140                         var handled = false
141 
142                         override fun onDraw() {
143                             if (!handled) {
144                                 handled = true
145                                 cont.resume(Unit)
146                             }
147                         }
148                     }
149                 viewTreeObserver.addOnDrawListener(drawListener)
150             }
151             invalidate()
152         }
153     } finally {
154         // cannot remove on draw listener inside of onDraw
155         if (drawListener != null) {
156             viewTreeObserver.removeOnDrawListener(drawListener)
157         }
158     }
159 }
160 
generateBitmapnull161 private suspend fun View.generateBitmap(rect: Rect? = null): Bitmap {
162     var rectWidth = rect?.width() ?: width
163     var rectHeight = rect?.height() ?: height
164 
165     // Throttling to wait for the readiness of `rectWidth` and `rectHeight`.
166     val maximumPixelNumbers = 1_000_000_000L
167     val milliSecondsInTenSeconds = 10L * 1000L
168     var milliSecondsToWait = 10L
169     while (rectWidth.toLong() * rectHeight.toLong() > maximumPixelNumbers
170         && milliSecondsToWait < milliSecondsInTenSeconds) {
171         Log.d("View.generateBitmap",
172             "Current width ($rectWidth) or current height ($rectHeight) is too large"
173                 + ", which might indicate the view is not stable yet. "
174                 + "Wait for $milliSecondsToWait milliseconds to retry.")
175         Thread.sleep(milliSecondsToWait)
176         milliSecondsToWait = milliSecondsToWait * 2
177         rectWidth = rect?.width() ?: width
178         rectHeight = rect?.height() ?: height
179     }
180     if (rectWidth.toLong() * rectHeight.toLong() > maximumPixelNumbers) {
181         throw IllegalStateException(
182             "The view $this is not stable within $milliSecondsInTenSeconds milliseconds.")
183     }
184 
185     val destBitmap = Bitmap.createBitmap(rectWidth, rectHeight, Bitmap.Config.ARGB_8888)
186 
187     return when {
188         Build.VERSION.SDK_INT < 26 -> generateBitmapFromDraw(destBitmap, rect)
189         Build.VERSION.SDK_INT >= 34 -> generateBitmapFromPixelCopy(destBitmap, rect)
190         this is SurfaceView -> generateBitmapFromSurfaceViewPixelCopy(destBitmap, rect)
191         else -> generateBitmapFromPixelCopy(this.getSurface(), destBitmap, rect)
192     }
193 }
194 
195 @RequiresApi(Build.VERSION_CODES.O)
generateBitmapFromSurfaceViewPixelCopynull196 private suspend fun SurfaceView.generateBitmapFromSurfaceViewPixelCopy(
197     destBitmap: Bitmap,
198     rect: Rect?,
199 ): Bitmap {
200     return suspendCancellableCoroutine<Bitmap> { cont ->
201         val onCopyFinished =
202             PixelCopy.OnPixelCopyFinishedListener { result ->
203                 if (result == PixelCopy.SUCCESS) {
204                     cont.resume(destBitmap)
205                 } else {
206                     cont.resumeWithException(
207                         RuntimeException(String.format("PixelCopy failed: %d", result))
208                     )
209                 }
210             }
211         PixelCopy.request(this, rect, destBitmap, onCopyFinished, handler)
212     }
213 }
214 
generateBitmapFromDrawnull215 internal fun View.generateBitmapFromDraw(destBitmap: Bitmap, rect: Rect?): Bitmap {
216     destBitmap.density = resources.displayMetrics.densityDpi
217     computeScroll()
218     val canvas = Canvas(destBitmap)
219     canvas.translate((-scrollX).toFloat(), (-scrollY).toFloat())
220     if (rect != null) {
221         canvas.translate((-rect.left).toFloat(), (-rect.top).toFloat())
222     }
223 
224     draw(canvas)
225     return destBitmap
226 }
227 
228 /**
229  * Generates a bitmap from the given surface using [PixelCopy].
230  *
231  * This method is effectively the backwards compatibility version of android U's
232  * [PixelCopy.ofWindow(View)], and will be called when running on Android API levels O to T.
233  */
234 @RequiresApi(Build.VERSION_CODES.O)
generateBitmapFromPixelCopynull235 private suspend fun View.generateBitmapFromPixelCopy(
236     surface: Surface,
237     destBitmap: Bitmap,
238     rect: Rect?,
239 ): Bitmap {
240     return suspendCancellableCoroutine<Bitmap> { cont ->
241         var bounds = getBoundsInSurface()
242         if (rect != null) {
243             bounds =
244                 Rect(
245                     bounds.left + rect.left,
246                     bounds.top + rect.top,
247                     bounds.left + rect.right,
248                     bounds.top + rect.bottom,
249                 )
250         }
251         val onCopyFinished =
252             PixelCopy.OnPixelCopyFinishedListener { result ->
253                 if (result == PixelCopy.SUCCESS) {
254                     cont.resume(destBitmap)
255                 } else {
256                     cont.resumeWithException(RuntimeException("PixelCopy failed: $result"))
257                 }
258             }
259         PixelCopy.request(
260             surface,
261             bounds,
262             destBitmap,
263             onCopyFinished,
264             Handler(Looper.getMainLooper())
265         )
266     }
267 }
268 
269 /** Returns the Rect indicating the View's coordinates within its containing window. */
getBoundsInWindownull270 private fun View.getBoundsInWindow(): Rect {
271     val locationInWindow = intArrayOf(0, 0)
272     getLocationInWindow(locationInWindow)
273     val x = locationInWindow[0]
274     val y = locationInWindow[1]
275     return Rect(x, y, x + width, y + height)
276 }
277 
278 /** Returns the Rect indicating the View's coordinates within its containing surface. */
getBoundsInSurfacenull279 private fun View.getBoundsInSurface(): Rect {
280     val locationInSurface = intArrayOf(0, 0)
281     if (Build.VERSION.SDK_INT < 29) {
282         reflectivelyGetLocationInSurface(locationInSurface)
283     } else {
284         getLocationInSurface(locationInSurface)
285     }
286     val x = locationInSurface[0]
287     val y = locationInSurface[1]
288     val bounds = Rect(x, y, x + width, y + height)
289 
290     Log.d("ViewCapture", "getBoundsInSurface $bounds")
291 
292     return bounds
293 }
294 
getSurfacenull295 private fun View.getSurface(): Surface {
296     // copy the implementation of API 34's PixelCopy.ofWindow to get the surface from view
297     val viewRootImpl = ReflectiveMethod<Any>(View::class.java, "getViewRootImpl").invoke(this)
298     return ReflectiveField<Surface>("android.view.ViewRootImpl", "mSurface").get(viewRootImpl)
299 }
300 
301 /**
302  * The backwards compatible version of API 29's [View.getLocationInSurface].
303  *
304  * It makes a best effort attempt to replicate the API 29 logic.
305  */
306 @SuppressLint("NewApi")
reflectivelyGetLocationInSurfacenull307 private fun View.reflectivelyGetLocationInSurface(locationInSurface: IntArray) {
308     // copy the implementation of API 29+ getLocationInSurface
309     getLocationInWindow(locationInSurface)
310     if (Build.VERSION.SDK_INT < 28) {
311         val viewRootImpl = ReflectiveMethod<Any>(View::class.java, "getViewRootImpl").invoke(this)
312         val windowAttributes =
313             ReflectiveField<WindowManager.LayoutParams>(
314                     "android.view.ViewRootImpl",
315                     "mWindowAttributes"
316                 )
317                 .get(viewRootImpl)
318         val surfaceInsets =
319             ReflectiveField<Rect>(WindowManager.LayoutParams::class.java, "surfaceInsets")
320                 .get(windowAttributes)
321         locationInSurface[0] += surfaceInsets.left
322         locationInSurface[1] += surfaceInsets.top
323     } else {
324         // ART restrictions introduced in API 29 disallow reflective access to mWindowAttributes
325         Log.w(
326             "ViewCapture",
327             "Could not calculate offset of view in surface on API 28," +
328                 " resulting image may have incorrect positioning",
329         )
330     }
331 }
332 
generateBitmapFromPixelCopynull333 private suspend fun View.generateBitmapFromPixelCopy(
334     destBitmap: Bitmap,
335     rect: Rect? = null,
336 ): Bitmap {
337     return suspendCancellableCoroutine<Bitmap> { cont ->
338         val request =
339             PixelCopy.Request.Builder.ofWindow(this)
340                 .setSourceRect(rect ?: getBoundsInWindow())
341                 .setDestinationBitmap(destBitmap)
342                 .build()
343         val onCopyFinished =
344             Consumer<PixelCopy.Result> { result ->
345                 if (result.status == PixelCopy.SUCCESS) {
346                     cont.resume(result.bitmap)
347                 } else {
348                     cont.resumeWithException(RuntimeException("PixelCopy failed: $(result.status)"))
349                 }
350             }
351         PixelCopy.request(request, HandlerExecutor(handler), onCopyFinished)
352     }
353 }
354