1 /*
<lambda>null2  * Copyright 2022 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package platform.test.screenshot
18 
19 import android.annotation.ColorInt
20 import android.annotation.SuppressLint
21 import android.graphics.Bitmap
22 import android.graphics.BitmapFactory
23 import android.graphics.Color
24 import android.graphics.Rect
25 import android.platform.uiautomator_helpers.DeviceHelpers.shell
26 import android.provider.Settings.System
27 import androidx.annotation.VisibleForTesting
28 import androidx.test.platform.app.InstrumentationRegistry
29 import androidx.test.runner.screenshot.Screenshot
30 import com.android.internal.app.SimpleIconFactory
31 import java.io.FileNotFoundException
32 import org.junit.rules.TestRule
33 import org.junit.runner.Description
34 import org.junit.runners.model.Statement
35 import platform.test.screenshot.matchers.BitmapMatcher
36 import platform.test.screenshot.matchers.MSSIMMatcher
37 import platform.test.screenshot.matchers.MatchResult
38 import platform.test.screenshot.matchers.PixelPerfectMatcher
39 import platform.test.screenshot.parity.ParityStatsCollector
40 import platform.test.screenshot.proto.ScreenshotResultProto
41 import platform.test.screenshot.report.DiffResultExportStrategy
42 
43 /**
44  * Rule to be added to a test to facilitate screenshot testing.
45  *
46  * This rule records current test name and when instructed it will perform the given bitmap
47  * comparison against the given golden. All the results (including result proto file) are stored
48  * into the device to be retrieved later.
49  *
50  * @see Bitmap.assertAgainstGolden
51  */
52 @SuppressLint("SyntheticAccessor")
53 open class ScreenshotTestRule
54 @VisibleForTesting
55 internal constructor(
56     val goldenPathManager: GoldenPathManager,
57     /** Strategy to report diffs to external systems. */
58     private val diffEscrowStrategy: DiffResultExportStrategy,
59     private val disableIconPool: Boolean = true
60 ) : TestRule, BitmapDiffer, ScreenshotAsserterFactory {
61 
62     @JvmOverloads
63     constructor(
64         goldenPathManager: GoldenPathManager,
65         disableIconPool: Boolean = true,
66     ) : this(
67         goldenPathManager,
68         DiffResultExportStrategy.createDefaultStrategy(goldenPathManager),
69         disableIconPool
70     )
71 
72     private val doesCollectScreenshotParityStats =
73         "yes".equals(
74             java.lang.System.getProperty("screenshot.collectScreenshotParityStats"),
75             ignoreCase = true)
76     private lateinit var testIdentifier: String
77 
78     companion object {
79         private val parityStatsCollector = ParityStatsCollector()
80     }
81 
82     override fun apply(base: Statement, description: Description): Statement =
83         object : Statement() {
84             override fun evaluate() {
85                 try {
86                     testIdentifier = getTestIdentifier(description)
87                     if (disableIconPool) {
88                         // Disables pooling of SimpleIconFactory objects as it caches
89                         // density, which when updating the screen configuration in runtime
90                         // sometimes it does not get updated in the icon renderer.
91                         SimpleIconFactory.setPoolEnabled(false)
92                     }
93                     base.evaluate()
94                 } finally {
95                     if (disableIconPool) {
96                         SimpleIconFactory.setPoolEnabled(true)
97                     }
98                 }
99             }
100         }
101 
102     open fun getTestIdentifier(description: Description): String =
103         "${description.className}_${description.methodName}"
104 
105     private fun fetchExpectedImage(goldenIdentifier: String): Bitmap? {
106         val instrument = InstrumentationRegistry.getInstrumentation()
107         return listOf(instrument.targetContext.applicationContext, instrument.context)
108             .map { context ->
109                 try {
110                     context.assets
111                         .open(goldenPathManager.goldenImageIdentifierResolver(goldenIdentifier))
112                         .use {
113                             return@use BitmapFactory.decodeStream(it)
114                         }
115                 } catch (e: FileNotFoundException) {
116                     return@map null
117                 }
118             }
119             .filterNotNull()
120             .firstOrNull()
121     }
122 
123     /**
124      * Asserts the given bitmap against the golden identified by the given name.
125      *
126      * Note: The golden identifier should be unique per your test module (unless you want multiple
127      * tests to match the same golden). The name must not contain extension. You should also avoid
128      * adding strings like "golden", "image" and instead describe what is the golder referring to.
129      *
130      * @param actual The bitmap captured during the test.
131      * @param goldenIdentifier Name of the golden. Allowed characters: 'A-Za-z0-9_-'
132      * @param matcher The algorithm to be used to perform the matching.
133      * @throws IllegalArgumentException If the golden identifier contains forbidden characters or is
134      *   empty.
135      * @see MSSIMMatcher
136      * @see PixelPerfectMatcher
137      * @see Bitmap.assertAgainstGolden
138      */
139     @Deprecated("use BitmapDiffer or ScreenshotAsserterFactory interfaces")
140     fun assertBitmapAgainstGolden(
141         actual: Bitmap,
142         goldenIdentifier: String,
143         matcher: BitmapMatcher
144     ) {
145         try {
146             assertBitmapAgainstGolden(
147                 actual = actual,
148                 goldenIdentifier = goldenIdentifier,
149                 matcher = matcher,
150                 regions = emptyList<Rect>()
151             )
152         } finally {
153             actual.recycle()
154         }
155     }
156 
157     /**
158      * Asserts the given bitmap against the golden identified by the given name.
159      *
160      * Note: The golden identifier should be unique per your test module (unless you want multiple
161      * tests to match the same golden). The name must not contain extension. You should also avoid
162      * adding strings like "golden", "image" and instead describe what is the golder referring to.
163      *
164      * @param actual The bitmap captured during the test.
165      * @param goldenIdentifier Name of the golden. Allowed characters: 'A-Za-z0-9_-'
166      * @param matcher The algorithm to be used to perform the matching.
167      * @param regions An optional array of interesting regions for partial screenshot diff.
168      * @throws IllegalArgumentException If the golden identifier contains forbidden characters or is
169      *   empty.
170      * @see MSSIMMatcher
171      * @see PixelPerfectMatcher
172      * @see Bitmap.assertAgainstGolden
173      */
174     @Deprecated("use BitmapDiffer or ScreenshotAsserterFactory interfaces")
175     override fun assertBitmapAgainstGolden(
176         actual: Bitmap,
177         goldenIdentifier: String,
178         matcher: BitmapMatcher,
179         regions: List<Rect>
180     ) {
181         if (!goldenIdentifier.matches("^[A-Za-z0-9_-]+$".toRegex())) {
182             throw IllegalArgumentException(
183                 "The given golden identifier '$goldenIdentifier' does not satisfy the naming " +
184                     "requirement. Allowed characters are: '[A-Za-z0-9_-]'"
185             )
186         }
187 
188         val expected = fetchExpectedImage(goldenIdentifier)
189         if (expected == null) {
190             diffEscrowStrategy.reportResult(
191                 testIdentifier = testIdentifier,
192                 goldenIdentifier = goldenIdentifier,
193                 status = ScreenshotResultProto.DiffResult.Status.MISSING_REFERENCE,
194                 actual = actual
195             )
196             throw AssertionError(
197                 "Missing golden image " +
198                     "'${goldenPathManager.goldenImageIdentifierResolver(goldenIdentifier)}'. " +
199                     "Did you mean to check in a new image?"
200             )
201         }
202 
203         if (expected.sameAs(actual)) {
204             if (doesCollectScreenshotParityStats) {
205                 val stats =
206                     ScreenshotResultProto.DiffResult.ComparisonStatistics.newBuilder()
207                         .setNumberPixelsCompared(actual.width * actual.height)
208                         .setNumberPixelsIdentical(actual.width * actual.height)
209                         .setNumberPixelsDifferent(0)
210                         .setNumberPixelsIgnored(0)
211                         .build()
212                 parityStatsCollector.collectTestStats(
213                     testIdentifier,
214                     MatchResult(matches = true, diff = null, comparisonStatistics = stats))
215                 parityStatsCollector.report()
216             }
217             expected.recycle()
218             return
219         }
220 
221         if (actual.width != expected.width || actual.height != expected.height) {
222             val comparisonResult =
223                 matcher.compareBitmaps(
224                     expected = expected.toIntArray(),
225                     given = actual.toIntArray(),
226                     expectedWidth = expected.width,
227                     expectedHeight = expected.height,
228                     actualWidth = actual.width,
229                     actualHeight = actual.height
230                 )
231             diffEscrowStrategy.reportResult(
232                 testIdentifier = testIdentifier,
233                 goldenIdentifier = goldenIdentifier,
234                 status = ScreenshotResultProto.DiffResult.Status.FAILED,
235                 actual = actual,
236                 comparisonStatistics = comparisonResult.comparisonStatistics,
237                 expected = expected,
238                 diff = comparisonResult.diff
239             )
240             if (doesCollectScreenshotParityStats) {
241                 parityStatsCollector.collectTestStats(testIdentifier, comparisonResult)
242                 parityStatsCollector.report()
243             }
244 
245             val expectedWidth = expected.width
246             val expectedHeight = expected.height
247             expected.recycle()
248 
249             throw AssertionError(
250                 "Sizes are different! Expected: [$expectedWidth, $expectedHeight], Actual: [${
251                     actual.width}, ${actual.height}]. " +
252                     "Force aligned at (0, 0). Comparison stats: '${comparisonResult
253                         .comparisonStatistics}'"
254             )
255         }
256 
257         val comparisonResult =
258             matcher.compareBitmaps(
259                 expected = expected.toIntArray(),
260                 given = actual.toIntArray(),
261                 width = actual.width,
262                 height = actual.height,
263                 regions = regions
264             )
265         if (doesCollectScreenshotParityStats) {
266             parityStatsCollector.collectTestStats(testIdentifier, comparisonResult)
267             parityStatsCollector.report()
268         }
269 
270         val status =
271             if (comparisonResult.matches) {
272                 ScreenshotResultProto.DiffResult.Status.PASSED
273             } else {
274                 ScreenshotResultProto.DiffResult.Status.FAILED
275             }
276 
277         if (!comparisonResult.matches) {
278             val expectedWithHighlight = highlightedBitmap(expected, regions)
279             diffEscrowStrategy.reportResult(
280                 testIdentifier = testIdentifier,
281                 goldenIdentifier = goldenIdentifier,
282                 status = status,
283                 actual = actual,
284                 comparisonStatistics = comparisonResult.comparisonStatistics,
285                 expected = expectedWithHighlight,
286                 diff = comparisonResult.diff
287             )
288 
289             expectedWithHighlight.recycle()
290             expected.recycle()
291 
292             throw AssertionError(
293                 "Image mismatch! Comparison stats: '${comparisonResult.comparisonStatistics}'"
294             )
295         }
296 
297         expected.recycle()
298     }
299 
300     override fun createScreenshotAsserter(config: ScreenshotAsserterConfig): ScreenshotAsserter {
301         return ScreenshotRuleAsserter.Builder(this)
302             .withMatcher(config.matcher)
303             .setOnBeforeScreenshot(config.beforeScreenshot)
304             .setOnAfterScreenshot(config.afterScreenshot)
305             .setScreenshotProvider(config.captureStrategy)
306             .build()
307     }
308 
309     /** This will create a new Bitmap with the output (not modifying the [original] Bitmap */
310     private fun highlightedBitmap(original: Bitmap, regions: List<Rect>): Bitmap {
311         if (regions.isEmpty()) return original
312 
313         val outputBitmap = original.copy(original.config!!, true)
314         val imageRect = Rect(0, 0, original.width, original.height)
315         val regionLineWidth = 2
316         for (region in regions) {
317             val regionToDraw =
318                 Rect(region).apply {
319                     inset(-regionLineWidth, -regionLineWidth)
320                     intersect(imageRect)
321                 }
322 
323             repeat(regionLineWidth) {
324                 drawRectOnBitmap(outputBitmap, regionToDraw, Color.RED)
325                 regionToDraw.inset(1, 1)
326                 regionToDraw.intersect(imageRect)
327             }
328         }
329         return outputBitmap
330     }
331 
332     private fun drawRectOnBitmap(bitmap: Bitmap, rect: Rect, @ColorInt color: Int) {
333         // Draw top and bottom edges
334         for (x in rect.left until rect.right) {
335             bitmap.setPixel(x, rect.top, color)
336             bitmap.setPixel(x, rect.bottom - 1, color)
337         }
338         // Draw left and right edge
339         for (y in rect.top until rect.bottom) {
340             bitmap.setPixel(rect.left, y, color)
341             bitmap.setPixel(rect.right - 1, y, color)
342         }
343     }
344 }
345 
346 typealias BitmapSupplier = () -> Bitmap
347 
348 /** Implements a screenshot asserter based on the ScreenshotRule */
349 class ScreenshotRuleAsserter private constructor(private val rule: ScreenshotTestRule) :
350     ScreenshotAsserter {
351     // use the most constraining matcher as default
352     private var matcher: BitmapMatcher = PixelPerfectMatcher()
353     private var beforeScreenshot: Runnable? = null
354     private var afterScreenshot: Runnable? = null
355 
356     // use the instrumentation screenshot as default
<lambda>null357     private var screenShotter: BitmapSupplier = { Screenshot.capture().bitmap }
358 
359     private var pointerLocationSetting: Int
360         get() = shell("settings get system ${System.POINTER_LOCATION}").trim().toIntOrNull() ?: 0
361         set(value) {
362             shell("settings put system ${System.POINTER_LOCATION} $value")
363         }
364 
365     private var showTouchesSetting
366         get() = shell("settings get system ${System.SHOW_TOUCHES}").trim().toIntOrNull() ?: 0
367         set(value) {
368             shell("settings put system ${System.SHOW_TOUCHES} $value")
369         }
370 
371     private var prevPointerLocationSetting: Int? = null
372     private var prevShowTouchesSetting: Int? = null
373     @Suppress("DEPRECATION")
assertGoldenImagenull374     override fun assertGoldenImage(goldenId: String) {
375         runBeforeScreenshot()
376         var actual: Bitmap? = null
377         try {
378             actual = screenShotter()
379             rule.assertBitmapAgainstGolden(actual, goldenId, matcher)
380         } finally {
381             actual?.recycle()
382             runAfterScreenshot()
383         }
384     }
385 
386     @Suppress("DEPRECATION")
assertGoldenImagenull387     override fun assertGoldenImage(goldenId: String, areas: List<Rect>) {
388         runBeforeScreenshot()
389         var actual: Bitmap? = null
390         try {
391             actual = screenShotter()
392             rule.assertBitmapAgainstGolden(actual, goldenId, matcher, areas)
393         } finally {
394             actual?.recycle()
395             runAfterScreenshot()
396         }
397     }
398 
runBeforeScreenshotnull399     private fun runBeforeScreenshot() {
400         prevPointerLocationSetting = pointerLocationSetting
401         prevShowTouchesSetting = showTouchesSetting
402 
403         if (prevPointerLocationSetting != 0) pointerLocationSetting = 0
404         if (prevShowTouchesSetting != 0) showTouchesSetting = 0
405 
406         beforeScreenshot?.run()
407     }
408 
runAfterScreenshotnull409     private fun runAfterScreenshot() {
410         afterScreenshot?.run()
411 
412         prevPointerLocationSetting?.let { pointerLocationSetting = it }
413         prevShowTouchesSetting?.let { showTouchesSetting = it }
414     }
415 
416     @Deprecated("Use ScreenshotAsserterFactory instead")
417     class Builder(private val rule: ScreenshotTestRule) {
418         private var asserter = ScreenshotRuleAsserter(rule)
<lambda>null419         fun withMatcher(matcher: BitmapMatcher): Builder = apply { asserter.matcher = matcher }
420 
421         /**
422          * The [Bitmap] produced by [screenshotProvider] will be recycled immediately after
423          * assertions are completed. Therefore, do not retain references to created [Bitmap]s.
424          */
<lambda>null425         fun setScreenshotProvider(screenshotProvider: BitmapSupplier): Builder = apply {
426             asserter.screenShotter = screenshotProvider
427         }
428 
setOnBeforeScreenshotnull429         fun setOnBeforeScreenshot(run: Runnable): Builder = apply {
430             asserter.beforeScreenshot = run
431         }
432 
<lambda>null433         fun setOnAfterScreenshot(run: Runnable): Builder = apply { asserter.afterScreenshot = run }
434 
<lambda>null435         fun build(): ScreenshotAsserter = asserter.also { asserter = ScreenshotRuleAsserter(rule) }
436     }
437 }
438 
toIntArraynull439 internal fun Bitmap.toIntArray(): IntArray {
440     val bitmapArray = IntArray(width * height)
441     getPixels(bitmapArray, 0, width, 0, 0, width, height)
442     return bitmapArray
443 }
444 
445 /**
446  * Asserts this bitmap against the golden identified by the given name.
447  *
448  * Note: The golden identifier should be unique per your test module (unless you want multiple tests
449  * to match the same golden). The name must not contain extension. You should also avoid adding
450  * strings like "golden", "image" and instead describe what is the golder referring to.
451  *
452  * @param bitmapDiffer The screenshot test rule that provides the comparison and reporting.
453  * @param goldenIdentifier Name of the golden. Allowed characters: 'A-Za-z0-9_-'
454  * @param matcher The algorithm to be used to perform the matching. By default [MSSIMMatcher] is
455  *   used.
456  * @see MSSIMMatcher
457  * @see PixelPerfectMatcher
458  */
assertAgainstGoldennull459 fun Bitmap.assertAgainstGolden(
460     bitmapDiffer: BitmapDiffer,
461     goldenIdentifier: String,
462     matcher: BitmapMatcher = MSSIMMatcher(),
463     regions: List<Rect> = emptyList()
464 ) {
465     bitmapDiffer.assertBitmapAgainstGolden(
466         this,
467         goldenIdentifier,
468         matcher = matcher,
469         regions = regions
470     )
471 }
472