1 /*
<lambda>null2  * Copyright 2020 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 androidx.compose.testutils
18 
19 import android.graphics.Bitmap
20 import androidx.annotation.VisibleForTesting
21 import androidx.compose.ui.geometry.Offset
22 import androidx.compose.ui.geometry.Rect
23 import androidx.compose.ui.geometry.Size
24 import androidx.compose.ui.graphics.Canvas
25 import androidx.compose.ui.graphics.Color
26 import androidx.compose.ui.graphics.ImageBitmap
27 import androidx.compose.ui.graphics.Path
28 import androidx.compose.ui.graphics.PixelMap
29 import androidx.compose.ui.graphics.RectangleShape
30 import androidx.compose.ui.graphics.Shape
31 import androidx.compose.ui.graphics.addOutline
32 import androidx.compose.ui.graphics.asAndroidPath
33 import androidx.compose.ui.graphics.asComposePath
34 import androidx.compose.ui.graphics.toPixelMap
35 import androidx.compose.ui.unit.Density
36 import androidx.compose.ui.unit.Dp
37 import androidx.compose.ui.unit.IntOffset
38 import androidx.compose.ui.unit.IntSize
39 import androidx.compose.ui.unit.LayoutDirection
40 import androidx.compose.ui.unit.dp
41 import kotlin.math.abs
42 import kotlin.math.max
43 import kotlin.math.min
44 import kotlin.math.roundToInt
45 
46 /**
47  * A helper function to run asserts on [Bitmap].
48  *
49  * @param expectedSize The expected size of the bitmap. Leave null to skip the check.
50  * @param expectedColorProvider Returns the expected color for the provided pixel position. The
51  *   returned color is then asserted as the expected one on the given bitmap.
52  * @throws AssertionError if size or colors don't match.
53  */
54 fun ImageBitmap.assertPixels(
55     expectedSize: IntSize? = null,
56     expectedColorProvider: (pos: IntOffset) -> Color?
57 ) {
58     if (expectedSize != null) {
59         if (width != expectedSize.width || height != expectedSize.height) {
60             throw AssertionError(
61                 "Bitmap size is wrong! Expected '$expectedSize' but got " + "'$width x $height'"
62             )
63         }
64     }
65 
66     val pixel = toPixelMap()
67     for (y in 0 until height) {
68         for (x in 0 until width) {
69             val pxPos = IntOffset(x, y)
70             val expectedClr = expectedColorProvider(pxPos)
71             if (expectedClr != null) {
72                 pixel.assertPixelColor(expectedClr, x, y)
73             }
74         }
75     }
76 }
77 
78 /** Asserts that the color at a specific pixel in the bitmap at ([x], [y]) is [expected]. */
PixelMapnull79 fun PixelMap.assertPixelColor(
80     expected: Color,
81     x: Int,
82     y: Int,
83     error: (Color) -> String = { color -> "Pixel($x, $y) expected to be $expected, but was $color" }
84 ) {
85     val actual = this[x, y]
<lambda>null86     assert(abs(expected.red - actual.red) < 0.02f) { error(actual) }
<lambda>null87     assert(abs(expected.green - actual.green) < 0.02f) { error(actual) }
<lambda>null88     assert(abs(expected.blue - actual.blue) < 0.02f) { error(actual) }
<lambda>null89     assert(abs(expected.alpha - actual.alpha) < 0.02f) { error(actual) }
90 }
91 
92 /**
93  * Asserts that the expected color is present in this bitmap.
94  *
95  * @throws AssertionError if the expected color is not present.
96  */
assertContainsColornull97 fun ImageBitmap.assertContainsColor(expectedColor: Color): ImageBitmap {
98     if (!containsColor(expectedColor)) {
99         throw AssertionError("The given color $expectedColor was not found in the bitmap.")
100     }
101     return this
102 }
103 
assertDoesNotContainColornull104 fun ImageBitmap.assertDoesNotContainColor(unexpectedColor: Color): ImageBitmap {
105     if (containsColor(unexpectedColor)) {
106         throw AssertionError("The given color $unexpectedColor was found in the bitmap.")
107     }
108     return this
109 }
110 
containsColornull111 private fun ImageBitmap.containsColor(expectedColor: Color): Boolean {
112     val pixels = this.toPixelMap()
113     for (y in 0 until height) {
114         for (x in 0 until width) {
115             val color = pixels[x, y]
116             if (color == expectedColor) {
117                 return true
118             }
119         }
120     }
121     return false
122 }
123 
124 /**
125  * Asserts that the given [shape] is drawn in the bitmap in the color [shapeColor] on a background
126  * of [backgroundColor].
127  *
128  * The whole background of the bitmap should be filled with the [backgroundColor]. The [shape]'s
129  * size is that of the bitmap, minus the [horizontalPadding] and [verticalPadding] on all sides. The
130  * shape must be aligned in the center.
131  *
132  * To avoid the pixels on the edge of a shape, where anti-aliasing means the pixel is neither the
133  * shape color nor the background color, a gap size can be given with [antiAliasingGap]. Pixels that
134  * are close to the border of the shape are not checked. A larger [antiAliasingGap] means more
135  * pixels are left unchecked, and a gap of 0 pixels means all pixels are tested.
136  *
137  * @param density current [Density] or the screen
138  * @param shape the [Shape] of the foreground
139  * @param shapeColor the color of the foreground shape
140  * @param backgroundColor the color of the background shape
141  * @param antiAliasingGap The size of the border area from the shape outline to leave it untested as
142  *   it is likely anti-aliased. Only works for convex shapes. The default is 1 pixel
143  */
ImageBitmapnull144 fun ImageBitmap.assertShape(
145     density: Density,
146     horizontalPadding: Dp,
147     verticalPadding: Dp,
148     backgroundColor: Color,
149     shapeColor: Color,
150     shape: Shape = RectangleShape,
151     antiAliasingGap: Float = with(density) { 1.dp.toPx() }
152 ) =
153     assertShape(
154         density = density,
155         shape = shape,
156         shapeColor = shapeColor,
157         backgroundColor = backgroundColor,
158         backgroundShape = RectangleShape,
159         shapeSize =
160             Size(
<lambda>null161                 width - with(density) { horizontalPadding.toPx() * 2 },
<lambda>null162                 height - with(density) { verticalPadding.toPx() * 2 }
163             ),
164         antiAliasingGap = antiAliasingGap
165     )
166 
167 /**
168  * Asserts that the given [shape] and [backgroundShape] are drawn in the bitmap according to the
169  * given parameters.
170  *
171  * The [shape]'s bounding box should have size [shapeSize] and be centered at [shapeCenter]. The
172  * [backgroundShape]'s bounding box should have size [backgroundSize] and be centered at
173  * [backgroundCenter].
174  *
175  * Pixels that are outside the background area are not checked. Pixels that are outside the [shape]
176  * and inside the [backgroundShape] must be [backgroundColor]. Pixels that are inside the [shape]
177  * must be [shapeColor].
178  *
179  * If [backgroundColor] is `null`, only pixels inside the [shape] are checked.
180  *
181  * Because pixels on the edge of a shape are anti-aliased, pixels that are close the shape's edges
182  * are not checked. Use [antiAliasingGap] to ignore more (or less) pixels around the shape's edges.
183  * A larger [antiAliasingGap] means more pixels are left unchecked, and a gap of 0 pixels means all
184  * pixels are tested.
185  */
186 // TODO (mount, malkov) : to investigate why it flakes when shape is not rect
ImageBitmapnull187 fun ImageBitmap.assertShape(
188     density: Density,
189     shape: Shape,
190     shapeColor: Color,
191     shapeSize: Size = Size(width.toFloat(), height.toFloat()),
192     shapeCenter: Offset = Offset(width / 2f, height / 2f),
193     backgroundShape: Shape = RectangleShape,
194     backgroundColor: Color?,
195     backgroundSize: Size = Size(width.toFloat(), height.toFloat()),
196     backgroundCenter: Offset = Offset(width / 2f, height / 2f),
197     antiAliasingGap: Float = with(density) { 1.dp.toPx() }
198 ) {
199     val pixels = toPixelMap()
200 
201     // the bounding box of the foreground shape in the bitmap
202     val shapeBounds =
203         Rect(
204             left = shapeCenter.x - shapeSize.width / 2f,
205             top = shapeCenter.y - shapeSize.height / 2f,
206             right = shapeCenter.x + shapeSize.width / 2f,
207             bottom = shapeCenter.y + shapeSize.height / 2f,
208         )
209     // the bounding box of the background shape in the bitmap
210     val backgroundBounds =
211         Rect(
212             left = backgroundCenter.x - backgroundSize.width / 2f,
213             top = backgroundCenter.y - backgroundSize.height / 2f,
214             right = backgroundCenter.x + backgroundSize.width / 2f,
215             bottom = backgroundCenter.y + backgroundSize.height / 2f,
216         )
217 
218     // Convert the shapes into a paths
219     val foregroundPath = shape.asPath(shapeBounds, density)
220     val backgroundPath = backgroundShape.asPath(backgroundBounds, density)
221 
inFgBoundsnull222     forEachPixelIn(shapeBounds, backgroundBounds, antiAliasingGap) { x, y, inFgBounds, inBgBounds ->
223         if (inFgBounds && !inBgBounds) {
224             // Only consider the foreground shape
225             if (foregroundPath.contains(x, y, shapeCenter, antiAliasingGap)) {
226                 pixels.assertPixelColor(shapeColor, x, y)
227             }
228         } else if (inBgBounds && !inFgBounds) {
229             // Only consider the background shape, if there is one
230             if (
231                 backgroundColor != null &&
232                     backgroundPath.contains(x, y, backgroundCenter, antiAliasingGap)
233             ) {
234                 pixels.assertPixelColor(backgroundColor, x, y)
235             }
236         } else if (inFgBounds /* && inBgBounds */) {
237             // Need to consider both the foreground and background (if there is one)
238             if (foregroundPath.contains(x, y, shapeCenter, antiAliasingGap)) {
239                 pixels.assertPixelColor(shapeColor, x, y)
240             } else if (
241                 backgroundColor != null &&
242                     foregroundPath.notContains(x, y, shapeCenter, antiAliasingGap) &&
243                     backgroundPath.contains(x, y, backgroundCenter, antiAliasingGap)
244             ) {
245                 pixels.assertPixelColor(backgroundColor, x, y)
246             }
247         }
248     }
249 }
250 
ImageBitmapnull251 private fun ImageBitmap.forEachPixelIn(
252     shapeBounds: Rect,
253     backgroundBounds: Rect,
254     margin: Float,
255     block: (x: Int, y: Int, inShapeBounds: Boolean, inBackgroundBounds: Boolean) -> Unit
256 ) {
257     // Iterate over all pixels in the background. Usually that's all we have to do.
258     for (y in rowIndices(backgroundBounds)) {
259         for (x in columnIndices(backgroundBounds)) {
260             block.invoke(x, y, shapeBounds.contains(x, y, margin), true)
261         }
262     }
263     // While checking the background area, we potentially checked a lot of foreground area at the
264     // same time. Try to avoid checking pixels again.
265     val shapeBoundsToCheck = shapeBounds.subtract(backgroundBounds)
266     for (y in rowIndices(shapeBoundsToCheck)) {
267         for (x in columnIndices(shapeBoundsToCheck)) {
268             // Anything contained in the background is already checked
269             if (!backgroundBounds.contains(x, y, margin)) {
270                 block.invoke(x, y, true, false)
271             }
272         }
273     }
274 }
275 
rowIndicesnull276 private fun ImageBitmap.rowIndices(bounds: Rect): IntRange =
277     bounds.top.coerceAtLeast(0f) until bounds.bottom.coerceAtMost(height.toFloat())
278 
279 private fun ImageBitmap.columnIndices(bounds: Rect): IntRange =
280     bounds.left.coerceAtLeast(0f) until bounds.right.coerceAtMost(width.toFloat())
281 
282 private infix fun Float.until(until: Float): IntRange {
283     val from = this.roundToInt()
284     val to = until.roundToInt()
285     if (from <= Int.MIN_VALUE) return IntRange.EMPTY
286     return from until to
287 }
288 
Shapenull289 private fun Shape.asPath(bounds: Rect, density: Density): Path {
290     return android.graphics.Path().asComposePath().apply {
291         addOutline(createOutline(bounds.size, LayoutDirection.Ltr, density))
292         // Translate only modifies segments already added, so call it after addOutline
293         translate(bounds.topLeft)
294     }
295 }
296 
Pathnull297 private fun Path.contains(x: Int, y: Int, center: Offset, margin: Float): Boolean =
298     contains(pointFartherFromAnchor(x, y, center, margin))
299 
300 private fun Path.notContains(x: Int, y: Int, center: Offset, margin: Float): Boolean =
301     !contains(pointCloserToAnchor(x, y, center, margin))
302 
303 /**
304  * Tests to see if the given point is within the path. (That is, whether the point would be in the
305  * visible portion of the path if the path was used with [Canvas.clipPath].)
306  *
307  * The `point` argument is interpreted as an offset from the origin.
308  *
309  * Returns true if the point is in the path, and false otherwise.
310  */
311 @VisibleForTesting
312 internal fun Path.contains(offset: Offset): Boolean {
313     val path = android.graphics.Path()
314     path.addRect(
315         /* left = */ offset.x - 0.01f,
316         /* top = */ offset.y - 0.01f,
317         /* right = */ offset.x + 0.01f,
318         /* bottom = */ offset.y + 0.01f,
319         /* dir = */ android.graphics.Path.Direction.CW
320     )
321     if (path.op(asAndroidPath(), android.graphics.Path.Op.INTERSECT)) {
322         return !path.isEmpty
323     }
324     return false
325 }
326 
pointCloserToAnchornull327 private fun pointCloserToAnchor(x: Int, y: Int, anchor: Offset, delta: Float): Offset {
328     val xx =
329         when {
330             x > anchor.x -> max(x - delta, anchor.x)
331             x < anchor.x -> min(x + delta, anchor.x)
332             else -> x.toFloat()
333         }
334     val yy =
335         when {
336             y > anchor.y -> max(y - delta, anchor.y)
337             y < anchor.y -> min(y + delta, anchor.y)
338             else -> y.toFloat()
339         }
340     return Offset(xx, yy)
341 }
342 
pointFartherFromAnchornull343 private fun pointFartherFromAnchor(x: Int, y: Int, anchor: Offset, delta: Float): Offset {
344     val xx =
345         when {
346             x > anchor.x -> x + delta
347             x < anchor.x -> x - delta
348             else -> x.toFloat()
349         }
350     val yy =
351         when {
352             y > anchor.y -> y + delta
353             y < anchor.y -> y - delta
354             else -> y.toFloat()
355         }
356     return Offset(xx, yy)
357 }
358 
Rectnull359 private fun Rect.contains(x: Int, y: Int, margin: Float): Boolean =
360     left <= x + margin && top <= y + margin && right >= x - margin && bottom >= y - margin
361 
362 private fun Rect.subtract(other: Rect): Rect {
363     // Subtraction can only happen if the other rect is overlapping entirely in a dimension
364     if (other.left <= this.left && other.right >= this.right) {
365         // Other rect potentially overlaps over entire width
366         return if (other.top <= this.top && other.bottom >= this.bottom) {
367             // Subtract everything
368             Rect.Zero
369         } else if (other.top <= this.top && other.bottom > this.top) {
370             // Subtract from the top
371             Rect(this.left, other.bottom, this.right, this.bottom)
372         } else if (other.top < this.bottom && other.bottom >= this.bottom) {
373             // Subtract from the bottom
374             Rect(this.left, this.top, this.right, other.top)
375         } else {
376             // Subtract nothing
377             this
378         }
379     } else if (other.top <= this.top && other.bottom >= this.bottom) {
380         // Other rect potentially overlaps over entire height
381         return if (other.left <= this.left && other.right > this.left) {
382             // Subtract from the left
383             Rect(other.right, this.top, this.right, this.bottom)
384         } else if (other.left < this.right && other.right >= this.right) {
385             // Subtract from the right
386             Rect(this.left, this.top, other.left, this.bottom)
387         } else {
388             // Subtract nothing
389             this
390         }
391     } else {
392         // Subtract nothing
393         return this
394     }
395 }
396