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.PixelPerfectMatcher
38 import platform.test.screenshot.proto.ScreenshotResultProto
39 import platform.test.screenshot.report.DiffResultExportStrategy
40 
41 /**
42  * Rule to be added to a test to facilitate screenshot testing.
43  *
44  * This rule records current test name and when instructed it will perform the given bitmap
45  * comparison against the given golden. All the results (including result proto file) are stored
46  * into the device to be retrieved later.
47  *
48  * @see Bitmap.assertAgainstGolden
49  */
50 @SuppressLint("SyntheticAccessor")
51 open class ScreenshotTestRule
52 @VisibleForTesting
53 internal constructor(
54     val goldenPathManager: GoldenPathManager,
55     /** Strategy to report diffs to external systems. */
56     private val diffEscrowStrategy: DiffResultExportStrategy,
57     private val disableIconPool: Boolean = true
58 ) : TestRule, BitmapDiffer, ScreenshotAsserterFactory {
59 
60     @JvmOverloads
61     constructor(
62         goldenPathManager: GoldenPathManager,
63         disableIconPool: Boolean = true,
64     ) : this(
65         goldenPathManager,
66         DiffResultExportStrategy.createDefaultStrategy(goldenPathManager),
67         disableIconPool
68     )
69 
70     private lateinit var testIdentifier: String
71 
72     override fun apply(base: Statement, description: Description): Statement =
73         object : Statement() {
74             override fun evaluate() {
75                 try {
76                     testIdentifier = getTestIdentifier(description)
77                     if (disableIconPool) {
78                         // Disables pooling of SimpleIconFactory objects as it caches
79                         // density, which when updating the screen configuration in runtime
80                         // sometimes it does not get updated in the icon renderer.
81                         SimpleIconFactory.setPoolEnabled(false)
82                     }
83                     base.evaluate()
84                 } finally {
85                     if (disableIconPool) {
86                         SimpleIconFactory.setPoolEnabled(true)
87                     }
88                 }
89             }
90         }
91 
92     open fun getTestIdentifier(description: Description): String =
93         "${description.className}_${description.methodName}"
94 
95     private fun fetchExpectedImage(goldenIdentifier: String): Bitmap? {
96         val instrument = InstrumentationRegistry.getInstrumentation()
97         return listOf(instrument.targetContext.applicationContext, instrument.context)
98             .map { context ->
99                 try {
100                     context.assets
101                         .open(goldenPathManager.goldenImageIdentifierResolver(goldenIdentifier))
102                         .use {
103                             return@use BitmapFactory.decodeStream(it)
104                         }
105                 } catch (e: FileNotFoundException) {
106                     return@map null
107                 }
108             }
109             .filterNotNull()
110             .firstOrNull()
111     }
112 
113     /**
114      * Asserts the given bitmap against the golden identified by the given name.
115      *
116      * Note: The golden identifier should be unique per your test module (unless you want multiple
117      * tests to match the same golden). The name must not contain extension. You should also avoid
118      * adding strings like "golden", "image" and instead describe what is the golder referring to.
119      *
120      * @param actual The bitmap captured during the test.
121      * @param goldenIdentifier Name of the golden. Allowed characters: 'A-Za-z0-9_-'
122      * @param matcher The algorithm to be used to perform the matching.
123      * @throws IllegalArgumentException If the golden identifier contains forbidden characters or is
124      *   empty.
125      * @see MSSIMMatcher
126      * @see PixelPerfectMatcher
127      * @see Bitmap.assertAgainstGolden
128      */
129     @Deprecated("use BitmapDiffer or ScreenshotAsserterFactory interfaces")
130     fun assertBitmapAgainstGolden(
131         actual: Bitmap,
132         goldenIdentifier: String,
133         matcher: BitmapMatcher
134     ) {
135         try {
136             assertBitmapAgainstGolden(
137                 actual = actual,
138                 goldenIdentifier = goldenIdentifier,
139                 matcher = matcher,
140                 regions = emptyList<Rect>()
141             )
142         } finally {
143             actual.recycle()
144         }
145     }
146 
147     /**
148      * Asserts the given bitmap against the golden identified by the given name.
149      *
150      * Note: The golden identifier should be unique per your test module (unless you want multiple
151      * tests to match the same golden). The name must not contain extension. You should also avoid
152      * adding strings like "golden", "image" and instead describe what is the golder referring to.
153      *
154      * @param actual The bitmap captured during the test.
155      * @param goldenIdentifier Name of the golden. Allowed characters: 'A-Za-z0-9_-'
156      * @param matcher The algorithm to be used to perform the matching.
157      * @param regions An optional array of interesting regions for partial screenshot diff.
158      * @throws IllegalArgumentException If the golden identifier contains forbidden characters or is
159      *   empty.
160      * @see MSSIMMatcher
161      * @see PixelPerfectMatcher
162      * @see Bitmap.assertAgainstGolden
163      */
164     @Deprecated("use BitmapDiffer or ScreenshotAsserterFactory interfaces")
165     override fun assertBitmapAgainstGolden(
166         actual: Bitmap,
167         goldenIdentifier: String,
168         matcher: BitmapMatcher,
169         regions: List<Rect>
170     ) {
171         if (!goldenIdentifier.matches("^[A-Za-z0-9_-]+$".toRegex())) {
172             throw IllegalArgumentException(
173                 "The given golden identifier '$goldenIdentifier' does not satisfy the naming " +
174                     "requirement. Allowed characters are: '[A-Za-z0-9_-]'"
175             )
176         }
177 
178         val expected = fetchExpectedImage(goldenIdentifier)
179         if (expected == null) {
180             diffEscrowStrategy.reportResult(
181                 testIdentifier = testIdentifier,
182                 goldenIdentifier = goldenIdentifier,
183                 status = ScreenshotResultProto.DiffResult.Status.MISSING_REFERENCE,
184                 actual = actual
185             )
186             throw AssertionError(
187                 "Missing golden image " +
188                     "'${goldenPathManager.goldenImageIdentifierResolver(goldenIdentifier)}'. " +
189                     "Did you mean to check in a new image?"
190             )
191         }
192 
193         if (actual.width != expected.width || actual.height != expected.height) {
194             val comparisonResult =
195                 matcher.compareBitmaps(
196                     expected = expected.toIntArray(),
197                     given = actual.toIntArray(),
198                     expectedWidth = expected.width,
199                     expectedHeight = expected.height,
200                     actualWidth = actual.width,
201                     actualHeight = actual.height
202                 )
203             diffEscrowStrategy.reportResult(
204                 testIdentifier = testIdentifier,
205                 goldenIdentifier = goldenIdentifier,
206                 status = ScreenshotResultProto.DiffResult.Status.FAILED,
207                 actual = actual,
208                 comparisonStatistics = comparisonResult.comparisonStatistics,
209                 expected = expected,
210                 diff = comparisonResult.diff
211             )
212 
213             val expectedWidth = expected.width
214             val expectedHeight = expected.height
215             expected.recycle()
216 
217             throw AssertionError(
218                 "Sizes are different! Expected: [$expectedWidth, $expectedHeight], Actual: [${
219                     actual.width}, ${actual.height}]. " +
220                     "Force aligned at (0, 0). Comparison stats: '${comparisonResult
221                         .comparisonStatistics}'"
222             )
223         }
224 
225         val comparisonResult =
226             matcher.compareBitmaps(
227                 expected = expected.toIntArray(),
228                 given = actual.toIntArray(),
229                 width = actual.width,
230                 height = actual.height,
231                 regions = regions
232             )
233 
234         val status =
235             if (comparisonResult.matches) {
236                 ScreenshotResultProto.DiffResult.Status.PASSED
237             } else {
238                 ScreenshotResultProto.DiffResult.Status.FAILED
239             }
240 
241         if (!comparisonResult.matches) {
242             val expectedWithHighlight = highlightedBitmap(expected, regions)
243             diffEscrowStrategy.reportResult(
244                 testIdentifier = testIdentifier,
245                 goldenIdentifier = goldenIdentifier,
246                 status = status,
247                 actual = actual,
248                 comparisonStatistics = comparisonResult.comparisonStatistics,
249                 expected = expectedWithHighlight,
250                 diff = comparisonResult.diff
251             )
252 
253             expectedWithHighlight.recycle()
254             expected.recycle()
255 
256             throw AssertionError(
257                 "Image mismatch! Comparison stats: '${comparisonResult.comparisonStatistics}'"
258             )
259         }
260 
261         expected.recycle()
262     }
263 
264     override fun createScreenshotAsserter(config: ScreenshotAsserterConfig): ScreenshotAsserter {
265         return ScreenshotRuleAsserter.Builder(this)
266             .withMatcher(config.matcher)
267             .setOnBeforeScreenshot(config.beforeScreenshot)
268             .setOnAfterScreenshot(config.afterScreenshot)
269             .setScreenshotProvider(config.captureStrategy)
270             .build()
271     }
272 
273     /** This will create a new Bitmap with the output (not modifying the [original] Bitmap */
274     private fun highlightedBitmap(original: Bitmap, regions: List<Rect>): Bitmap {
275         if (regions.isEmpty()) return original
276 
277         val outputBitmap = original.copy(original.config!!, true)
278         val imageRect = Rect(0, 0, original.width, original.height)
279         val regionLineWidth = 2
280         for (region in regions) {
281             val regionToDraw =
282                 Rect(region).apply {
283                     inset(-regionLineWidth, -regionLineWidth)
284                     intersect(imageRect)
285                 }
286 
287             repeat(regionLineWidth) {
288                 drawRectOnBitmap(outputBitmap, regionToDraw, Color.RED)
289                 regionToDraw.inset(1, 1)
290                 regionToDraw.intersect(imageRect)
291             }
292         }
293         return outputBitmap
294     }
295 
296     private fun drawRectOnBitmap(bitmap: Bitmap, rect: Rect, @ColorInt color: Int) {
297         // Draw top and bottom edges
298         for (x in rect.left until rect.right) {
299             bitmap.setPixel(x, rect.top, color)
300             bitmap.setPixel(x, rect.bottom - 1, color)
301         }
302         // Draw left and right edge
303         for (y in rect.top until rect.bottom) {
304             bitmap.setPixel(rect.left, y, color)
305             bitmap.setPixel(rect.right - 1, y, color)
306         }
307     }
308 }
309 
310 typealias BitmapSupplier = () -> Bitmap
311 
312 /** Implements a screenshot asserter based on the ScreenshotRule */
313 class ScreenshotRuleAsserter private constructor(private val rule: ScreenshotTestRule) :
314     ScreenshotAsserter {
315     // use the most constraining matcher as default
316     private var matcher: BitmapMatcher = PixelPerfectMatcher()
317     private var beforeScreenshot: Runnable? = null
318     private var afterScreenshot: Runnable? = null
319 
320     // use the instrumentation screenshot as default
<lambda>null321     private var screenShotter: BitmapSupplier = { Screenshot.capture().bitmap }
322 
323     private var pointerLocationSetting: Int
324         get() = shell("settings get system ${System.POINTER_LOCATION}").trim().toIntOrNull() ?: 0
325         set(value) {
326             shell("settings put system ${System.POINTER_LOCATION} $value")
327         }
328 
329     private var showTouchesSetting
330         get() = shell("settings get system ${System.SHOW_TOUCHES}").trim().toIntOrNull() ?: 0
331         set(value) {
332             shell("settings put system ${System.SHOW_TOUCHES} $value")
333         }
334 
335     private var prevPointerLocationSetting: Int? = null
336     private var prevShowTouchesSetting: Int? = null
337     @Suppress("DEPRECATION")
assertGoldenImagenull338     override fun assertGoldenImage(goldenId: String) {
339         runBeforeScreenshot()
340         var actual: Bitmap? = null
341         try {
342             actual = screenShotter()
343             rule.assertBitmapAgainstGolden(actual, goldenId, matcher)
344         } finally {
345             actual?.recycle()
346             runAfterScreenshot()
347         }
348     }
349 
350     @Suppress("DEPRECATION")
assertGoldenImagenull351     override fun assertGoldenImage(goldenId: String, areas: List<Rect>) {
352         runBeforeScreenshot()
353         var actual: Bitmap? = null
354         try {
355             actual = screenShotter()
356             rule.assertBitmapAgainstGolden(actual, goldenId, matcher, areas)
357         } finally {
358             actual?.recycle()
359             runAfterScreenshot()
360         }
361     }
362 
runBeforeScreenshotnull363     private fun runBeforeScreenshot() {
364         prevPointerLocationSetting = pointerLocationSetting
365         prevShowTouchesSetting = showTouchesSetting
366 
367         if (prevPointerLocationSetting != 0) pointerLocationSetting = 0
368         if (prevShowTouchesSetting != 0) showTouchesSetting = 0
369 
370         beforeScreenshot?.run()
371     }
372 
runAfterScreenshotnull373     private fun runAfterScreenshot() {
374         afterScreenshot?.run()
375 
376         prevPointerLocationSetting?.let { pointerLocationSetting = it }
377         prevShowTouchesSetting?.let { showTouchesSetting = it }
378     }
379 
380     @Deprecated("Use ScreenshotAsserterFactory instead")
381     class Builder(private val rule: ScreenshotTestRule) {
382         private var asserter = ScreenshotRuleAsserter(rule)
<lambda>null383         fun withMatcher(matcher: BitmapMatcher): Builder = apply { asserter.matcher = matcher }
384 
385         /**
386          * The [Bitmap] produced by [screenshotProvider] will be recycled immediately after
387          * assertions are completed. Therefore, do not retain references to created [Bitmap]s.
388          */
<lambda>null389         fun setScreenshotProvider(screenshotProvider: BitmapSupplier): Builder = apply {
390             asserter.screenShotter = screenshotProvider
391         }
392 
setOnBeforeScreenshotnull393         fun setOnBeforeScreenshot(run: Runnable): Builder = apply {
394             asserter.beforeScreenshot = run
395         }
396 
<lambda>null397         fun setOnAfterScreenshot(run: Runnable): Builder = apply { asserter.afterScreenshot = run }
398 
<lambda>null399         fun build(): ScreenshotAsserter = asserter.also { asserter = ScreenshotRuleAsserter(rule) }
400     }
401 }
402 
toIntArraynull403 internal fun Bitmap.toIntArray(): IntArray {
404     val bitmapArray = IntArray(width * height)
405     getPixels(bitmapArray, 0, width, 0, 0, width, height)
406     return bitmapArray
407 }
408 
409 /**
410  * Asserts this bitmap against the golden identified by the given name.
411  *
412  * Note: The golden identifier should be unique per your test module (unless you want multiple tests
413  * to match the same golden). The name must not contain extension. You should also avoid adding
414  * strings like "golden", "image" and instead describe what is the golder referring to.
415  *
416  * @param bitmapDiffer The screenshot test rule that provides the comparison and reporting.
417  * @param goldenIdentifier Name of the golden. Allowed characters: 'A-Za-z0-9_-'
418  * @param matcher The algorithm to be used to perform the matching. By default [MSSIMMatcher] is
419  *   used.
420  * @see MSSIMMatcher
421  * @see PixelPerfectMatcher
422  */
assertAgainstGoldennull423 fun Bitmap.assertAgainstGolden(
424     bitmapDiffer: BitmapDiffer,
425     goldenIdentifier: String,
426     matcher: BitmapMatcher = MSSIMMatcher(),
427     regions: List<Rect> = emptyList()
428 ) {
429     bitmapDiffer.assertBitmapAgainstGolden(
430         this,
431         goldenIdentifier,
432         matcher = matcher,
433         regions = regions
434     )
435 }
436