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