Consolidate screenshot library.

Bug: 286482579
Test: atest -c MyRoboTests
Merged-In: Ib91e5275340a6df776bdd540bc80df940b6af592
Change-Id: If3eafd40e9ca2075e04802f4bf76131ca4b944f9
diff --git a/libraries/screenshot/src/androidTest/java/platform/test/screenshot/ScreenshotTestRuleTest.kt b/libraries/screenshot/src/androidTest/java/platform/test/screenshot/ScreenshotTestRuleTest.kt
index 2302499..f4bdef2 100644
--- a/libraries/screenshot/src/androidTest/java/platform/test/screenshot/ScreenshotTestRuleTest.kt
+++ b/libraries/screenshot/src/androidTest/java/platform/test/screenshot/ScreenshotTestRuleTest.kt
@@ -23,10 +23,10 @@
 import androidx.test.platform.app.InstrumentationRegistry
 import com.google.common.truth.Truth.assertThat
 import java.io.File
-import java.io.FileInputStream
 import java.lang.AssertionError
 import java.util.ArrayList
 import org.junit.After
+import org.junit.Assert.assertThrows
 import org.junit.Rule
 import org.junit.Test
 import org.junit.runner.RunWith
@@ -55,7 +55,8 @@
 
     @get:Rule
     val rule = ScreenshotTestRule(
-        CustomGoldenImagePathManager(InstrumentationRegistry.getInstrumentation().getContext()))
+        CustomGoldenImagePathManager(InstrumentationRegistry.getInstrumentation().getContext())
+    )
 
     @get:Rule
     val customizedRule = ScreenshotTestRule(
@@ -73,17 +74,10 @@
         first
             .assertAgainstGolden(rule, goldenIdentifier, matcher = PixelPerfectMatcher())
 
-        val resultProto = rule.getPathOnDeviceFor(RESULT_PROTO, goldenIdentifier)
-        val fileResultBinProto = rule.getPathOnDeviceFor(RESULT_BIN_PROTO, goldenIdentifier)
-        var diffProto = ScreenshotResultProto.DiffResult.newBuilder()
-        diffProto.mergeFrom(FileInputStream(fileResultBinProto))
-
-        assertThat(resultProto.readText()).contains("PASS")
-        assertThat(rule.getPathOnDeviceFor(IMAGE_ACTUAL, goldenIdentifier).exists()).isTrue()
+        assertThat(rule.getPathOnDeviceFor(IMAGE_ACTUAL, goldenIdentifier).exists()).isFalse()
         assertThat(rule.getPathOnDeviceFor(IMAGE_DIFF, goldenIdentifier).exists()).isFalse()
-        assertThat(rule.getPathOnDeviceFor(IMAGE_EXPECTED, goldenIdentifier).exists()).isTrue()
-        assertThat(rule.getPathOnDeviceFor(RESULT_BIN_PROTO, goldenIdentifier).exists()).isTrue()
-        assertThat(diffProto.build().imageLocationGolden.startsWith("assets")).isTrue()
+        assertThat(rule.getPathOnDeviceFor(IMAGE_EXPECTED, goldenIdentifier).exists()).isFalse()
+        assertThat(rule.getPathOnDeviceFor(RESULT_BIN_PROTO, goldenIdentifier).exists()).isFalse()
     }
 
     @Test
@@ -94,23 +88,14 @@
         first
             .assertAgainstGolden(customizedRule, goldenIdentifier, matcher = PixelPerfectMatcher())
 
-        val resultProto = customizedRule.getPathOnDeviceFor(RESULT_PROTO, goldenIdentifier)
-        val fileResultBinProto = customizedRule.getPathOnDeviceFor(
-            RESULT_BIN_PROTO, goldenIdentifier)
-        var diffProto = ScreenshotResultProto.DiffResult.newBuilder()
-        diffProto.mergeFrom(FileInputStream(fileResultBinProto))
-
-        assertThat(resultProto.readText()).contains("PASS")
         assertThat(customizedRule.getPathOnDeviceFor(IMAGE_ACTUAL, goldenIdentifier).exists())
-            .isTrue()
+            .isFalse()
         assertThat(customizedRule.getPathOnDeviceFor(IMAGE_DIFF, goldenIdentifier).exists())
             .isFalse()
         assertThat(customizedRule.getPathOnDeviceFor(IMAGE_EXPECTED, goldenIdentifier).exists())
-            .isTrue()
+            .isFalse()
         assertThat(customizedRule.getPathOnDeviceFor(RESULT_BIN_PROTO, goldenIdentifier).exists())
-            .isTrue()
-        assertThat(diffProto.build().imageLocationGolden.startsWith(customizedAssetsPath))
-            .isTrue()
+            .isFalse()
     }
 
     @Test
@@ -125,12 +110,10 @@
             regions = regions
         )
 
-        val resultProto = rule.getPathOnDeviceFor(RESULT_PROTO, goldenIdentifier)
-        assertThat(resultProto.readText()).contains("PASS")
-        assertThat(rule.getPathOnDeviceFor(IMAGE_ACTUAL, goldenIdentifier).exists()).isTrue()
+        assertThat(rule.getPathOnDeviceFor(IMAGE_ACTUAL, goldenIdentifier).exists()).isFalse()
         assertThat(rule.getPathOnDeviceFor(IMAGE_DIFF, goldenIdentifier).exists()).isFalse()
-        assertThat(rule.getPathOnDeviceFor(IMAGE_EXPECTED, goldenIdentifier).exists()).isTrue()
-        assertThat(rule.getPathOnDeviceFor(RESULT_BIN_PROTO, goldenIdentifier).exists()).isTrue()
+        assertThat(rule.getPathOnDeviceFor(IMAGE_EXPECTED, goldenIdentifier).exists()).isFalse()
+        assertThat(rule.getPathOnDeviceFor(RESULT_BIN_PROTO, goldenIdentifier).exists()).isFalse()
     }
 
     @Test
@@ -150,12 +133,10 @@
             rule, goldenIdentifier, matcher, regions
         )
 
-        val resultProto = rule.getPathOnDeviceFor(RESULT_PROTO, goldenIdentifier)
-        assertThat(resultProto.readText()).contains("PASS")
-        assertThat(rule.getPathOnDeviceFor(IMAGE_ACTUAL, goldenIdentifier).exists()).isTrue()
+        assertThat(rule.getPathOnDeviceFor(IMAGE_ACTUAL, goldenIdentifier).exists()).isFalse()
         assertThat(rule.getPathOnDeviceFor(IMAGE_DIFF, goldenIdentifier).exists()).isFalse()
-        assertThat(rule.getPathOnDeviceFor(IMAGE_EXPECTED, goldenIdentifier).exists()).isTrue()
-        assertThat(rule.getPathOnDeviceFor(RESULT_BIN_PROTO, goldenIdentifier).exists()).isTrue()
+        assertThat(rule.getPathOnDeviceFor(IMAGE_EXPECTED, goldenIdentifier).exists()).isFalse()
+        assertThat(rule.getPathOnDeviceFor(RESULT_BIN_PROTO, goldenIdentifier).exists()).isFalse()
     }
 
     @Test
@@ -225,6 +206,51 @@
     }
 
     @Test
+    fun performDiff_sameSizes_pixelPerfect_noMatch_noDuplicateImageWritten() {
+        val first = loadBitmap("round_rect_gray")
+        val second = loadBitmap("round_rect_gray_dark")
+
+        val goldenIdentifier = "round_rect_green"
+        assertThrows(AssertionError::class.java) {
+            first
+                .assertAgainstGolden(rule, goldenIdentifier, matcher = PixelPerfectMatcher())
+        }
+        val actualFile1 = rule.getPathOnDeviceFor(IMAGE_ACTUAL, goldenIdentifier)
+
+        assertThrows(AssertionError::class.java) {
+            second
+                .assertAgainstGolden(rule, goldenIdentifier, matcher = PixelPerfectMatcher())
+        }
+        val actualFile2 = rule.getPathOnDeviceFor(IMAGE_ACTUAL, goldenIdentifier)
+
+        assertThat(actualFile1).isEqualTo(actualFile2)
+    }
+
+    @Test
+    fun performDiff_sameSizes_pixelPerfect_firstMatchSecondNoMatch_noDuplicateImageWritten() {
+        val first = loadBitmap("round_rect_green")
+        val second = loadBitmap("round_rect_gray")
+
+        val goldenIdentifier = "round_rect_green"
+        first.assertAgainstGolden(rule, goldenIdentifier, matcher = PixelPerfectMatcher())
+
+        assertThat(rule.getPathOnDeviceFor(IMAGE_ACTUAL, goldenIdentifier).exists()).isFalse()
+        assertThat(rule.getPathOnDeviceFor(IMAGE_DIFF, goldenIdentifier).exists()).isFalse()
+        assertThat(rule.getPathOnDeviceFor(IMAGE_EXPECTED, goldenIdentifier).exists()).isFalse()
+        assertThat(rule.getPathOnDeviceFor(RESULT_BIN_PROTO, goldenIdentifier).exists()).isFalse()
+
+        assertThrows(AssertionError::class.java) {
+            second
+                .assertAgainstGolden(rule, goldenIdentifier, matcher = PixelPerfectMatcher())
+        }
+
+        assertThat(rule.getPathOnDeviceFor(IMAGE_ACTUAL, goldenIdentifier).exists()).isTrue()
+        assertThat(rule.getPathOnDeviceFor(IMAGE_DIFF, goldenIdentifier).exists()).isTrue()
+        assertThat(rule.getPathOnDeviceFor(IMAGE_EXPECTED, goldenIdentifier).exists()).isTrue()
+        assertThat(rule.getPathOnDeviceFor(RESULT_BIN_PROTO, goldenIdentifier).exists()).isTrue()
+    }
+
+    @Test
     fun performDiff_differentSizes() {
         val first =
             loadBitmap("fullscreen_rect_gray")
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 0000000..bdaf1f1
--- /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 0000000..4dc11f6
--- /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/DeviceEmulationRule.kt b/libraries/screenshot/src/main/java/platform/test/screenshot/DeviceEmulationRule.kt
index 4522fd1..7b7a0f7 100644
--- a/libraries/screenshot/src/main/java/platform/test/screenshot/DeviceEmulationRule.kt
+++ b/libraries/screenshot/src/main/java/platform/test/screenshot/DeviceEmulationRule.kt
@@ -19,6 +19,7 @@
 import android.app.UiAutomation
 import android.app.UiModeManager
 import android.content.Context
+import android.os.Build
 import android.os.UserHandle
 import android.view.Display
 import android.view.WindowManagerGlobal
@@ -43,6 +44,7 @@
 
     private val instrumentation = InstrumentationRegistry.getInstrumentation()
     private val uiAutomation = instrumentation.uiAutomation
+    private val isRoblectric = Build.FINGERPRINT.contains("robolectric")
 
     override fun apply(base: Statement, description: Description): Statement {
         // The statement which calls beforeTest() before running the test and afterTest()
@@ -60,33 +62,48 @@
     }
 
     private fun beforeTest() {
-        // Make sure that we are in natural orientation (rotation 0) before we set the screen size
-        uiAutomation.setRotation(UiAutomation.ROTATION_FREEZE_0)
-
         // Emulate the display size and density.
         val display = spec.display
         val density = display.densityDpi
-        val wm = WindowManagerGlobal.getWindowManagerService()
         val (width, height) = getEmulatedDisplaySize()
-        wm.setForcedDisplayDensityForUser(Display.DEFAULT_DISPLAY, density, UserHandle.myUserId())
-        wm.setForcedDisplaySize(Display.DEFAULT_DISPLAY, width, height)
 
-        // Force the dark/light theme.
-        val uiModeManager =
-            InstrumentationRegistry.getInstrumentation()
-                .targetContext
-                .getSystemService(Context.UI_MODE_SERVICE) as UiModeManager
-        uiModeManager.setApplicationNightMode(
-            if (spec.isDarkTheme) {
-                UiModeManager.MODE_NIGHT_YES
-            } else {
-                UiModeManager.MODE_NIGHT_NO
-            }
-        )
+        if (isRoblectric) {
+            // For Robolectric tests use RuntimeEnvironment.setQualifiers until wm is shadowed
+            // b/275751037  to address this issue.
+            val runtimeEnvironment = Class.forName("org.robolectric.RuntimeEnvironment")
+            val setQualifiers =
+                runtimeEnvironment.getDeclaredMethod("setQualifiers", String::class.java)
+            val qualifier = "w${width}dp-h${height}dp-${density}dpi"
+            setQualifiers.invoke(null, qualifier)
+        } else {
+            // Make sure that we are in natural orientation (rotation 0) before we set the screen
+            // size
+            uiAutomation.setRotation(UiAutomation.ROTATION_FREEZE_0)
 
-        // Make sure that all devices are in touch mode to avoid screenshot differences
-        // in focused elements when in keyboard mode
-        instrumentation.setInTouchMode(true)
+            val wm = WindowManagerGlobal.getWindowManagerService()
+            wm.setForcedDisplayDensityForUser(
+                Display.DEFAULT_DISPLAY,
+                density,
+                UserHandle.myUserId()
+            )
+            wm.setForcedDisplaySize(Display.DEFAULT_DISPLAY, width, height)
+
+            // Force the dark/light theme.
+            val uiModeManager =
+                InstrumentationRegistry.getInstrumentation()
+                    .targetContext
+                    .getSystemService(Context.UI_MODE_SERVICE) as UiModeManager
+            uiModeManager.setApplicationNightMode(
+                if (spec.isDarkTheme) {
+                    UiModeManager.MODE_NIGHT_YES
+                } else {
+                    UiModeManager.MODE_NIGHT_NO
+                }
+            )
+            // Make sure that all devices are in touch mode to avoid screenshot differences
+            // in focused elements when in keyboard mode
+            instrumentation.setInTouchMode(true)
+        }
     }
 
     /** Get the emulated display size for [spec]. */
@@ -102,6 +119,10 @@
 
     private fun afterTest() {
         // Reset the density and display size.
+        if (isRoblectric) {
+            return
+        }
+
         val wm = WindowManagerGlobal.getWindowManagerService()
         wm.clearForcedDisplayDensityForUser(Display.DEFAULT_DISPLAY, UserHandle.myUserId())
         wm.clearForcedDisplaySize(Display.DEFAULT_DISPLAY)
@@ -116,7 +137,7 @@
         instrumentation.resetInTouchMode()
 
         // Unfreeze locked rotation
-        uiAutomation.setRotation(UiAutomation.ROTATION_UNFREEZE);
+        uiAutomation.setRotation(UiAutomation.ROTATION_UNFREEZE)
     }
 }
 
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 0000000..37fa024
--- /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/MaterialYouColorsRule.kt b/libraries/screenshot/src/main/java/platform/test/screenshot/MaterialYouColorsRule.kt
index ab8c73d..cfad140 100644
--- a/libraries/screenshot/src/main/java/platform/test/screenshot/MaterialYouColorsRule.kt
+++ b/libraries/screenshot/src/main/java/platform/test/screenshot/MaterialYouColorsRule.kt
@@ -75,7 +75,7 @@
          * Create a [MaterialYouColors] from [colors], where:
          * - `colors[i]` should be the value of `FIRST_RESOURCE_COLOR_ID + i`.
          * - [colors] must contain all values of all system colors, i.e. `colors.size` should be
-         * `LAST_RESOURCE_COLOR_ID - FIRST_RESOURCE_COLOR_ID + 1`.
+         *   `LAST_RESOURCE_COLOR_ID - FIRST_RESOURCE_COLOR_ID + 1`.
          */
         private fun fromColors(colors: IntArray): MaterialYouColors {
             val expectedSize = LAST_RESOURCE_COLOR_ID - FIRST_RESOURCE_COLOR_ID + 1
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 0000000..1570b1f
--- /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 52629e0..cffb5d3 100644
--- a/libraries/screenshot/src/main/java/platform/test/screenshot/ScreenshotTestRule.kt
+++ b/libraries/screenshot/src/main/java/platform/test/screenshot/ScreenshotTestRule.kt
@@ -92,8 +92,8 @@
     private fun fetchExpectedImage(goldenIdentifier: String): Bitmap? {
         val instrument = InstrumentationRegistry.getInstrumentation()
         return listOf(
-                instrument.targetContext.applicationContext,
-                instrument.context
+            instrument.targetContext.applicationContext,
+            instrument.context
         ).map {
             try {
                 it.assets.open(
@@ -215,17 +215,17 @@
             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}'"
@@ -248,7 +248,8 @@
             .addMetadata(
                 ScreenshotResultProto.Metadata.newBuilder()
                     .setKey("repoRootPath")
-                    .setValue(goldenImagePathManager.deviceLocalPath))
+                    .setValue(goldenImagePathManager.deviceLocalPath)
+            )
 
         if (comparisonStatistics != null) {
             resultProto.comparisonStatistics = comparisonStatistics
@@ -329,15 +330,18 @@
         }
 
         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)
             }
         } catch (e: Exception) {
-            throw IOException(
-                "Could not write file to storage (path: ${file.absolutePath}). " +
-                    " Stacktrace: " + e.stackTrace
-            )
+            throw IOException("Could not write file to storage (path: ${file.absolutePath}). ", e)
         }
         return file
     }
@@ -421,7 +425,9 @@
 /**
  * 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
@@ -429,22 +435,20 @@
     // 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 0000000..fce5f56
--- /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 0000000..79ed09f
--- /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 0000000..f3a5dae
--- /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 0000000..d21bbd4
--- /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 0000000..d417946
--- /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())
+    )
+}