diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BackgroundStyleApplicator.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BackgroundStyleApplicator.kt index c3af3626ae6e..22cef27488b8 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BackgroundStyleApplicator.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BackgroundStyleApplicator.kt @@ -89,6 +89,11 @@ public object BackgroundStyleApplicator { ensureBackgroundImageDrawable(view).backgroundImageLayers = backgroundImageLayers } + @JvmStatic + internal fun cancelBackgroundImageURLRequests(view: View) { + getBackgroundImage(view)?.cancelBackgroundImageURLRequests() + } + @JvmStatic internal fun setBackgroundSize(view: View, backgroundSizes: List?) { ensureBackgroundImageDrawable(view).backgroundSize = backgroundSizes diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/BackgroundImageDrawable.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/BackgroundImageDrawable.kt index 66e04d3e7cbc..bffad3775234 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/BackgroundImageDrawable.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/BackgroundImageDrawable.kt @@ -16,6 +16,8 @@ import android.graphics.PixelFormat import android.graphics.Rect import android.graphics.RectF import android.graphics.drawable.Drawable +import com.facebook.common.logging.FLog +import com.facebook.react.common.ReactConstants import com.facebook.react.uimanager.FloatUtil import com.facebook.react.uimanager.LengthPercentage import com.facebook.react.uimanager.LengthPercentageType @@ -45,11 +47,13 @@ internal class BackgroundImageDrawable( private var backgroundImageClipPath: Path? = null private var backgroundPositioningArea: RectF? = null private var backgroundPaintingArea: RectF? = null + private val backgroundImageURLLoader = BackgroundImageURLLoader(context) { invalidateSelf() } var backgroundImageLayers: List? = null set(value) { if (field != value) { field = value + backgroundImageURLLoader.loadBackgroundImages(value.orEmpty()) invalidateSelf() } } @@ -79,7 +83,12 @@ internal class BackgroundImageDrawable( } private val backgroundPaint: Paint = - Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL } + Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG).apply { style = Paint.Style.FILL } + + /** Called when the view is dropped. */ + fun cancelBackgroundImageURLRequests() { + backgroundImageURLLoader.cancelRequests() + } override fun invalidateSelf() { needUpdatePath = true @@ -111,7 +120,7 @@ internal class BackgroundImageDrawable( } override fun draw(canvas: Canvas) { - if (backgroundImageLayers == null || backgroundImageLayers?.isEmpty() == true) { + if (backgroundImageLayers.isNullOrEmpty()) { return } @@ -140,13 +149,39 @@ internal class BackgroundImageDrawable( val position = backgroundPosition?.takeIf { it.isNotEmpty() }?.let { it.getOrNull(index % it.size) } + val uri = backgroundImageLayer.uri + val urlImage: BackgroundImageURLLoader.OrientedBitmap? + val (intrinsicWidth, intrinsicHeight) = + if (uri == null) { + urlImage = null + backgroundPositioningArea.width() to backgroundPositioningArea.height() + } else { + val image = backgroundImageURLLoader.imageForUri(uri) + if (image == null) { + continue + } + urlImage = image + // Asset (local require() assets) pass intrinsicWidth/Height in DIPs + // So use those dimensions and convert from dp to physical pixels. + val assetWidth = backgroundImageLayer.intrinsicWidth + val assetHeight = backgroundImageLayer.intrinsicHeight + if (assetWidth != null && assetHeight != null) { + assetWidth.dpToPx() to assetHeight.dpToPx() + } else { + // Plain url() images don't pass intrinsic width/height + // so take the decoded pixels as DIPs and convert + image.width.dpToPx() to image.height.dpToPx() + } + } + // 2. Calculate the size of a single tile. val (tileWidth, tileHeight) = calculateBackgroundImageSize( backgroundPositioningArea.width(), backgroundPositioningArea.height(), - backgroundPositioningArea.width(), - backgroundPositioningArea.height(), + intrinsicWidth, + intrinsicHeight, + uri != null, size, repeat, ) @@ -155,8 +190,12 @@ internal class BackgroundImageDrawable( continue } - // 3. Set paint shader - backgroundPaint.setShader(backgroundImageLayer.getShader(tileWidth, tileHeight)) + // 3. Set paint shader for gradients (URL images don't use shaders) + if (uri == null) { + backgroundPaint.setShader(backgroundImageLayer.getShader(tileWidth, tileHeight)) + } else { + backgroundPaint.setShader(null) + } // 4. Calculate spacing, x and y tiles count and position for tiles var (initialX, initialY) = calculateBackgroundPosition(tileWidth, tileHeight, position) @@ -248,18 +287,30 @@ internal class BackgroundImageDrawable( } // 5. draw the repeating tiles using translate + val urlImageMatrix = urlImage?.drawMatrix(tileWidth, tileHeight) var translateX = initialX var translateY: Float - repeat(xTilesCount) { - translateY = initialY - repeat(yTilesCount) { - canvas.save() - canvas.translate(translateX, translateY) - canvas.drawRect(0f, 0f, tileWidth, tileHeight, backgroundPaint) - canvas.restore() - translateY += tileHeight + ySpacing + val saveCount = canvas.saveCount + try { + repeat(xTilesCount) { + translateY = initialY + repeat(yTilesCount) { + canvas.save() + canvas.translate(translateX, translateY) + if (urlImage != null && urlImageMatrix != null) { + canvas.drawBitmap(urlImage.bitmap, urlImageMatrix, backgroundPaint) + } else { + canvas.drawRect(0f, 0f, tileWidth, tileHeight, backgroundPaint) + } + canvas.restore() + translateY += tileHeight + ySpacing + } + translateX += tileWidth + xSpacing } - translateX += tileWidth + xSpacing + } catch (e: RuntimeException) { + // Like `ReactImageView`: the canvas throws for a bitmap that is too large to draw. + canvas.restoreToCount(saveCount) + FLog.w(ReactConstants.TAG, e, "Could not draw background image: %s", uri) } } } @@ -350,16 +401,37 @@ internal class BackgroundImageDrawable( containerHeight: Float, imageWidth: Float, imageHeight: Float, + hasIntrinsicRatio: Boolean, backgroundSize: BackgroundSize?, repeat: BackgroundRepeat?, ): Pair { var finalWidth = imageWidth var finalHeight = imageHeight + // An `auto` dimension keeps the image's aspect ratio. Without a ratio (gradients), it stays at + // its intrinsic size. + val keepsRatio = hasIntrinsicRatio && imageWidth > 0 && imageHeight > 0 + val ratio = if (keepsRatio) imageWidth / imageHeight else 0f + var xAuto = true + var yAuto = true if (backgroundSize is BackgroundSize.LengthPercentageAuto) { val w = backgroundSize.lengthPercentage.x val h = backgroundSize.lengthPercentage.y - if (w != null && h != null) { + xAuto = backgroundSize.lengthPercentage.isXAuto() + yAuto = backgroundSize.lengthPercentage.isYAuto() + if (keepsRatio) { + if (w != null) { + finalWidth = positionToPixels(w, containerWidth) + } + if (h != null) { + finalHeight = positionToPixels(h, containerHeight) + } + if (!xAuto && yAuto) { + finalHeight = finalWidth / ratio + } else if (xAuto && !yAuto) { + finalWidth = finalHeight * ratio + } + } else if (w != null && h != null) { finalWidth = positionToPixels(w, containerWidth) finalHeight = positionToPixels(h, containerHeight) } @@ -367,22 +439,29 @@ internal class BackgroundImageDrawable( if (repeat?.x == BackgroundRepeatKeyword.Round && finalWidth > 0) { if (!FloatUtil.floatsEqual(containerWidth.rem(finalWidth), 0f)) { - val numRepeats = round(containerWidth / finalWidth) - if (numRepeats > 0) { - finalWidth = containerWidth / numRepeats - } + // CSS rounds to the nearest whole number of tiles, but at least one. + val numRepeats = maxOf(1f, round(containerWidth / finalWidth)) + finalWidth = containerWidth / numRepeats } } if (repeat?.y == BackgroundRepeatKeyword.Round && finalHeight > 0) { if (!FloatUtil.floatsEqual(containerHeight.rem(finalHeight), 0f)) { - val numRepeats = round(containerHeight / finalHeight) - if (numRepeats > 0) { - finalHeight = containerHeight / numRepeats - } + // CSS rounds to the nearest whole number of tiles, but at least one. + val numRepeats = maxOf(1f, round(containerHeight / finalHeight)) + finalHeight = containerHeight / numRepeats } } + // With `round` on one axis only, an `auto` other axis is scaled again to restore the ratio. + val roundX = repeat?.x == BackgroundRepeatKeyword.Round + val roundY = repeat?.y == BackgroundRepeatKeyword.Round + if (keepsRatio && roundX && !roundY && yAuto) { + finalHeight = finalWidth / ratio + } else if (keepsRatio && roundY && !roundX && xAuto) { + finalWidth = finalHeight * ratio + } + return finalWidth to finalHeight } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/BackgroundImageURLLoader.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/BackgroundImageURLLoader.kt new file mode 100644 index 000000000000..1ca1b4223c6f --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/BackgroundImageURLLoader.kt @@ -0,0 +1,184 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.uimanager.drawable + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Matrix +import android.graphics.RectF +import com.facebook.common.executors.UiThreadImmediateExecutorService +import com.facebook.common.logging.FLog +import com.facebook.common.references.CloseableReference +import com.facebook.datasource.BaseDataSubscriber +import com.facebook.datasource.DataSource +import com.facebook.drawee.backends.pipeline.Fresco +import com.facebook.drawee.components.DeferredReleaser +import com.facebook.imagepipeline.common.ImageDecodeOptions +import com.facebook.imagepipeline.image.CloseableImage +import com.facebook.imagepipeline.image.CloseableStaticBitmap +import com.facebook.imagepipeline.request.ImageRequestBuilder +import com.facebook.react.common.ReactConstants +import com.facebook.react.uimanager.style.BackgroundImageLayer +import com.facebook.react.views.imagehelper.ImageSource + +/** + * Loads the `url()` layers of a view's background image. Keeps one request for each unique uri and + * calls [onLoad] on the UI thread each time an image finishes loading. Use it only on the UI + * thread. + */ +internal class BackgroundImageURLLoader( + private val context: Context, + private val onLoad: () -> Unit, +) { + + private var entries: List = emptyList() + + /** + * Requests every `url()` layer in [backgroundImageLayers]. Does nothing if the uris did not + * change since the last call. Cancels all requests when there are no `url()` layers. + */ + fun loadBackgroundImages(backgroundImageLayers: List) { + val distinctUris = + backgroundImageLayers.mapNotNull { it.uri }.filter { it.isNotEmpty() }.distinct() + if (distinctUris == entries.map { it.uri }) { + return + } + + // Keep the entries for uris that are still used, so their images do not disappear while the + // new ones load. + val newEntries = mutableListOf() + val nextEntries = + distinctUris.map { uri -> + entries.find { it.uri == uri } ?: Entry(uri).also { newEntries.add(it) } + } + entries.filter { it !in nextEntries }.forEach { it.cancel() } + entries = nextEntries + newEntries.forEach { it.start() } + } + + fun cancelRequests() { + entries.forEach { it.cancel() } + entries = emptyList() + } + + /** Returns null until the image for [uri] has loaded. */ + fun imageForUri(uri: String): OrientedBitmap? = entries.find { it.uri == uri }?.image + + /** + * A decoded image from the pipeline's cache. Fresco's drawee also draws the shared bitmap and + * applies the EXIF orientation when it draws, in the same way as [drawMatrix]. + */ + class OrientedBitmap(val bitmap: Bitmap, exifOrientation: Int, rotationAngle: Int) { + private val orientation = + Matrix().apply { + when (exifOrientation) { + 2 -> setScale(-1f, 1f) + 4 -> setScale(1f, -1f) + 5 -> { + setRotate(270f) + postScale(1f, -1f) + } + 7 -> { + setRotate(270f) + postScale(-1f, 1f) + } + else -> if (rotationAngle > 0) setRotate(rotationAngle.toFloat()) + } + } + private val orientedBounds = + RectF(0f, 0f, bitmap.width.toFloat(), bitmap.height.toFloat()).also { + orientation.mapRect(it) + } + + /** Width in pixels, after the EXIF orientation. */ + val width: Float + get() = orientedBounds.width() + + /** Height in pixels, after the EXIF orientation. */ + val height: Float + get() = orientedBounds.height() + + /** Returns a matrix that draws [bitmap] upright and scaled to [width] x [height]. */ + fun drawMatrix(width: Float, height: Float): Matrix = + Matrix(orientation).apply { + postTranslate(-orientedBounds.left, -orientedBounds.top) + postScale(width / orientedBounds.width(), height / orientedBounds.height()) + } + } + + private inner class Entry(val uri: String) { + var image: OrientedBitmap? = null + private set + + private var dataSource: DataSource>? = null + // Keeps the bitmap in the pipeline's cache while this entry draws it. + private var imageReference: CloseableReference? = null + private var cancelled = false + + fun start() { + val imageRequest = + ImageRequestBuilder.newBuilderWithSource(ImageSource(context, uri).uri) + // Like iOS, animated images show their first frame. + .setImageDecodeOptions( + ImageDecodeOptions.newBuilder().setForceStaticImage(true).build() + ) + .build() + val dataSource = Fresco.getImagePipeline().fetchDecodedImage(imageRequest, null) + this.dataSource = dataSource + dataSource.subscribe( + object : BaseDataSubscriber>() { + override fun onNewResultImpl( + dataSource: DataSource> + ) { + if (!dataSource.isFinished) { + return + } + val imageReference = dataSource.result ?: return + // This is the final image. Closing the data source makes any later callback get + // no result, so only this reference is kept. + dataSource.close() + val staticBitmap = imageReference.get() as? CloseableStaticBitmap + val bitmap = staticBitmap?.underlyingBitmap + if (cancelled || staticBitmap == null || bitmap == null) { + CloseableReference.closeSafely(imageReference) + return + } + this@Entry.imageReference = imageReference + image = + OrientedBitmap(bitmap, staticBitmap.exifOrientation, staticBitmap.rotationAngle) + onLoad() + } + + override fun onFailureImpl(dataSource: DataSource>) { + FLog.w( + ReactConstants.TAG, + dataSource.failureCause, + "Failed to load background image: %s", + uri, + ) + } + }, + // Like Fresco's drawee. A cached image calls back at once. + UiThreadImmediateExecutorService.getInstance(), + ) + } + + fun cancel() { + cancelled = true + dataSource?.close() + image = null + val imageReference = imageReference ?: return + this.imageReference = null + // The last frame drawn can still use the bitmap, so release it after that frame, like + // Fresco's drawee. + DeferredReleaser.getInstance().scheduleDeferredRelease { + CloseableReference.closeSafely(imageReference) + } + } + } +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/BackgroundImageLayer.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/BackgroundImageLayer.kt index 8e5cc7d8c2a4..6758ebce8f6a 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/BackgroundImageLayer.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/BackgroundImageLayer.kt @@ -13,11 +13,11 @@ import com.facebook.react.bridge.ReadableMap import com.facebook.react.bridge.ReadableType /** - * Represents a single layer of a background image, typically containing a gradient. + * Represents a single layer of a background image: a gradient, or an image loaded from a `url()`. * - * This class encapsulates gradient definitions (linear or radial) that can be applied as background - * layers to React Native views. It provides parsing from React Native bridge data and shader - * generation for rendering. + * This class encapsulates gradient definitions (linear or radial) and `url()` images that can be + * applied as background layers to React Native views. It provides parsing from React Native bridge + * data and shader generation for rendering gradients. * * @see LinearGradient * @see RadialGradient @@ -25,6 +25,14 @@ import com.facebook.react.bridge.ReadableType public class BackgroundImageLayer() { private lateinit var gradient: Gradient + /** The source uri of a `url()` layer, or null for a gradient layer. */ + internal var uri: String? = null + + /** Natural size in DIPs, supplied for bundled assets. Null for a plain url. */ + internal var intrinsicWidth: Float? = null + + internal var intrinsicHeight: Float? = null + private constructor(gradient: Gradient) : this() { this.gradient = gradient } @@ -33,10 +41,10 @@ public class BackgroundImageLayer() { /** * Parses a ReadableMap into a BackgroundImageLayer. * - * The map should contain gradient configuration including a "type" key specifying either - * "linear-gradient" or "radial-gradient". + * The map should contain a "type" key specifying either "linear-gradient", "radial-gradient", + * or "url". * - * @param gradientMap The map containing gradient configuration + * @param gradientMap The map containing the background image configuration * @param context Android context for resource resolution * @return A BackgroundImageLayer instance, or null if parsing fails */ @@ -44,6 +52,13 @@ public class BackgroundImageLayer() { if (gradientMap == null) { return null } + if ( + gradientMap.hasKey("type") && + gradientMap.getType("type") == ReadableType.String && + gradientMap.getString("type") == "url" + ) { + return parseURLImage(gradientMap) + } val gradient = parseGradient(gradientMap, context) ?: return null return BackgroundImageLayer(gradient) } @@ -59,6 +74,25 @@ public class BackgroundImageLayer() { else -> null } } + + private fun parseURLImage(urlMap: ReadableMap): BackgroundImageLayer? { + if (!urlMap.hasKey("uri") || urlMap.getType("uri") != ReadableType.String) { + return null + } + val uri = urlMap.getString("uri") ?: return null + return BackgroundImageLayer().apply { + this.uri = uri + intrinsicWidth = readDimension(urlMap, "intrinsicWidth") + intrinsicHeight = readDimension(urlMap, "intrinsicHeight") + } + } + + private fun readDimension(map: ReadableMap, key: String): Float? = + if (map.hasKey(key) && map.getType(key) == ReadableType.Number) { + map.getDouble(key).toFloat() + } else { + null + } } /** diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewManager.kt index 21ef11182da3..1ae08f279020 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewManager.kt @@ -88,6 +88,8 @@ public open class ReactViewManager : ReactClippingViewManager() } override fun onDropViewInstance(view: ReactViewGroup) { + // Before `super`, which may recycle the view and drop its background. + BackgroundStyleApplicator.cancelBackgroundImageURLRequests(view) super.onDropViewInstance(view) view.cleanUpAxOrderListener() } diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/drawable/BackgroundImageURLLoaderTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/drawable/BackgroundImageURLLoaderTest.kt new file mode 100644 index 000000000000..bd13cbe7fae2 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/drawable/BackgroundImageURLLoaderTest.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.uimanager.drawable + +import android.graphics.Bitmap +import org.assertj.core.api.Assertions.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.GraphicsMode + +/** Tests for [BackgroundImageURLLoader.OrientedBitmap] */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class BackgroundImageURLLoaderTest { + private val bitmap = Bitmap.createBitmap(60, 40, Bitmap.Config.ARGB_8888) + + /** + * Maps the first and the last pixel corner of the bitmap's first row into a 0..1 box of the + * upright image. + */ + private fun firstRowCorners(exifOrientation: Int, rotationAngle: Int = 0): List { + val image = BackgroundImageURLLoader.OrientedBitmap(bitmap, exifOrientation, rotationAngle) + val points = floatArrayOf(0f, 0f, bitmap.width.toFloat(), 0f) + image.drawMatrix(1f, 1f).mapPoints(points) + return points.map { Math.round(it * 100) / 100f } + } + + @Test + fun testKeepsSizeWithoutOrientation() { + val image = BackgroundImageURLLoader.OrientedBitmap(bitmap, 1, 0) + assertThat(image.width).isEqualTo(60f) + assertThat(image.height).isEqualTo(40f) + assertThat(firstRowCorners(1)).containsExactly(0f, 0f, 1f, 0f) + } + + @Test + fun testMirrorsAndRotatesWithoutSwappingAxes() { + assertThat(firstRowCorners(2)).containsExactly(1f, 0f, 0f, 0f) + assertThat(firstRowCorners(3, 180)).containsExactly(1f, 1f, 0f, 1f) + assertThat(firstRowCorners(4)).containsExactly(0f, 1f, 1f, 1f) + } + + @Test + fun testSwapsAxesForQuarterTurns() { + for (orientation in listOf(5 to 0, 6 to 90, 7 to 0, 8 to 270)) { + val image = + BackgroundImageURLLoader.OrientedBitmap(bitmap, orientation.first, orientation.second) + assertThat(image.width).isEqualTo(40f) + assertThat(image.height).isEqualTo(60f) + } + // The first row becomes the left, right, right, and left edge. + assertThat(firstRowCorners(5)).containsExactly(0f, 0f, 0f, 1f) + assertThat(firstRowCorners(6, 90)).containsExactly(1f, 0f, 1f, 1f) + assertThat(firstRowCorners(7)).containsExactly(1f, 1f, 1f, 0f) + assertThat(firstRowCorners(8, 270)).containsExactly(0f, 1f, 0f, 0f) + } +} diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/style/BackgroundImageLayerTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/style/BackgroundImageLayerTest.kt new file mode 100644 index 000000000000..0dc44cf739fa --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/style/BackgroundImageLayerTest.kt @@ -0,0 +1,110 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.uimanager.style + +import android.content.Context +import android.graphics.Color +import android.graphics.LinearGradient as AndroidLinearGradient +import android.util.DisplayMetrics +import com.facebook.react.bridge.JavaOnlyArray +import com.facebook.react.bridge.JavaOnlyMap +import com.facebook.react.uimanager.DisplayMetricsHolder +import org.assertj.core.api.Assertions.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +/** Tests for [BackgroundImageLayer] */ +@RunWith(RobolectricTestRunner::class) +class BackgroundImageLayerTest { + private lateinit var context: Context + + @Before + fun setUp() { + val metrics = DisplayMetrics() + metrics.density = 1f + DisplayMetricsHolder.setScreenDisplayMetrics(metrics) + context = RuntimeEnvironment.getApplication() + } + + @Test + fun testParsesURL() { + val layer = + BackgroundImageLayer.parse( + JavaOnlyMap.of("type", "url", "uri", "https://example.com/a.png"), + context, + ) + + assertThat(layer).isNotNull + assertThat(layer?.uri).isEqualTo("https://example.com/a.png") + assertThat(layer?.intrinsicWidth).isNull() + assertThat(layer?.intrinsicHeight).isNull() + } + + @Test + fun testParsesURLIntrinsicSize() { + val layer = + BackgroundImageLayer.parse( + JavaOnlyMap.of( + "type", + "url", + "uri", + "asset.png", + "intrinsicWidth", + 100.0, + "intrinsicHeight", + 50.0, + ), + context, + ) + + assertThat(layer).isNotNull + assertThat(layer?.uri).isEqualTo("asset.png") + assertThat(layer?.intrinsicWidth).isEqualTo(100f) + assertThat(layer?.intrinsicHeight).isEqualTo(50f) + } + + @Test + fun testRejectsURLWithoutStringURI() { + assertThat(BackgroundImageLayer.parse(JavaOnlyMap.of("type", "url"), context)).isNull() + assertThat(BackgroundImageLayer.parse(JavaOnlyMap.of("type", "url", "uri", 1.0), context)) + .isNull() + } + + @Test + fun testParsesGradient() { + val layer = + BackgroundImageLayer.parse( + JavaOnlyMap.of( + "type", + "linear-gradient", + "direction", + JavaOnlyMap.of("type", "angle", "value", 180.0), + "colorStops", + JavaOnlyArray.of( + JavaOnlyMap.of("color", Color.RED), + JavaOnlyMap.of("color", Color.BLUE), + ), + ), + context, + ) + + assertThat(layer).isNotNull + assertThat(layer?.uri).isNull() + assertThat(layer?.getShader(100f, 100f)).isInstanceOf(AndroidLinearGradient::class.java) + } + + @Test + fun testRejectsUnknownType() { + assertThat(BackgroundImageLayer.parse(JavaOnlyMap.of("type", "conic-gradient"), context)) + .isNull() + assertThat(BackgroundImageLayer.parse(null, context)).isNull() + } +}