diff options
Diffstat (limited to 'libraries/screenshot/src/main/java/platform/test')
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()) + ) +} |