[go: nahoru, domu]

blob: b65642eb0b9c50c0fcca601e9b916c569936f6a7 [file] [log] [blame]
/*
* Copyright 2019 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 androidx.ui.test
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.graphics.Bitmap
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.view.View
import androidx.annotation.RequiresApi
import androidx.ui.core.semantics.SemanticsNode
import androidx.ui.geometry.Offset
import androidx.ui.geometry.Rect
import androidx.ui.graphics.Canvas
import androidx.ui.graphics.Color
import androidx.ui.graphics.Path
import androidx.ui.graphics.RectangleShape
import androidx.ui.graphics.Shape
import androidx.ui.graphics.addOutline
import androidx.ui.graphics.asAndroidPath
import androidx.ui.test.android.SynchronizedTreeCollector
import androidx.ui.test.android.captureRegionToBitmap
import androidx.ui.unit.Density
import androidx.ui.unit.Dp
import androidx.ui.unit.IntPxPosition
import androidx.ui.unit.IntPxSize
import androidx.ui.unit.Px
import androidx.ui.unit.PxSize
import androidx.ui.unit.ipx
import androidx.ui.unit.px
import androidx.ui.unit.round
import androidx.ui.unit.toRect
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
@RequiresApi(Build.VERSION_CODES.O)
internal fun captureNodeToBitmap(node: SemanticsNode): Bitmap {
val collectedInfo = SynchronizedTreeCollector.collectOwners()
val exists = getAllSemanticsNodes().any { it.id == node.id }
if (!exists) {
throw AssertionError("The required node is no longer in the tree!")
}
val window = collectedInfo.findActivity().window
// TODO(pavlis): Consider doing assertIsDisplayed here. Will need to move things around.
// TODO(pavlis): Make sure that the Activity actually hosts the view. As in case of popup
// it wouldn't. This will require us rewriting the structure how we collect the nodes.
// TODO(pavlis): Add support for popups. So if we find composable hosted in popup we can
// grab its reference to its window (need to add a hook to popup).
val handler = Handler(Looper.getMainLooper())
return captureRegionToBitmap(node.globalBounds.toRect(), handler, window)
}
/**
* Captures the underlying component's surface into bitmap.
*
* This has currently several limitations. Currently we assume that the component is hosted in
* Activity's window. Also if there is another window covering part of the component if won't occur
* in the bitmap as this is taken from the component's window surface.
*/
@RequiresApi(Build.VERSION_CODES.O)
fun SemanticsNodeInteraction.captureToBitmap(): Bitmap {
val errorMessageOnFail = "Failed to capture a node to bitmap."
return captureNodeToBitmap(fetchSemanticsNode(errorMessageOnFail))
}
/**
* Captures the underlying view's surface into bitmap.
*
* This has currently several limitations. Currently we assume that the view is hosted in
* Activity's window. Also if there is another window covering part of the component if won't occur
* in the bitmap as this is taken from the component's window surface.
*/
@RequiresApi(Build.VERSION_CODES.O)
fun View.captureToBitmap(): Bitmap {
val locationOnScreen = intArrayOf(0, 0)
getLocationOnScreen(locationOnScreen)
val x = locationOnScreen[0].toFloat()
val y = locationOnScreen[1].toFloat()
val bounds = Rect(x, y, x + width, y + height)
// Recursively search for the Activity context through (possible) ContextWrappers
fun Context.getActivity(): Activity? {
return when (this) {
is Activity -> this
is ContextWrapper -> this.baseContext.getActivity()
else -> null
}
}
return captureRegionToBitmap(bounds, handler, context.getActivity()!!.window)
}
/**
* A helper function to run asserts on [Bitmap].
*
* @param expectedSize The expected size of the bitmap. Leave null to skip the check.
* @param expectedColorProvider Returns the expected color for the provided pixel position.
* The returned color is then asserted as the expected one on the given bitmap.
*
* @throws AssertionError if size or colors don't match.
*/
fun Bitmap.assertPixels(
expectedSize: IntPxSize? = null,
expectedColorProvider: (pos: IntPxPosition) -> Color?
) {
if (expectedSize != null) {
if (width != expectedSize.width.value || height != expectedSize.height.value) {
throw AssertionError(
"Bitmap size is wrong! Expected '$expectedSize' but got " +
"'$width x $height'"
)
}
}
for (x in 0 until width) {
for (y in 0 until height) {
val pxPos = IntPxPosition(x.ipx, y.ipx)
val expectedClr = expectedColorProvider(pxPos)
if (expectedClr != null) {
assertPixelColor(expectedClr, x, y)
}
}
}
}
/**
* Asserts that the color at a specific pixel in the bitmap at ([x], [y]) is [expected].
*/
fun Bitmap.assertPixelColor(
expected: Color,
x: Int,
y: Int,
error: (Color) -> String = { color -> "Pixel($x, $y) expected to be $expected, but was $color" }
) {
val color = Color(getPixel(x, y))
val errorString = error(color)
assertEquals(errorString, expected.red, color.red, 0.02f)
assertEquals(errorString, expected.green, color.green, 0.02f)
assertEquals(errorString, expected.blue, color.blue, 0.02f)
assertEquals(errorString, expected.alpha, color.alpha, 0.02f)
}
/**
* Tests to see if the given point is within the path. (That is, whether the
* point would be in the visible portion of the path if the path was used
* with [Canvas.clipPath].)
*
* The `point` argument is interpreted as an offset from the origin.
*
* Returns true if the point is in the path, and false otherwise.
*/
fun Path.contains(offset: Offset): Boolean {
val path = android.graphics.Path()
path.addRect(
offset.dx - 0.01f,
offset.dy - 0.01f,
offset.dx + 0.01f,
offset.dy + 0.01f,
android.graphics.Path.Direction.CW
)
if (path.op(asAndroidPath(), android.graphics.Path.Op.INTERSECT)) {
return !path.isEmpty
}
return false
}
/**
* Asserts that the given [shape] is drawn within the bitmap with the size the dimensions
* [shapeSizeX] x [shapeSizeY], centered at ([centerX], [centerY]) with the color [shapeColor].
* The bitmap area examined is [sizeX] x [sizeY], centered at ([centerX], [centerY]) and everything
* outside the shape is expected to be color [backgroundColor].
*
* @param density current [Density] or the screen
* @param shape defines the [Shape]
* @param shapeColor the color of the shape
* @param backgroundColor the color of the background
* @param backgroundShape defines the [Shape] of the background
* @param sizeX width of the area filled with the [backgroundShape]
* @param sizeY height of the area filled with the [backgroundShape]
* @param shapeSizeX width of the area filled with the [shape]
* @param shapeSizeY height of the area filled with the [shape]
* @param centerX the X position of the center of the [shape] inside the [sizeX]
* @param centerY the Y position of the center of the [shape] inside the [sizeY]
* @param shapeOverlapPixelCount The size of the border area from the shape outline to leave it
* untested as it is likely anti-aliased. The default is 1 pixel
*/
// TODO (mount, malkov) : to investigate why it flakes when shape is not rect
fun Bitmap.assertShape(
density: Density,
shape: Shape,
shapeColor: Color,
backgroundColor: Color,
backgroundShape: Shape = RectangleShape,
sizeX: Px = width.toFloat().px,
sizeY: Px = height.toFloat().px,
shapeSizeX: Px = sizeX,
shapeSizeY: Px = sizeY,
centerX: Px = width.px / 2f,
centerY: Px = height.px / 2f,
shapeOverlapPixelCount: Px = 1.px
) {
val width = width.px
val height = height.px
assertTrue(centerX + sizeX / 2 <= width)
assertTrue(centerX - sizeX / 2 >= 0.px)
assertTrue(centerY + sizeY / 2 <= height)
assertTrue(centerY - sizeY / 2 >= 0.px)
val outline = shape.createOutline(PxSize(shapeSizeX, shapeSizeY), density)
val path = Path()
path.addOutline(outline)
val shapeOffset = Offset(
(centerX - shapeSizeX / 2f).value,
(centerY - shapeSizeY / 2f).value
)
val backgroundPath = Path()
backgroundPath.addOutline(backgroundShape.createOutline(PxSize(sizeX, sizeY), density))
for (x in centerX - sizeX / 2 until centerX + sizeX / 2) {
for (y in centerY - sizeY / 2 until centerY + sizeY / 2) {
val point = Offset(x.toFloat(), y.toFloat())
if (!backgroundPath.contains(
pixelFartherFromCenter(
point,
sizeX,
sizeY,
shapeOverlapPixelCount
)
)
) {
continue
}
val offset = point - shapeOffset
val isInside = path.contains(
pixelFartherFromCenter(
offset,
shapeSizeX,
shapeSizeY,
shapeOverlapPixelCount
)
)
val isOutside = !path.contains(
pixelCloserToCenter(
offset,
shapeSizeX,
shapeSizeY,
shapeOverlapPixelCount
)
)
if (isInside) {
assertPixelColor(shapeColor, x, y)
} else if (isOutside) {
assertPixelColor(backgroundColor, x, y)
}
}
}
}
/**
* Asserts that the bitmap is fully occupied by the given [shape] with the color [shapeColor]
* without [horizontalPadding] and [verticalPadding] from the sides. The padded area is expected
* to have [backgroundColor].
*
* @param density current [Density] or the screen
* @param horizontalPadding the symmetrical padding to be applied from both left and right sides
* @param verticalPadding the symmetrical padding to be applied from both top and bottom sides
* @param backgroundColor the color of the background
* @param shapeColor the color of the shape
* @param shape defines the [Shape]
* @param shapeOverlapPixelCount The size of the border area from the shape outline to leave it
* untested as it is likely anti-aliased. The default is 1 pixel
*/
fun Bitmap.assertShape(
density: Density,
horizontalPadding: Dp,
verticalPadding: Dp,
backgroundColor: Color,
shapeColor: Color,
shape: Shape = RectangleShape,
shapeOverlapPixelCount: Px = 1.px
) {
val fullHorizontalPadding = with(density) { horizontalPadding.toPx() * 2 }
val fullVerticalPadding = with(density) { verticalPadding.toPx() * 2 }
return assertShape(
density = density,
shape = shape,
shapeColor = shapeColor,
backgroundColor = backgroundColor,
backgroundShape = RectangleShape,
shapeSizeX = width.toFloat().px - fullHorizontalPadding,
shapeSizeY = height.toFloat().px - fullVerticalPadding,
shapeOverlapPixelCount = shapeOverlapPixelCount
)
}
private infix fun Px.until(until: Px): IntRange {
val from = this.round().value
val to = until.round().value
if (from <= Int.MIN_VALUE) return IntRange.EMPTY
return from..(to - 1).toInt()
}
private fun pixelCloserToCenter(offset: Offset, shapeSizeX: Px, shapeSizeY: Px, delta: Px):
Offset {
val centerX = shapeSizeX.value / 2f
val centerY = shapeSizeY.value / 2f
val d = delta.value
val x = when {
offset.dx > centerX -> offset.dx - d
offset.dx < centerX -> offset.dx + d
else -> offset.dx
}
val y = when {
offset.dy > centerY -> offset.dy - d
offset.dy < centerY -> offset.dy + d
else -> offset.dy
}
return Offset(x, y)
}
private fun pixelFartherFromCenter(offset: Offset, shapeSizeX: Px, shapeSizeY: Px, delta: Px):
Offset {
val centerX = shapeSizeX.value / 2f
val centerY = shapeSizeY.value / 2f
val d = delta.value
val x = when {
offset.dx > centerX -> offset.dx + d
offset.dx < centerX -> offset.dx - d
else -> offset.dx
}
val y = when {
offset.dy > centerY -> offset.dy + d
offset.dy < centerY -> offset.dy - d
else -> offset.dy
}
return Offset(x, y)
}