summaryrefslogtreecommitdiff
path: root/libraries/screenshot/src/main/java/platform/test
diff options
context:
space:
mode:
Diffstat (limited to 'libraries/screenshot/src/main/java/platform/test')
-rw-r--r--libraries/screenshot/src/main/java/platform/test/screenshot/Bitmap.kt66
-rw-r--r--libraries/screenshot/src/main/java/platform/test/screenshot/DefaultDeviceEmulationSpec.kt47
-rw-r--r--libraries/screenshot/src/main/java/platform/test/screenshot/ExternalViewScreenshotTestRule.kt100
-rw-r--r--libraries/screenshot/src/main/java/platform/test/screenshot/ScreenshotActivity.kt28
-rw-r--r--libraries/screenshot/src/main/java/platform/test/screenshot/ScreenshotTestRule.kt44
-rw-r--r--libraries/screenshot/src/main/java/platform/test/screenshot/TestAppComponentFactory.kt60
-rw-r--r--libraries/screenshot/src/main/java/platform/test/screenshot/View.kt45
-rw-r--r--libraries/screenshot/src/main/java/platform/test/screenshot/ViewCapture.kt233
-rw-r--r--libraries/screenshot/src/main/java/platform/test/screenshot/ViewScreenshotTestRule.kt168
-rw-r--r--libraries/screenshot/src/main/java/platform/test/screenshot/WindowCapture.kt37
10 files changed, 809 insertions, 19 deletions
diff --git a/libraries/screenshot/src/main/java/platform/test/screenshot/Bitmap.kt b/libraries/screenshot/src/main/java/platform/test/screenshot/Bitmap.kt
new file mode 100644
index 000000000..bdaf1f1ef
--- /dev/null
+++ b/libraries/screenshot/src/main/java/platform/test/screenshot/Bitmap.kt
@@ -0,0 +1,66 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package platform.test.screenshot
+
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.os.Build
+import android.view.View
+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(
+ measuredWidth,
+ measuredHeight,
+ Bitmap.Config.ARGB_8888,
+ )
+ val canvas = Canvas(bitmap)
+ draw(canvas)
+ return bitmap
+}
+
+/**
+ * The [BitmapMatcher][platform.test.screenshot.matchers.BitmapMatcher] that should be used for
+ * screenshot *unit* tests.
+ */
+val UnitTestBitmapMatcher =
+ if (Build.CPU_ABI == "x86_64") {
+ // Different CPU architectures can sometimes end up rendering differently, so we can't do
+ // pixel-perfect matching on different architectures using the same golden. Given that our
+ // presubmits are run on cf_x86_64_phone, our goldens should be perfectly matched on the
+ // x86_64 architecture and use the Structural Similarity Index on others.
+ // TODO(b/237511747): Run our screenshot presubmit tests on arm64 instead so that we can
+ // do pixel perfect matching both at presubmit time and at development time with actual
+ // devices.
+ PixelPerfectMatcher()
+ } else {
+ MSSIMMatcher()
+ }
+
+/**
+ * The [BitmapMatcher][platform.test.screenshot.matchers.BitmapMatcher] that should be used for
+ * screenshot *unit* tests.
+ *
+ * We use the Structural Similarity Index for integration tests because they usually contain
+ * additional information and noise that shouldn't break the test.
+ */
+val IntegrationTestBitmapMatcher = MSSIMMatcher()
diff --git a/libraries/screenshot/src/main/java/platform/test/screenshot/DefaultDeviceEmulationSpec.kt b/libraries/screenshot/src/main/java/platform/test/screenshot/DefaultDeviceEmulationSpec.kt
new file mode 100644
index 000000000..4dc11f64f
--- /dev/null
+++ b/libraries/screenshot/src/main/java/platform/test/screenshot/DefaultDeviceEmulationSpec.kt
@@ -0,0 +1,47 @@
+package platform.test.screenshot
+
+/**
+ * The emulations specs for all 8 permutations of:
+ * - phone or tablet.
+ * - dark of light mode.
+ * - portrait or landscape.
+ */
+val DeviceEmulationSpec.Companion.PhoneAndTabletFull
+ get() = PhoneAndTabletFullSpec
+
+private val PhoneAndTabletFullSpec =
+ DeviceEmulationSpec.forDisplays(Displays.Phone, Displays.Tablet)
+
+/**
+ * The emulations specs of:
+ * - phone + light mode + portrait.
+ * - phone + light mode + landscape.
+ * - tablet + dark mode + portrait.
+ *
+ * This allows to test the most important permutations of a screen/layout with only 3
+ * configurations.
+ */
+val DeviceEmulationSpec.Companion.PhoneAndTabletMinimal
+ get() = PhoneAndTabletMinimalSpec
+
+private val PhoneAndTabletMinimalSpec =
+ DeviceEmulationSpec.forDisplays(Displays.Phone, isDarkTheme = false) +
+ DeviceEmulationSpec.forDisplays(Displays.Tablet, isDarkTheme = true, isLandscape = false)
+
+object Displays {
+ val Phone =
+ DisplaySpec(
+ "phone",
+ width = 1440,
+ height = 3120,
+ densityDpi = 560,
+ )
+
+ val Tablet =
+ DisplaySpec(
+ "tablet",
+ width = 2560,
+ height = 1600,
+ densityDpi = 320,
+ )
+}
diff --git a/libraries/screenshot/src/main/java/platform/test/screenshot/ExternalViewScreenshotTestRule.kt b/libraries/screenshot/src/main/java/platform/test/screenshot/ExternalViewScreenshotTestRule.kt
new file mode 100644
index 000000000..37fa024f6
--- /dev/null
+++ b/libraries/screenshot/src/main/java/platform/test/screenshot/ExternalViewScreenshotTestRule.kt
@@ -0,0 +1,100 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package platform.test.screenshot
+
+import android.app.Activity
+import android.graphics.Color
+import android.view.View
+import android.view.Window
+import android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.WindowInsetsControllerCompat
+import androidx.core.view.WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
+import androidx.test.platform.app.InstrumentationRegistry
+import org.junit.rules.RuleChain
+import org.junit.rules.TestRule
+import org.junit.runner.Description
+import org.junit.runners.model.Statement
+
+/**
+ * A rule that allows to run a screenshot diff test on a view that is hosted in another activity.
+ */
+class ExternalViewScreenshotTestRule(
+ emulationSpec: DeviceEmulationSpec,
+ pathManager: GoldenImagePathManager
+) : TestRule {
+
+ private val colorsRule = MaterialYouColorsRule()
+ private val deviceEmulationRule = DeviceEmulationRule(emulationSpec)
+ private val screenshotRule = ScreenshotTestRule(pathManager)
+ private val delegateRule =
+ RuleChain.outerRule(colorsRule).around(deviceEmulationRule).around(screenshotRule)
+ private val matcher = UnitTestBitmapMatcher
+
+ override fun apply(base: Statement, description: Description): Statement {
+ return delegateRule.apply(base, description)
+ }
+
+ /**
+ * Compare the content of the [view] with the golden image identified by [goldenIdentifier] in
+ * the context of [emulationSpec]. Window must be specified to capture views that render
+ * hardware buffers.
+ */
+ fun screenshotTest(goldenIdentifier: String, view: View, window: Window? = null) {
+ view.removeElevationRecursively()
+
+ ScreenshotRuleAsserter.Builder(screenshotRule)
+ .setScreenshotProvider { view.toBitmap(window) }
+ .withMatcher(matcher)
+ .build()
+ .assertGoldenImage(goldenIdentifier)
+ }
+
+ /**
+ * Compare the content of the [activity] with the golden image identified by [goldenIdentifier]
+ * in the context of [emulationSpec].
+ */
+ fun activityScreenshotTest(
+ goldenIdentifier: String,
+ activity: Activity,
+ ) {
+ val rootView = activity.window.decorView
+
+ // Hide system bars, remove insets, focus and make sure device-specific cutouts
+ // don't affect screenshots
+ InstrumentationRegistry.getInstrumentation().runOnMainSync {
+ val window = activity.window
+ window.setDecorFitsSystemWindows(false)
+ WindowInsetsControllerCompat(window, rootView).apply {
+ hide(WindowInsetsCompat.Type.systemBars())
+ systemBarsBehavior = BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
+ }
+
+ window.statusBarColor = Color.TRANSPARENT
+ window.navigationBarColor = Color.TRANSPARENT
+ window.attributes =
+ window.attributes.apply {
+ layoutInDisplayCutoutMode = LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
+ }
+
+ rootView.removeInsetsRecursively()
+ activity.currentFocus?.clearFocus()
+ }
+
+ screenshotTest(goldenIdentifier, rootView, activity.window)
+ }
+}
diff --git a/libraries/screenshot/src/main/java/platform/test/screenshot/ScreenshotActivity.kt b/libraries/screenshot/src/main/java/platform/test/screenshot/ScreenshotActivity.kt
new file mode 100644
index 000000000..1570b1f46
--- /dev/null
+++ b/libraries/screenshot/src/main/java/platform/test/screenshot/ScreenshotActivity.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package platform.test.screenshot
+
+import androidx.activity.ComponentActivity
+
+/**
+ * The Activity that is launched and whose content is set for screenshot tests. Please add the
+ * following snippet to your test's AndroidManifest.xml
+ *
+ * <activity android:name="platform.test.screenshot.ScreenshotActivity" android:exported="true">
+ * </activity>
+ */
+class ScreenshotActivity : ComponentActivity()
diff --git a/libraries/screenshot/src/main/java/platform/test/screenshot/ScreenshotTestRule.kt b/libraries/screenshot/src/main/java/platform/test/screenshot/ScreenshotTestRule.kt
index 9c529c0c6..cffb5d3fd 100644
--- a/libraries/screenshot/src/main/java/platform/test/screenshot/ScreenshotTestRule.kt
+++ b/libraries/screenshot/src/main/java/platform/test/screenshot/ScreenshotTestRule.kt
@@ -215,17 +215,17 @@ open class ScreenshotTestRule(
ScreenshotResultProto.DiffResult.Status.FAILED
}
- reportResult(
- status = status,
- assetsPathRelativeToRepo = goldenImagePathManager.assetsPathRelativeToBuildRoot,
- goldenIdentifier = goldenIdentifier,
- actual = actual,
- comparisonStatistics = comparisonResult.comparisonStatistics,
- expected = highlightedBitmap(expected, regions),
- diff = comparisonResult.diff
- )
-
if (!comparisonResult.matches) {
+ reportResult(
+ status = status,
+ assetsPathRelativeToRepo = goldenImagePathManager.assetsPathRelativeToBuildRoot,
+ goldenIdentifier = goldenIdentifier,
+ actual = actual,
+ comparisonStatistics = comparisonResult.comparisonStatistics,
+ expected = highlightedBitmap(expected, regions),
+ diff = comparisonResult.diff
+ )
+
throw AssertionError(
"Image mismatch! Comparison stats: '${comparisonResult
.comparisonStatistics}'"
@@ -330,6 +330,12 @@ open class ScreenshotTestRule(
}
var file = getPathOnDeviceFor(fileType, goldenIdentifier)
+ if (file.exists()) {
+ // This typically happens when in one test, the same golden image was repeatedly
+ // compared with. In this scenario, multiple actual/expected/diff images with same
+ // names will be attempted to write to the device.
+ return file
+ }
try {
FileOutputStream(file).use {
writeAction(it)
@@ -419,7 +425,9 @@ typealias BitmapSupplier = () -> Bitmap
/**
* Implements a screenshot asserter based on the ScreenshotRule
*/
-class ScreenshotRuleAsserter private constructor(private val rule: ScreenshotTestRule) : ScreenshotAsserter {
+class ScreenshotRuleAsserter private constructor(
+ private val rule: ScreenshotTestRule
+) : ScreenshotAsserter {
// use the most constraining matcher as default
private var matcher: BitmapMatcher = PixelPerfectMatcher()
private var beforeScreenshot: Runnable? = null
@@ -427,22 +435,20 @@ class ScreenshotRuleAsserter private constructor(private val rule: ScreenshotTes
// use the instrumentation screenshot as default
private var screenShotter: BitmapSupplier = { Screenshot.capture().bitmap }
override fun assertGoldenImage(goldenId: String) {
- beforeScreenshot?.run();
+ beforeScreenshot?.run()
try {
rule.assertBitmapAgainstGolden(screenShotter(), goldenId, matcher)
- }
- finally {
- afterScreenshot?.run();
+ } finally {
+ afterScreenshot?.run()
}
}
override fun assertGoldenImage(goldenId: String, areas: List<Rect>) {
- beforeScreenshot?.run();
+ beforeScreenshot?.run()
try {
rule.assertBitmapAgainstGolden(screenShotter(), goldenId, matcher, areas)
- }
- finally {
- afterScreenshot?.run();
+ } finally {
+ afterScreenshot?.run()
}
}
diff --git a/libraries/screenshot/src/main/java/platform/test/screenshot/TestAppComponentFactory.kt b/libraries/screenshot/src/main/java/platform/test/screenshot/TestAppComponentFactory.kt
new file mode 100644
index 000000000..fce5f56a8
--- /dev/null
+++ b/libraries/screenshot/src/main/java/platform/test/screenshot/TestAppComponentFactory.kt
@@ -0,0 +1,60 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package platform.test.screenshot
+
+import android.app.Activity
+import android.content.Intent
+import androidx.core.app.AppComponentFactory
+
+class TestAppComponentFactory : AppComponentFactory() {
+
+ init {
+ instance = this
+ }
+
+ private val overrides: MutableMap<String, () -> Activity> = hashMapOf()
+
+ fun clearOverrides() {
+ overrides.clear()
+ }
+
+ fun <T : Activity> registerActivityOverride(activity: Class<T>, provider: () -> T) {
+ overrides[activity.name] = provider
+ }
+
+ override fun instantiateActivityCompat(
+ cl: ClassLoader,
+ className: String,
+ intent: Intent?
+ ): Activity {
+ return overrides
+ .getOrDefault(className) { super.instantiateActivityCompat(cl, className, intent) }
+ .invoke()
+ }
+
+ companion object {
+
+ private var instance: TestAppComponentFactory? = null
+
+ fun getInstance(): TestAppComponentFactory =
+ instance
+ ?: error(
+ "TestAppComponentFactory is not initialized, " +
+ "did you specify it in the manifest?"
+ )
+ }
+}
diff --git a/libraries/screenshot/src/main/java/platform/test/screenshot/View.kt b/libraries/screenshot/src/main/java/platform/test/screenshot/View.kt
new file mode 100644
index 000000000..79ed09fa5
--- /dev/null
+++ b/libraries/screenshot/src/main/java/platform/test/screenshot/View.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package platform.test.screenshot
+
+import android.view.View
+import android.view.ViewGroup
+import android.view.WindowInsets
+
+/** [Sequence] that yields all of the direct children of this [ViewGroup] */
+val ViewGroup.children
+ get() = sequence { for (i in 0 until childCount) yield(getChildAt(i)) }
+
+/**
+ * Elevation/shadows is not deterministic when doing hardware rendering, this exentsion allows to
+ * disable it for any view in the hierarchy.
+ */
+fun View.removeElevationRecursively() {
+ this.elevation = 0f
+ (this as? ViewGroup)?.children?.forEach(View::removeElevationRecursively)
+}
+
+/**
+ * Different devices could have different insets (e.g. different height of the navigation bar or
+ * taskbar). This method dispatches empty insets to the whole view hierarchy and removes the
+ * original listener, so the views won't receive real insets.
+ */
+fun View.removeInsetsRecursively() {
+ this.dispatchApplyWindowInsets(WindowInsets.CONSUMED)
+ this.setOnApplyWindowInsetsListener(null)
+ (this as? ViewGroup)?.children?.forEach(View::removeInsetsRecursively)
+}
diff --git a/libraries/screenshot/src/main/java/platform/test/screenshot/ViewCapture.kt b/libraries/screenshot/src/main/java/platform/test/screenshot/ViewCapture.kt
new file mode 100644
index 000000000..f3a5dae48
--- /dev/null
+++ b/libraries/screenshot/src/main/java/platform/test/screenshot/ViewCapture.kt
@@ -0,0 +1,233 @@
+package platform.test.screenshot
+
+import android.annotation.WorkerThread
+import android.app.Activity
+import android.content.Context
+import android.content.ContextWrapper
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.HardwareRenderer
+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.espresso.Espresso
+import androidx.test.platform.graphics.HardwareRendererCompat
+import com.google.common.util.concurrent.FutureCallback
+import com.google.common.util.concurrent.Futures
+import com.google.common.util.concurrent.ListenableFuture
+import kotlin.coroutines.suspendCoroutine
+import kotlinx.coroutines.runBlocking
+
+/*
+ * 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()))
+ val isRobolectric = if (Build.FINGERPRINT.contains("robolectric")) true else false
+
+ // disable drawing again if necessary once work is complete
+ if (!HardwareRendererCompat.isDrawingEnabled()) {
+ HardwareRendererCompat.setDrawingEnabled(true)
+ bitmapFuture.addListener({ HardwareRendererCompat.setDrawingEnabled(false) }, mainExecutor)
+ }
+
+ mainExecutor.execute {
+ if (isRobolectric) {
+ generateBitmap(bitmapFuture)
+ } else {
+ val forceRedrawFuture = forceRedraw()
+ forceRedrawFuture.addListener({ generateBitmap(bitmapFuture, window) }, mainExecutor)
+ }
+ }
+
+ return bitmapFuture
+}
+
+/**
+ * Synchronously captures an image of the view into a [Bitmap]. Synchronous equivalent of
+ * [captureToBitmap].
+ */
+@WorkerThread
+@ExperimentalTestApi
+@RequiresApi(Build.VERSION_CODES.JELLY_BEAN)
+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,
+ )
+ }
+ }
+}
+
+/**
+ * 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)
+}
diff --git a/libraries/screenshot/src/main/java/platform/test/screenshot/ViewScreenshotTestRule.kt b/libraries/screenshot/src/main/java/platform/test/screenshot/ViewScreenshotTestRule.kt
new file mode 100644
index 000000000..d21bbd43e
--- /dev/null
+++ b/libraries/screenshot/src/main/java/platform/test/screenshot/ViewScreenshotTestRule.kt
@@ -0,0 +1,168 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package platform.test.screenshot
+
+import android.app.Activity
+import android.app.Dialog
+import android.graphics.Bitmap
+import android.os.Build
+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 androidx.activity.ComponentActivity
+import androidx.test.ext.junit.rules.ActivityScenarioRule
+import java.util.concurrent.TimeUnit
+import org.junit.Assert.assertEquals
+import org.junit.rules.RuleChain
+import org.junit.rules.TestRule
+import org.junit.runner.Description
+import org.junit.runners.model.Statement
+import platform.test.screenshot.matchers.BitmapMatcher
+
+/** A rule for View screenshot diff unit tests. */
+open class ViewScreenshotTestRule(
+ emulationSpec: DeviceEmulationSpec,
+ pathManager: GoldenImagePathManager,
+ private val matcher: BitmapMatcher = UnitTestBitmapMatcher
+) : TestRule {
+ private val colorsRule = MaterialYouColorsRule()
+ private val deviceEmulationRule = DeviceEmulationRule(emulationSpec)
+ protected val screenshotRule = ScreenshotTestRule(pathManager)
+ private val activityRule = ActivityScenarioRule(ScreenshotActivity::class.java)
+ private val roboRule =
+ RuleChain.outerRule(deviceEmulationRule).around(screenshotRule).around(activityRule)
+ private val delegateRule = RuleChain.outerRule(colorsRule).around(roboRule)
+ private val isRobolectric = if (Build.FINGERPRINT.contains("robolectric")) true else false
+
+ override fun apply(base: Statement, description: Description): Statement {
+ val ruleToApply = if (isRobolectric) roboRule else delegateRule
+ return ruleToApply.apply(base, description)
+ }
+
+ protected fun takeScreenshot(
+ mode: Mode = Mode.WrapContent,
+ viewProvider: (ComponentActivity) -> View,
+ beforeScreenshot: (ComponentActivity) -> Unit = {}
+ ): Bitmap {
+ activityRule.scenario.onActivity { activity ->
+ // Make sure that the activity draws full screen and fits the whole display instead of
+ // the system bars.
+ 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()
+
+ activity.currentFocus?.clearFocus()
+ }
+
+ // 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)
+ contentView = content.getChildAt(0)
+ beforeScreenshot(activity)
+ }
+
+ return if (isRobolectric) {
+ contentView?.captureToBitmap()?.get(10, TimeUnit.SECONDS)
+ ?: error("timeout while trying to capture view to bitmap")
+ } else {
+ contentView?.toBitmap() ?: error("contentView is null")
+ }
+ }
+
+ /**
+ * Compare the content of the view provided by [viewProvider] with the golden image identified
+ * by [goldenIdentifier] in the context of [emulationSpec].
+ */
+ fun screenshotTest(
+ goldenIdentifier: String,
+ mode: Mode = Mode.WrapContent,
+ beforeScreenshot: (ComponentActivity) -> Unit = {},
+ viewProvider: (ComponentActivity) -> View,
+ ) {
+ val bitmap = takeScreenshot(mode, viewProvider, beforeScreenshot)
+ screenshotRule.assertBitmapAgainstGolden(
+ bitmap,
+ goldenIdentifier,
+ matcher,
+ )
+ }
+
+ /**
+ * Compare the content of the dialog provided by [dialogProvider] with the golden image
+ * identified by [goldenIdentifier] in the context of [emulationSpec].
+ */
+ fun dialogScreenshotTest(
+ goldenIdentifier: String,
+ dialogProvider: (Activity) -> Dialog,
+ ) {
+ var dialog: Dialog? = null
+ activityRule.scenario.onActivity { activity ->
+ dialog =
+ dialogProvider(activity).apply {
+ // Make sure that the dialog draws full screen and fits the whole display
+ // instead of the system bars.
+ window.setDecorFitsSystemWindows(false)
+
+ // Disable enter/exit animations.
+ 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()
+ }
+ }
+
+ try {
+ val bitmap = dialog?.toBitmap() ?: error("dialog is null")
+ screenshotRule.assertBitmapAgainstGolden(
+ bitmap,
+ goldenIdentifier,
+ matcher,
+ )
+ } finally {
+ dialog?.dismiss()
+ }
+ }
+
+ private fun Dialog.toBitmap(): Bitmap {
+ val window = window
+ return window.decorView.toBitmap(window)
+ }
+
+ 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)),
+ }
+}
diff --git a/libraries/screenshot/src/main/java/platform/test/screenshot/WindowCapture.kt b/libraries/screenshot/src/main/java/platform/test/screenshot/WindowCapture.kt
new file mode 100644
index 000000000..d4179464e
--- /dev/null
+++ b/libraries/screenshot/src/main/java/platform/test/screenshot/WindowCapture.kt
@@ -0,0 +1,37 @@
+package platform.test.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())
+ )
+}