Render View into Bitmap using hardware acceleration (1/2) am: 170bf9e812 am: 70fcf445dd
Original change: https://googleplex-android-review.googlesource.com/c/platform/frameworks/base/+/19289531 Change-Id: I6b8fca72e20020d6561fdc0287eb18d8d91cf075 Signed-off-by: Automerger Merge Worker <android-build-automerger-merge-worker@system.gserviceaccount.com>
This commit is contained in:
committed by
Automerger Merge Worker
commit
2f53dd10ba
@@ -38,6 +38,7 @@ android_library {
|
||||
"androidx.test.espresso.core",
|
||||
"androidx.appcompat_appcompat",
|
||||
"platform-screenshot-diff-core",
|
||||
"guava",
|
||||
],
|
||||
|
||||
kotlincflags: ["-Xjvm-default=all"],
|
||||
|
||||
@@ -19,6 +19,12 @@
|
||||
<item name="android:windowActionBar">false</item>
|
||||
<item name="android:windowNoTitle">true</item>
|
||||
|
||||
<!-- We make the status and navigation bars transparent so that the screenshotted content is
|
||||
not clipped by the status bar height when drawn into the Bitmap (which is what happens
|
||||
given that we draw the view into the Bitmap using hardware acceleration). -->
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
|
||||
<!-- Make sure that device specific cutouts don't impact the outcome of screenshot tests -->
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
</style>
|
||||
|
||||
@@ -24,6 +24,8 @@ import platform.test.screenshot.matchers.MSSIMMatcher
|
||||
import platform.test.screenshot.matchers.PixelPerfectMatcher
|
||||
|
||||
/** Draw this [View] into a [Bitmap]. */
|
||||
// TODO(b/195673633): Remove this once Compose screenshot tests use hardware rendering for their
|
||||
// tests.
|
||||
fun View.drawIntoBitmap(): Bitmap {
|
||||
val bitmap =
|
||||
Bitmap.createBitmap(
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
package com.android.systemui.testing.screenshot
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Rect
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.view.PixelCopy
|
||||
import android.view.SurfaceView
|
||||
import android.view.View
|
||||
import android.view.ViewTreeObserver
|
||||
import android.view.Window
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.concurrent.futures.ResolvableFuture
|
||||
import androidx.test.annotation.ExperimentalTestApi
|
||||
import androidx.test.core.internal.os.HandlerExecutor
|
||||
import androidx.test.platform.graphics.HardwareRendererCompat
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
|
||||
/*
|
||||
* This file was forked from androidx/test/core/view/ViewCapture.kt to add [Window] parameter to
|
||||
* [View.captureToBitmap].
|
||||
* TODO(b/195673633): Remove this fork and use the AndroidX version instead.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Asynchronously captures an image of the underlying view into a [Bitmap].
|
||||
*
|
||||
* For devices below [Build.VERSION_CODES#O] (or if the view's window cannot be determined), the
|
||||
* image is obtained using [View#draw]. Otherwise, [PixelCopy] is used.
|
||||
*
|
||||
* This method will also enable [HardwareRendererCompat#setDrawingEnabled(boolean)] if required.
|
||||
*
|
||||
* This API is primarily intended for use in lower layer libraries or frameworks. For test authors,
|
||||
* its recommended to use espresso or compose's captureToImage.
|
||||
*
|
||||
* This API is currently experimental and subject to change or removal.
|
||||
*/
|
||||
@ExperimentalTestApi
|
||||
@RequiresApi(Build.VERSION_CODES.JELLY_BEAN)
|
||||
fun View.captureToBitmap(window: Window? = null): ListenableFuture<Bitmap> {
|
||||
val bitmapFuture: ResolvableFuture<Bitmap> = ResolvableFuture.create()
|
||||
val mainExecutor = HandlerExecutor(Handler(Looper.getMainLooper()))
|
||||
|
||||
// disable drawing again if necessary once work is complete
|
||||
if (!HardwareRendererCompat.isDrawingEnabled()) {
|
||||
HardwareRendererCompat.setDrawingEnabled(true)
|
||||
bitmapFuture.addListener({ HardwareRendererCompat.setDrawingEnabled(false) }, mainExecutor)
|
||||
}
|
||||
|
||||
mainExecutor.execute {
|
||||
val forceRedrawFuture = forceRedraw()
|
||||
forceRedrawFuture.addListener({ generateBitmap(bitmapFuture, window) }, mainExecutor)
|
||||
}
|
||||
|
||||
return bitmapFuture
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a redraw of the given view.
|
||||
*
|
||||
* Should only be called on UI thread.
|
||||
*
|
||||
* @return a [ListenableFuture] that will be complete once ui drawing is complete
|
||||
*/
|
||||
// NoClassDefFoundError occurs on API 15
|
||||
@RequiresApi(Build.VERSION_CODES.JELLY_BEAN)
|
||||
// @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
|
||||
@ExperimentalTestApi
|
||||
fun View.forceRedraw(): ListenableFuture<Void> {
|
||||
val future: ResolvableFuture<Void> = ResolvableFuture.create()
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 29 && isHardwareAccelerated) {
|
||||
viewTreeObserver.registerFrameCommitCallback() { future.set(null) }
|
||||
} else {
|
||||
viewTreeObserver.addOnDrawListener(
|
||||
object : ViewTreeObserver.OnDrawListener {
|
||||
var handled = false
|
||||
override fun onDraw() {
|
||||
if (!handled) {
|
||||
handled = true
|
||||
future.set(null)
|
||||
// cannot remove on draw listener inside of onDraw
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
viewTreeObserver.removeOnDrawListener(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
invalidate()
|
||||
return future
|
||||
}
|
||||
|
||||
private fun View.generateBitmap(
|
||||
bitmapFuture: ResolvableFuture<Bitmap>,
|
||||
window: Window? = null,
|
||||
) {
|
||||
if (bitmapFuture.isCancelled) {
|
||||
return
|
||||
}
|
||||
val destBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
when {
|
||||
Build.VERSION.SDK_INT < 26 -> generateBitmapFromDraw(destBitmap, bitmapFuture)
|
||||
this is SurfaceView -> generateBitmapFromSurfaceViewPixelCopy(destBitmap, bitmapFuture)
|
||||
else -> {
|
||||
val window = window ?: getActivity()?.window
|
||||
if (window != null) {
|
||||
generateBitmapFromPixelCopy(window, destBitmap, bitmapFuture)
|
||||
} else {
|
||||
Log.i(
|
||||
"View.captureToImage",
|
||||
"Could not find window for view. Falling back to View#draw instead of PixelCopy"
|
||||
)
|
||||
generateBitmapFromDraw(destBitmap, bitmapFuture)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("NewApi")
|
||||
private fun SurfaceView.generateBitmapFromSurfaceViewPixelCopy(
|
||||
destBitmap: Bitmap,
|
||||
bitmapFuture: ResolvableFuture<Bitmap>
|
||||
) {
|
||||
val onCopyFinished =
|
||||
PixelCopy.OnPixelCopyFinishedListener { result ->
|
||||
if (result == PixelCopy.SUCCESS) {
|
||||
bitmapFuture.set(destBitmap)
|
||||
} else {
|
||||
bitmapFuture.setException(
|
||||
RuntimeException(String.format("PixelCopy failed: %d", result))
|
||||
)
|
||||
}
|
||||
}
|
||||
PixelCopy.request(this, null, destBitmap, onCopyFinished, handler)
|
||||
}
|
||||
|
||||
internal fun View.generateBitmapFromDraw(
|
||||
destBitmap: Bitmap,
|
||||
bitmapFuture: ResolvableFuture<Bitmap>
|
||||
) {
|
||||
destBitmap.density = resources.displayMetrics.densityDpi
|
||||
computeScroll()
|
||||
val canvas = Canvas(destBitmap)
|
||||
canvas.translate((-scrollX).toFloat(), (-scrollY).toFloat())
|
||||
draw(canvas)
|
||||
bitmapFuture.set(destBitmap)
|
||||
}
|
||||
|
||||
private fun View.getActivity(): Activity? {
|
||||
fun Context.getActivity(): Activity? {
|
||||
return when (this) {
|
||||
is Activity -> this
|
||||
is ContextWrapper -> this.baseContext.getActivity()
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
return context.getActivity()
|
||||
}
|
||||
|
||||
private fun View.generateBitmapFromPixelCopy(
|
||||
window: Window,
|
||||
destBitmap: Bitmap,
|
||||
bitmapFuture: ResolvableFuture<Bitmap>
|
||||
) {
|
||||
val locationInWindow = intArrayOf(0, 0)
|
||||
getLocationInWindow(locationInWindow)
|
||||
val x = locationInWindow[0]
|
||||
val y = locationInWindow[1]
|
||||
val boundsInWindow = Rect(x, y, x + width, y + height)
|
||||
|
||||
return window.generateBitmapFromPixelCopy(boundsInWindow, destBitmap, bitmapFuture)
|
||||
}
|
||||
@@ -18,10 +18,22 @@ package com.android.systemui.testing.screenshot
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Dialog
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.HardwareRenderer
|
||||
import android.os.Looper
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewGroup.LayoutParams
|
||||
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
||||
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
import android.view.Window
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.test.espresso.Espresso
|
||||
import androidx.test.ext.junit.rules.ActivityScenarioRule
|
||||
import com.google.common.util.concurrent.FutureCallback
|
||||
import com.google.common.util.concurrent.Futures
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.rules.RuleChain
|
||||
import org.junit.rules.TestRule
|
||||
@@ -59,29 +71,39 @@ class ViewScreenshotTestRule(emulationSpec: DeviceEmulationSpec) : TestRule {
|
||||
*/
|
||||
fun screenshotTest(
|
||||
goldenIdentifier: String,
|
||||
layoutParams: LayoutParams =
|
||||
LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT),
|
||||
viewProvider: (Activity) -> View,
|
||||
mode: Mode = Mode.WrapContent,
|
||||
viewProvider: (ComponentActivity) -> View,
|
||||
) {
|
||||
activityRule.scenario.onActivity { activity ->
|
||||
// Make sure that the activity draws full screen and fits the whole display instead of
|
||||
// the system bars.
|
||||
activity.window.setDecorFitsSystemWindows(false)
|
||||
activity.setContentView(viewProvider(activity), layoutParams)
|
||||
val window = activity.window
|
||||
window.setDecorFitsSystemWindows(false)
|
||||
|
||||
// Set the content.
|
||||
activity.setContentView(viewProvider(activity), mode.layoutParams)
|
||||
|
||||
// Elevation/shadows is not deterministic when doing hardware rendering, so we disable
|
||||
// it for any view in the hierarchy.
|
||||
window.decorView.removeElevationRecursively()
|
||||
}
|
||||
|
||||
// We call onActivity again because it will make sure that our Activity is done measuring,
|
||||
// laying out and drawing its content (that we set in the previous onActivity lambda).
|
||||
var contentView: View? = null
|
||||
activityRule.scenario.onActivity { activity ->
|
||||
// Check that the content is what we expected.
|
||||
val content = activity.requireViewById<ViewGroup>(android.R.id.content)
|
||||
assertEquals(1, content.childCount)
|
||||
screenshotRule.assertBitmapAgainstGolden(
|
||||
content.getChildAt(0).drawIntoBitmap(),
|
||||
goldenIdentifier,
|
||||
matcher
|
||||
)
|
||||
contentView = content.getChildAt(0)
|
||||
}
|
||||
|
||||
val bitmap = contentView?.toBitmap() ?: error("contentView is null")
|
||||
screenshotRule.assertBitmapAgainstGolden(
|
||||
bitmap,
|
||||
goldenIdentifier,
|
||||
matcher,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,25 +126,78 @@ class ViewScreenshotTestRule(emulationSpec: DeviceEmulationSpec) : TestRule {
|
||||
create()
|
||||
window.setWindowAnimations(0)
|
||||
|
||||
// Elevation/shadows is not deterministic when doing hardware rendering, so we
|
||||
// disable it for any view in the hierarchy.
|
||||
window.decorView.removeElevationRecursively()
|
||||
|
||||
// Show the dialog.
|
||||
show()
|
||||
}
|
||||
}
|
||||
|
||||
// We call onActivity again because it will make sure that our Dialog is done measuring,
|
||||
// laying out and drawing its content (that we set in the previous onActivity lambda).
|
||||
activityRule.scenario.onActivity {
|
||||
// Check that the content is what we expected.
|
||||
val dialog = dialog ?: error("dialog is null")
|
||||
try {
|
||||
screenshotRule.assertBitmapAgainstGolden(
|
||||
dialog.window.decorView.drawIntoBitmap(),
|
||||
goldenIdentifier,
|
||||
matcher,
|
||||
try {
|
||||
val bitmap = dialog?.toBitmap() ?: error("dialog is null")
|
||||
screenshotRule.assertBitmapAgainstGolden(
|
||||
bitmap,
|
||||
goldenIdentifier,
|
||||
matcher,
|
||||
)
|
||||
} finally {
|
||||
dialog?.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private fun View.removeElevationRecursively() {
|
||||
this.elevation = 0f
|
||||
|
||||
if (this is ViewGroup) {
|
||||
repeat(childCount) { i -> getChildAt(i).removeElevationRecursively() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun Dialog.toBitmap(): Bitmap {
|
||||
val window = window
|
||||
return window.decorView.toBitmap(window)
|
||||
}
|
||||
|
||||
private fun View.toBitmap(window: Window? = null): Bitmap {
|
||||
if (Looper.getMainLooper() == Looper.myLooper()) {
|
||||
error("toBitmap() can't be called from the main thread")
|
||||
}
|
||||
|
||||
if (!HardwareRenderer.isDrawingEnabled()) {
|
||||
error("Hardware rendering is not enabled")
|
||||
}
|
||||
|
||||
// Make sure we are idle.
|
||||
Espresso.onIdle()
|
||||
|
||||
val mainExecutor = context.mainExecutor
|
||||
return runBlocking {
|
||||
suspendCoroutine { continuation ->
|
||||
Futures.addCallback(
|
||||
captureToBitmap(window),
|
||||
object : FutureCallback<Bitmap> {
|
||||
override fun onSuccess(result: Bitmap?) {
|
||||
continuation.resumeWith(Result.success(result!!))
|
||||
}
|
||||
|
||||
override fun onFailure(t: Throwable) {
|
||||
continuation.resumeWith(Result.failure(t))
|
||||
}
|
||||
},
|
||||
// We know that we are not on the main thread, so we can block the current
|
||||
// thread and wait for the result in the main thread.
|
||||
mainExecutor,
|
||||
)
|
||||
} finally {
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class Mode(val layoutParams: LayoutParams) {
|
||||
WrapContent(LayoutParams(WRAP_CONTENT, WRAP_CONTENT)),
|
||||
MatchSize(LayoutParams(MATCH_PARENT, MATCH_PARENT)),
|
||||
MatchWidth(LayoutParams(MATCH_PARENT, WRAP_CONTENT)),
|
||||
MatchHeight(LayoutParams(WRAP_CONTENT, MATCH_PARENT)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.android.systemui.testing.screenshot
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Rect
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.PixelCopy
|
||||
import android.view.Window
|
||||
import androidx.concurrent.futures.ResolvableFuture
|
||||
|
||||
/*
|
||||
* This file was forked from androidx/test/core/view/WindowCapture.kt.
|
||||
* TODO(b/195673633): Remove this fork and use the AndroidX version instead.
|
||||
*/
|
||||
fun Window.generateBitmapFromPixelCopy(
|
||||
boundsInWindow: Rect? = null,
|
||||
destBitmap: Bitmap,
|
||||
bitmapFuture: ResolvableFuture<Bitmap>
|
||||
) {
|
||||
val onCopyFinished =
|
||||
PixelCopy.OnPixelCopyFinishedListener { result ->
|
||||
if (result == PixelCopy.SUCCESS) {
|
||||
bitmapFuture.set(destBitmap)
|
||||
} else {
|
||||
bitmapFuture.setException(
|
||||
RuntimeException(String.format("PixelCopy failed: %d", result))
|
||||
)
|
||||
}
|
||||
}
|
||||
PixelCopy.request(
|
||||
this,
|
||||
boundsInWindow,
|
||||
destBitmap,
|
||||
onCopyFinished,
|
||||
Handler(Looper.getMainLooper())
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user