diff --git a/detekt_custom_safe_calls.yml b/detekt_custom_safe_calls.yml index 243c0efb2d..31f738de15 100644 --- a/detekt_custom_safe_calls.yml +++ b/detekt_custom_safe_calls.yml @@ -260,6 +260,8 @@ datadog: - "androidx.compose.runtime.tooling.CompositionGroup.parameters(com.datadog.android.sessionreplay.compose.internal.data.ComposeContext)" - "androidx.compose.runtime.tooling.CompositionGroup.stableId()" - "androidx.compose.ui.graphics.Color(kotlin.Long)" + - "androidx.compose.ui.graphics.Color.constructor(kotlin.ULong)" + - "androidx.compose.ui.graphics.Color.copy(kotlin.Float, kotlin.Float, kotlin.Float, kotlin.Float)" - "androidx.compose.ui.graphics.Color.toArgb()" - "androidx.compose.ui.graphics.Matrix.constructor(kotlin.FloatArray)" - "androidx.compose.ui.graphics.Matrix.scale(kotlin.Float, kotlin.Float, kotlin.Float)" @@ -814,6 +816,7 @@ datadog: - "kotlin.collections.List.elementAtOrNull(kotlin.Int)" - "kotlin.collections.List.filter(kotlin.Function1)" - "kotlin.collections.List.filterIndexed(kotlin.Function2)" + - "kotlin.collections.List.filterIsInstance()" - "kotlin.collections.List.filterNot(kotlin.Function1)" - "kotlin.collections.List.filterNotNull(kotlin.Function1)" - "kotlin.collections.List.findFirstForType(java.lang.Class)" @@ -1050,6 +1053,7 @@ datadog: - "kotlin.collections.emptySet()" - "kotlin.collections.listOf()" - "kotlin.collections.listOf(android.view.Window)" + - "kotlin.collections.listOf(androidx.compose.ui.graphics.Color)" - "kotlin.collections.listOf(com.datadog.android.api.InternalLogger.Target)" - "kotlin.collections.listOf(com.datadog.android.rum.internal.vitals.FPSVitalListener)" - "kotlin.collections.listOf(com.datadog.android.flags.model.BatchedFlagEvaluations.FlagEvaluation)" diff --git a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/AbstractSemanticsNodeMapper.kt b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/AbstractSemanticsNodeMapper.kt index 33fa732e70..31d29cc3a2 100644 --- a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/AbstractSemanticsNodeMapper.kt +++ b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/AbstractSemanticsNodeMapper.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.text.style.TextAlign import com.datadog.android.sessionreplay.compose.internal.data.UiContext import com.datadog.android.sessionreplay.compose.internal.utils.BackgroundInfo import com.datadog.android.sessionreplay.compose.internal.utils.SemanticsUtils +import com.datadog.android.sessionreplay.compose.internal.utils.SemanticsUtils.Companion.COLOR_UNSPECIFIED import com.datadog.android.sessionreplay.model.MobileSegment import com.datadog.android.sessionreplay.utils.ColorStringFormatter import com.datadog.android.sessionreplay.utils.GlobalBounds @@ -108,7 +109,7 @@ internal abstract class AbstractSemanticsNodeMapper( } protected fun convertColor(color: Long): String? { - return if (color == UNSPECIFIED_COLOR) { + return if (color == COLOR_UNSPECIFIED) { null } else { val c = Color(color shr COMPOSE_COLOR_SHIFT) @@ -120,8 +121,6 @@ internal abstract class AbstractSemanticsNodeMapper( } companion object { - /** As defined in Compose's ColorSpaces. */ - private const val UNSPECIFIED_COLOR = 16L private const val COMPOSE_COLOR_SHIFT = 32 private const val MAX_ALPHA = 255 private const val SEMANTICS_ID_BIT_SHIFT = 32 diff --git a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/reflection/ComposeReflection.kt b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/reflection/ComposeReflection.kt index 2c40cdc2ca..d95e4754a7 100644 --- a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/reflection/ComposeReflection.kt +++ b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/reflection/ComposeReflection.kt @@ -50,6 +50,23 @@ internal object ComposeReflection { val ColorField = BackgroundElementClass?.getDeclaredFieldSafe("color") val ShapeField = BackgroundElementClass?.getDeclaredFieldSafe("shape") + // isCritical = false: brush and alpha are newer BackgroundElement fields used for the + // Modifier.background(brush, shape, alpha) overload. If they are absent (e.g. on an older + // Compose version whose internal layout differs), we fall back gracefully: brush backgrounds + // render with no color rather than crashing. ColorField above stays critical because we have + // no meaningful fallback if the primary color field is missing. + val BrushField = BackgroundElementClass?.getDeclaredFieldSafe("brush", isCritical = false) + val AlphaField = BackgroundElementClass?.getDeclaredFieldSafe("alpha", isCritical = false) + + // Gradient color-list fields are also non-critical: unavailable on the unit-test JVM and on + // Compose versions that rename internal classes. Missing → logUnsupportedBrush telemetry. + val LinearGradientClass = getClassSafe("androidx.compose.ui.graphics.LinearGradient", isCritical = false) + val LinearGradientColorsField = LinearGradientClass?.getDeclaredFieldSafe("colors", isCritical = false) + val RadialGradientClass = getClassSafe("androidx.compose.ui.graphics.RadialGradient", isCritical = false) + val RadialGradientColorsField = RadialGradientClass?.getDeclaredFieldSafe("colors", isCritical = false) + val SweepGradientClass = getClassSafe("androidx.compose.ui.graphics.SweepGradient", isCritical = false) + val SweepGradientColorsField = SweepGradientClass?.getDeclaredFieldSafe("colors", isCritical = false) + val DrawBehindElementClass = getClassSafe("androidx.compose.ui.draw.DrawBehindElement") val CheckboxKtClass = getClassSafe("androidx.compose.material.CheckboxKt\$CheckboxImpl\$1\$1", false) val RadioButtonKtClass = getClassSafe("androidx.compose.material.RadioButtonKt\$RadioButton\$2\$1", false) diff --git a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/ReflectionUtils.kt b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/ReflectionUtils.kt index e0b4acc5c1..bfdaf25cd9 100644 --- a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/ReflectionUtils.kt +++ b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/ReflectionUtils.kt @@ -13,10 +13,12 @@ import androidx.compose.animation.core.AnimationState import androidx.compose.runtime.Composition import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorProducer import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.VectorPainter @@ -133,6 +135,32 @@ internal class ReflectionUtils { return ComposeReflection.ColorField?.getSafe(modifier) as? Long } + fun getBrush(modifier: Modifier): Brush? { + return ComposeReflection.BrushField?.getSafe(modifier) as? Brush + } + + fun getAlpha(modifier: Modifier): Float? { + return ComposeReflection.AlphaField?.getSafe(modifier) as? Float + } + + // Note: SolidColor is handled via its public API (brush.value) while gradient types are + // accessed via reflection. This asymmetry is intentional: SolidColor is a stable public + // class, whereas LinearGradient/RadialGradient/SweepGradient are internal Compose types + // with no guaranteed public accessor for their color lists. + fun getBrushColors(brush: Brush): List? { + if (brush is SolidColor) return listOf(brush.value) + val colorsField = when { + ComposeReflection.LinearGradientClass?.isInstance(brush) == true -> + ComposeReflection.LinearGradientColorsField + ComposeReflection.RadialGradientClass?.isInstance(brush) == true -> + ComposeReflection.RadialGradientColorsField + ComposeReflection.SweepGradientClass?.isInstance(brush) == true -> + ComposeReflection.SweepGradientColorsField + else -> null + } + return (colorsField?.getSafe(brush) as? List<*>)?.filterIsInstance() + } + fun getShape(modifier: Modifier): Shape? { return ComposeReflection.ShapeField?.getSafe(modifier) as? Shape } diff --git a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/SemanticsUtils.kt b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/SemanticsUtils.kt index 13f5e6af8d..b0cd2660f3 100644 --- a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/SemanticsUtils.kt +++ b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/SemanticsUtils.kt @@ -97,7 +97,7 @@ internal class SemanticsUtils( // then reset `currentBackgroundInfo`. semanticsNode.layoutInfo.getModifierInfo().forEach { modifierInfo -> if (reflectionUtils.isBackgroundElement(modifierInfo.modifier)) { - val color = reflectionUtils.getColor(modifierInfo.modifier) + val color = resolveBackgroundElementColor(modifierInfo.modifier) currentBackgroundInfo = currentBackgroundInfo.copy(globalBounds = currentBounds, color = color) backgroundInfoList.add(currentBackgroundInfo) currentBackgroundInfo = BackgroundInfo() @@ -118,7 +118,70 @@ internal class SemanticsUtils( semanticsNode.layoutInfo.getModifierInfo().firstOrNull { modifierInfo -> reflectionUtils.isBackgroundElement(modifierInfo.modifier) } - return backgroundModifierInfo?.let { reflectionUtils.getColor(it.modifier) } + return backgroundModifierInfo?.let { resolveBackgroundElementColor(it.modifier) } + } + + private fun resolveBackgroundElementColor(modifier: Modifier): Long? { + val rawColor = reflectionUtils.getColor(modifier) + val alpha = reflectionUtils.getAlpha(modifier) + if (rawColor != null && rawColor != COLOR_UNSPECIFIED) { + return applyAlphaToColor(rawColor, alpha) + } + return resolveBrushColor(modifier, alpha) + } + + private fun resolveBrushColor(modifier: Modifier, alpha: Float?): Long? { + val brush = reflectionUtils.getBrush(modifier) ?: return null + val colors = reflectionUtils.getBrushColors(brush) + // null → unknown Brush type (no reflection field); log for investigation. + // empty → reflection succeeded but the color list was unexpectedly empty; also log. + // Gradients are reduced to their first stop for replay purposes. + return if (colors.isNullOrEmpty()) { + logUnsupportedBrush(brush) + null + } else { + // isNullOrEmpty() check above guarantees the list is non-empty here + @Suppress("UnsafeThirdPartyFunctionCall") + applyAlphaToColor(colors.first().value.toLong(), alpha) + } + } + + /** + * Multiplies the modifier-level [alpha] (from `Modifier.background(brush, shape, alpha)`) + * into the alpha channel of the given [colorValue]. + * + * [colorValue] is a Compose color packed as a 64-bit long (the bit layout of [Color.value]). + * A null or >= 1f [alpha] is a no-op so that the common fully-opaque case costs nothing extra. + * + * When [alpha] <= 0f the element is fully invisible. We return [COLOR_UNSPECIFIED] so that + * [com.datadog.android.sessionreplay.compose.internal.mappers.semantics.AbstractSemanticsNodeMapper.convertColor] + * converts it to null, producing a wireframe with no backgroundColor — the same representation + * used when color information is unavailable. This keeps the player contract consistent: a + * null backgroundColor means "no fill", regardless of why the fill was absent. + */ + private fun applyAlphaToColor(colorValue: Long, alpha: Float?): Long { + return when { + alpha == null || alpha >= 1f -> colorValue + alpha <= 0f -> COLOR_UNSPECIFIED + else -> { + val color = Color(colorValue.toULong()) + color.copy(alpha = (color.alpha * alpha).coerceIn(0f, 1f)).value.toLong() + } + } + } + + private fun logUnsupportedBrush(brush: Any) { + val brushType = brush.javaClass.name + (Datadog.getInstance() as? FeatureSdkCore)?.internalLogger?.log( + level = InternalLogger.Level.INFO, + targets = listOf(InternalLogger.Target.MAINTAINER, InternalLogger.Target.TELEMETRY), + messageBuilder = { "Unsupported Brush type for Compose background: $brushType" }, + onlyOnce = true, + additionalProperties = mapOf( + "brush.type" to brushType, + COMPONENT_KEY to COMPONENT_NAME + ) + ) } internal fun resolveBackgroundShape(semanticsNode: SemanticsNode): Shape? { @@ -558,6 +621,10 @@ internal class SemanticsUtils( } internal const val DEFAULT_COLOR_BLACK = "#000000FF" internal const val DEFAULT_COLOR_WHITE = "#FFFFFFFF" + + /** Raw Long encoding of [androidx.compose.ui.graphics.Color.Unspecified] (color-space id 16). */ + internal const val COLOR_UNSPECIFIED = 16L + private const val BITMAP_TELEMETRY_SAMPLE_RATE = 1f private const val COMPONENT_NAME = "SemanticsUtils" diff --git a/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/ReflectionUtilsTest.kt b/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/ReflectionUtilsTest.kt new file mode 100644 index 0000000000..0c5e0ee740 --- /dev/null +++ b/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/ReflectionUtilsTest.kt @@ -0,0 +1,85 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.compose.internal.utils + +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import com.datadog.android.sessionreplay.compose.test.elmyr.SessionReplayComposeForgeConfigurator +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.Extensions +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.mock +import org.mockito.quality.Strictness + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(SessionReplayComposeForgeConfigurator::class) +internal class ReflectionUtilsTest { + + private lateinit var testedReflectionUtils: ReflectionUtils + + @BeforeEach + fun `set up`() { + testedReflectionUtils = ReflectionUtils() + } + + // region getBrushColors + + @Test + fun `M return single-element list W getBrushColors { SolidColor }`() { + // Given + val fakeColor = Color.Red + val solidBrush = SolidColor(fakeColor) + + // When + val result = testedReflectionUtils.getBrushColors(solidBrush) + + // Then + assertThat(result).containsExactly(fakeColor) + } + + @Test + fun `M return Color from SolidColor W getBrushColors { SolidColor with arbitrary color }`() { + // Given — verify the value round-trips correctly, not just that the list is non-empty + val fakeColor = Color(0xFF42A5F5.toInt()) + val solidBrush = SolidColor(fakeColor) + + // When + val result = testedReflectionUtils.getBrushColors(solidBrush) + + // Then — verify the color value round-trips correctly through the list + assertThat(result).containsExactly(fakeColor) + } + + @Test + fun `M return null W getBrushColors { non-SolidColor brush }`() { + // Given — any non-SolidColor brush hits the reflection path. In the unit-test JVM, + // ComposeReflection.LinearGradientClass and siblings are null (internal Compose classes + // not on the test classpath), so the when-expression always falls to else -> null. + // This covers both the "unknown brush type" and "gradient with reflection unavailable" + // cases — both result in null, which is the correct safe fallback. + val mockBrush = mock() + + // When + val result = testedReflectionUtils.getBrushColors(mockBrush) + + // Then + assertThat(result).isNull() + } + + // endregion +} diff --git a/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/SemanticsUtilsTest.kt b/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/SemanticsUtilsTest.kt index d2244d40b6..0532054af7 100644 --- a/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/SemanticsUtilsTest.kt +++ b/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/SemanticsUtilsTest.kt @@ -17,9 +17,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.graphics.vector.VectorPainter import androidx.compose.ui.layout.ContentScale @@ -45,6 +47,7 @@ import androidx.compose.ui.unit.TextUnitType import androidx.compose.ui.unit.dp import com.datadog.android.sessionreplay.compose.internal.data.BitmapInfo import com.datadog.android.sessionreplay.compose.internal.mappers.semantics.TextLayoutInfo +import com.datadog.android.sessionreplay.compose.internal.utils.SemanticsUtils.Companion.COLOR_UNSPECIFIED import com.datadog.android.sessionreplay.compose.test.elmyr.SessionReplayComposeForgeConfigurator import com.datadog.android.sessionreplay.model.MobileSegment import com.datadog.android.sessionreplay.utils.GlobalBounds @@ -70,6 +73,8 @@ import org.mockito.kotlin.any import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.mockito.quality.Strictness import java.util.stream.Stream @@ -80,7 +85,7 @@ import java.util.stream.Stream ) @MockitoSettings(strictness = Strictness.LENIENT) @ForgeConfiguration(SessionReplayComposeForgeConfigurator::class) -class SemanticsUtilsTest { +internal class SemanticsUtilsTest { private lateinit var testedSemanticsUtils: SemanticsUtils @@ -345,11 +350,14 @@ class SemanticsUtilsTest { } @Test - fun `M return color W resolveBackgroundColor`(forge: Forge) { - // Given - val fakeColorValue = forge.aLong() + fun `M return color W resolveBackgroundColor`( + @LongForgery(min = 17L) fakeColorValue: Long + ) { + // Given — min = 17L avoids Color.Unspecified (16L) which would trigger the brush fallback. + // alpha = null → applyAlphaToColor is a no-op; raw color value must come back unchanged. whenever(mockReflectionUtils.isBackgroundElement(mockModifier)) doReturn true whenever(mockReflectionUtils.getColor(mockModifier)) doReturn fakeColorValue + whenever(mockReflectionUtils.getAlpha(mockModifier)) doReturn null // When val result = testedSemanticsUtils.resolveBackgroundColor(mockSemanticsNode) @@ -358,6 +366,118 @@ class SemanticsUtilsTest { assertThat(result).isEqualTo(fakeColorValue) } + @Test + fun `M fall back to brush first stop W resolveBackgroundColor { gradient brush }`() { + // Given + val firstStop = Color.Red + val secondStop = Color.Blue + val mockBrush = mock() + val mockBackgroundModifierInfo = backgroundModifierStub( + color = COLOR_UNSPECIFIED, + brush = mockBrush + ) + whenever(mockReflectionUtils.getBrushColors(mockBrush)) doReturn listOf(firstStop, secondStop) + whenever(mockLayoutInfo.getModifierInfo()) doReturn listOf(mockBackgroundModifierInfo) + + // When + val result = testedSemanticsUtils.resolveBackgroundColor(mockSemanticsNode) + + // Then + assertThat(result).isEqualTo(firstStop.value.toLong()) + } + + @Test + fun `M return null W resolveBackgroundColor { unspecified color and no brush }`() { + // Given + val mockBackgroundModifierInfo = backgroundModifierStub( + color = COLOR_UNSPECIFIED, + brush = null + ) + whenever(mockLayoutInfo.getModifierInfo()) doReturn listOf(mockBackgroundModifierInfo) + + // When + val result = testedSemanticsUtils.resolveBackgroundColor(mockSemanticsNode) + + // Then + assertThat(result).isNull() + } + + @Test + fun `M return null W resolveBackgroundColor { unknown brush type }`() { + // Given + val mockBrush = mock() + val mockBackgroundModifierInfo = backgroundModifierStub( + color = COLOR_UNSPECIFIED, + brush = mockBrush + ) + whenever(mockReflectionUtils.getBrushColors(mockBrush)) doReturn null + whenever(mockLayoutInfo.getModifierInfo()) doReturn listOf(mockBackgroundModifierInfo) + + // When + val result = testedSemanticsUtils.resolveBackgroundColor(mockSemanticsNode) + + // Then + assertThat(result).isNull() + } + + @Test + fun `M prefer color over brush W resolveBackgroundColor { both color and brush set }`( + @LongForgery(min = 17L) explicitColorValue: Long + ) { + // Given — when BackgroundElement.color is explicitly set (not Unspecified) we must use + // it, even if some future Compose version starts setting brush in parallel. + val mockBrush = mock() + val mockBackgroundModifierInfo = backgroundModifierStub( + color = explicitColorValue, + brush = mockBrush + ) + whenever(mockLayoutInfo.getModifierInfo()) doReturn listOf(mockBackgroundModifierInfo) + + // When + val result = testedSemanticsUtils.resolveBackgroundColor(mockSemanticsNode) + + // Then + assertThat(result).isEqualTo(explicitColorValue) + verify(mockReflectionUtils, never()).getBrushColors(any()) + } + + @Test + fun `M fall back to brush W resolveBackgroundColor { null color and brush set }`() { + // Given — getColor returns null (field inaccessible via reflection). This is distinct + // from Color.Unspecified (16L): both trigger the brush fallback, but the null path + // exercises a different branch in resolveBackgroundElementColor. + val firstStop = Color.Cyan + val mockBrush = mock() + val mockBackgroundModifierInfo = backgroundModifierStub( + color = null, + brush = mockBrush + ) + whenever(mockReflectionUtils.getBrushColors(mockBrush)) doReturn listOf(firstStop) + whenever(mockLayoutInfo.getModifierInfo()) doReturn listOf(mockBackgroundModifierInfo) + + // When + val result = testedSemanticsUtils.resolveBackgroundColor(mockSemanticsNode) + + // Then + assertThat(result).isEqualTo(firstStop.value.toLong()) + } + + @Test + fun `M return null W resolveBackgroundColor { null color and no brush }`() { + // Given + val mockBackgroundModifierInfo = backgroundModifierStub( + color = null, + brush = null + ) + whenever(mockLayoutInfo.getModifierInfo()) doReturn listOf(mockBackgroundModifierInfo) + + // When + val result = testedSemanticsUtils.resolveBackgroundColor(mockSemanticsNode) + + // Then + assertThat(result).isNull() + } + @Test fun `M return TextLayoutInfo W resolveTextLayoutInfo modifier color is null`(forge: Forge) { // Given @@ -433,14 +553,14 @@ class SemanticsUtilsTest { @Test fun `M return backgroundInfo W resolveBackgroundInfo`( forge: Forge, - @LongForgery fakeColorValue: Long, + @LongForgery(min = 17L) fakeColorValue: Long, @IntForgery fakeCornerSizeValue: Int ) { // Given val leftPos = forge.aSmallInt() - val rightPos = forge.anInt(leftPos, 0x2000) + val rightPos = forge.anInt(leftPos + MIN_VISIBLE_PX, 0x2000) val topPos = forge.aSmallInt() - val bottomPos = forge.anInt(topPos, 0x2000) + val bottomPos = forge.anInt(topPos + MIN_VISIBLE_PX, 0x2000) val fakeRect = Rect( left = leftPos.toFloat(), top = topPos.toFloat(), @@ -466,6 +586,8 @@ class SemanticsUtilsTest { whenever(mockBackgroundModifierInfo.modifier) doReturn mockBackgroundModifier whenever(mockPaddingModifierInfo.modifier) doReturn mockPaddingModifier whenever(mockReflectionUtils.getColor(mockBackgroundModifier)) doReturn fakeColorValue + // alpha = null → applyAlphaToColor is a no-op; color comes back unchanged + whenever(mockReflectionUtils.getAlpha(mockBackgroundModifier)) doReturn null whenever(mockReflectionUtils.getClipShape(mockShapeModifier)) doReturn mockShape whenever(mockReflectionUtils.getTopPadding(mockPaddingModifier)) doReturn topPadding whenever(mockReflectionUtils.getStartPadding(mockPaddingModifier)) doReturn startPadding @@ -505,6 +627,584 @@ class SemanticsUtilsTest { assertThat(result).containsExactly(expected) } + @Test + fun `M fall back to brush first stop W resolveBackgroundInfo { gradient brush }`(forge: Forge) { + // Given — Modifier.background(brush = ...) leaves BackgroundElement.color as + // Color.Unspecified (raw value 16L); the actual fill lives on `brush`. We expect the + // resolution to fall through to the brush and return the first stop's value. + val (fakeRect, fakeBounds) = forgeBackgroundBounds(forge) + val firstStop = Color.Red + val secondStop = Color.Blue + val mockBrush = mock() + val mockBackgroundModifierInfo = backgroundModifierStub( + color = COLOR_UNSPECIFIED, + brush = mockBrush + ) + whenever(mockReflectionUtils.getBrushColors(mockBrush)) doReturn listOf(firstStop, secondStop) + whenever(mockLayoutInfo.getModifierInfo()) doReturn listOf(mockBackgroundModifierInfo) + whenever(mockSemanticsNode.boundsInRoot) doReturn fakeRect + whenever(mockSemanticsNode.positionInRoot) doReturn Offset(fakeRect.left, fakeRect.top) + + // When + val result = testedSemanticsUtils.resolveBackgroundInfo(mockSemanticsNode) + + // Then + assertThat(result).containsExactly( + BackgroundInfo( + color = firstStop.value.toLong(), + globalBounds = GlobalBounds( + x = fakeBounds.x, + y = fakeBounds.y, + width = fakeBounds.width, + height = fakeBounds.height + ), + cornerRadius = 0f + ) + ) + } + + @Test + fun `M return solid color W resolveBackgroundInfo { SolidColor brush }`(forge: Forge) { + // Given — Modifier.background(brush = SolidColor(c)) is unusual but valid. SolidColor is + // accessed via its public API in ReflectionUtils (not reflection), so it is always available. + val (fakeRect, _) = forgeBackgroundBounds(forge) + val solidColor = Color.Green + val solidBrush = SolidColor(solidColor) + val mockBackgroundModifierInfo = backgroundModifierStub( + color = COLOR_UNSPECIFIED, + brush = solidBrush + ) + whenever(mockReflectionUtils.getBrushColors(solidBrush)) doReturn listOf(solidColor) + whenever(mockLayoutInfo.getModifierInfo()) doReturn listOf(mockBackgroundModifierInfo) + whenever(mockSemanticsNode.boundsInRoot) doReturn fakeRect + whenever(mockSemanticsNode.positionInRoot) doReturn Offset(fakeRect.left, fakeRect.top) + + // When + val result = testedSemanticsUtils.resolveBackgroundInfo(mockSemanticsNode) + + // Then + assertThat(result.single().color).isEqualTo(solidColor.value.toLong()) + } + + @Test + fun `M emit null color W resolveBackgroundInfo { unknown brush type }`(forge: Forge) { + // Given — a custom Brush implementation that ReflectionUtils can't introspect. We + // preserve the pre-fix behaviour (a wireframe is still emitted, but with a null + // backgroundColor — invisible) rather than skipping the wireframe entirely, so the + // shape's bounds still influence layout in the replay. + val (fakeRect, _) = forgeBackgroundBounds(forge) + val mockBrush = mock() + val mockBackgroundModifierInfo = backgroundModifierStub( + color = COLOR_UNSPECIFIED, + brush = mockBrush + ) + whenever(mockReflectionUtils.getBrushColors(mockBrush)) doReturn null + whenever(mockLayoutInfo.getModifierInfo()) doReturn listOf(mockBackgroundModifierInfo) + whenever(mockSemanticsNode.boundsInRoot) doReturn fakeRect + whenever(mockSemanticsNode.positionInRoot) doReturn Offset(fakeRect.left, fakeRect.top) + + // When + val result = testedSemanticsUtils.resolveBackgroundInfo(mockSemanticsNode) + + // Then + assertThat(result).hasSize(1) + assertThat(result.single().color).isNull() + } + + @Test + fun `M emit null color W resolveBackgroundInfo { brush returns empty color list }`(forge: Forge) { + // Given — getBrushColors returns an empty list (reflection succeeded but the colors field + // was unexpectedly empty). This is treated the same as an unknown brush type: a wireframe + // is emitted with null color to preserve layout space. + val (fakeRect, _) = forgeBackgroundBounds(forge) + val mockBrush = mock() + val mockBackgroundModifierInfo = backgroundModifierStub( + color = COLOR_UNSPECIFIED, + brush = mockBrush + ) + whenever(mockReflectionUtils.getBrushColors(mockBrush)) doReturn emptyList() + whenever(mockLayoutInfo.getModifierInfo()) doReturn listOf(mockBackgroundModifierInfo) + whenever(mockSemanticsNode.boundsInRoot) doReturn fakeRect + whenever(mockSemanticsNode.positionInRoot) doReturn Offset(fakeRect.left, fakeRect.top) + + // When + val result = testedSemanticsUtils.resolveBackgroundInfo(mockSemanticsNode) + + // Then + assertThat(result).hasSize(1) + assertThat(result.single().color).isNull() + } + + @Test + fun `M return null W resolveBackgroundColor { brush returns empty color list }`() { + // Given — mirrors the resolveBackgroundInfo empty-list case but through resolveBackgroundColor. + val mockBrush = mock() + val mockBackgroundModifierInfo = backgroundModifierStub( + color = COLOR_UNSPECIFIED, + brush = mockBrush + ) + whenever(mockReflectionUtils.getBrushColors(mockBrush)) doReturn emptyList() + whenever(mockLayoutInfo.getModifierInfo()) doReturn listOf(mockBackgroundModifierInfo) + + // When + val result = testedSemanticsUtils.resolveBackgroundColor(mockSemanticsNode) + + // Then + assertThat(result).isNull() + } + + @Test + fun `M prefer color over brush W resolveBackgroundInfo { both color and brush set }`( + forge: Forge, + @LongForgery(min = 17L) explicitColorValue: Long + ) { + // Given — when BackgroundElement.color is explicitly set (not Unspecified) we must use + // it, even if some future Compose version starts setting brush in parallel. + val (fakeRect, _) = forgeBackgroundBounds(forge) + val mockBrush = mock() + val mockBackgroundModifierInfo = backgroundModifierStub( + color = explicitColorValue, + brush = mockBrush + ) + whenever(mockLayoutInfo.getModifierInfo()) doReturn listOf(mockBackgroundModifierInfo) + whenever(mockSemanticsNode.boundsInRoot) doReturn fakeRect + whenever(mockSemanticsNode.positionInRoot) doReturn Offset(fakeRect.left, fakeRect.top) + + // When + val result = testedSemanticsUtils.resolveBackgroundInfo(mockSemanticsNode) + + // Then + assertThat(result.single().color).isEqualTo(explicitColorValue) + // Brush must not be consulted when the color is already set. + verify(mockReflectionUtils, never()).getBrushColors(any()) + } + + @Test + fun `M fall back to brush W resolveBackgroundInfo { null color and brush set }`(forge: Forge) { + // Given — getColor returns null (field inaccessible via reflection). Both null and + // Color.Unspecified trigger the brush fallback; this test exercises the null path. + val (fakeRect, _) = forgeBackgroundBounds(forge) + val firstStop = Color.Cyan + val mockBrush = mock() + val mockBackgroundModifierInfo = backgroundModifierStub( + color = null, + brush = mockBrush + ) + whenever(mockReflectionUtils.getBrushColors(mockBrush)) doReturn listOf(firstStop) + whenever(mockLayoutInfo.getModifierInfo()) doReturn listOf(mockBackgroundModifierInfo) + whenever(mockSemanticsNode.boundsInRoot) doReturn fakeRect + whenever(mockSemanticsNode.positionInRoot) doReturn Offset(fakeRect.left, fakeRect.top) + + // When + val result = testedSemanticsUtils.resolveBackgroundInfo(mockSemanticsNode) + + // Then + assertThat(result.single().color).isEqualTo(firstStop.value.toLong()) + } + + @Test + fun `M emit null color W resolveBackgroundInfo { null color and no brush }`(forge: Forge) { + // Given + val (fakeRect, _) = forgeBackgroundBounds(forge) + val mockBackgroundModifierInfo = backgroundModifierStub( + color = null, + brush = null + ) + whenever(mockLayoutInfo.getModifierInfo()) doReturn listOf(mockBackgroundModifierInfo) + whenever(mockSemanticsNode.boundsInRoot) doReturn fakeRect + whenever(mockSemanticsNode.positionInRoot) doReturn Offset(fakeRect.left, fakeRect.top) + + // When + val result = testedSemanticsUtils.resolveBackgroundInfo(mockSemanticsNode) + + // Then + assertThat(result).hasSize(1) + assertThat(result.single().color).isNull() + } + + // region alpha tests + + @Test + fun `M apply modifier alpha to solid color W resolveBackgroundColor { alpha less than 1 }`( + // Float.MIN_VALUE as lower bound avoids alpha=0f (fully-transparent degenerate case, + // which is covered by its own dedicated test below). + @FloatForgery(min = Float.MIN_VALUE, max = 1f) fakeAlpha: Float + ) { + // Given — Modifier.background(brush = SolidColor(Color.Red), alpha = fakeAlpha) stores the + // color in the brush field (not the color field) and the alpha in BackgroundElement.alpha. + // We simulate this here by supplying an opaque red via the color path with a non-unity alpha, + // which exercises applyAlphaToColor independently of which branch produced the color. + val opaqueRed = Color.Red + val mockBackgroundModifierInfo = backgroundModifierStub( + color = opaqueRed.value.toLong(), + brush = null, + alpha = fakeAlpha + ) + whenever(mockLayoutInfo.getModifierInfo()) doReturn listOf(mockBackgroundModifierInfo) + + // When + val result = testedSemanticsUtils.resolveBackgroundColor(mockSemanticsNode) + + // Then — the returned color must carry the blended alpha channel. + // Compose stores color components as 16-bit half-floats, so we compute expectedAlpha + // via the same Color.copy() round-trip the implementation uses, rather than using + // 32-bit float arithmetic which would disagree after half-float rounding. + val resultColor = result?.let { Color(it.toULong()) } + val expectedAlpha = opaqueRed.copy(alpha = (opaqueRed.alpha * fakeAlpha).coerceIn(0f, 1f)).alpha + assertThat(resultColor?.alpha).isCloseTo(expectedAlpha, org.assertj.core.data.Offset.offset(0.01f)) + // RGB channels must be preserved (half-float precision, tolerance 0.01f) + assertThat(resultColor?.red).isCloseTo(opaqueRed.red, org.assertj.core.data.Offset.offset(0.01f)) + assertThat(resultColor?.green).isCloseTo(opaqueRed.green, org.assertj.core.data.Offset.offset(0.01f)) + assertThat(resultColor?.blue).isCloseTo(opaqueRed.blue, org.assertj.core.data.Offset.offset(0.01f)) + } + + @Test + fun `M not change color W resolveBackgroundColor { alpha is 1f }`( + @LongForgery(min = 17L) fakeColorValue: Long + ) { + // Given + val mockBackgroundModifierInfo = backgroundModifierStub( + color = fakeColorValue, + brush = null, + alpha = 1f + ) + whenever(mockLayoutInfo.getModifierInfo()) doReturn listOf(mockBackgroundModifierInfo) + + // When + val result = testedSemanticsUtils.resolveBackgroundColor(mockSemanticsNode) + + // Then — alpha == 1f is a no-op; raw value must come back unchanged + assertThat(result).isEqualTo(fakeColorValue) + } + + @Test + fun `M not change color W resolveBackgroundColor { alpha is null }`( + @LongForgery(min = 17L) fakeColorValue: Long + ) { + // Given — alpha field inaccessible via reflection → null + val mockBackgroundModifierInfo = backgroundModifierStub( + color = fakeColorValue, + brush = null, + alpha = null + ) + whenever(mockLayoutInfo.getModifierInfo()) doReturn listOf(mockBackgroundModifierInfo) + + // When + val result = testedSemanticsUtils.resolveBackgroundColor(mockSemanticsNode) + + // Then — null alpha is treated as 1f (no-op) + assertThat(result).isEqualTo(fakeColorValue) + } + + @Test + fun `M apply modifier alpha to brush first stop W resolveBackgroundColor { brush with alpha less than 1 }`( + // Float.MIN_VALUE as lower bound excludes the degenerate alpha=0f case (own test below). + @FloatForgery(min = Float.MIN_VALUE, max = 1f) fakeAlpha: Float + ) { + // Given — opaque green as first brush stop + val firstStop = Color.Green + val mockBrush = mock() + val mockBackgroundModifierInfo = backgroundModifierStub( + color = COLOR_UNSPECIFIED, + brush = mockBrush, + alpha = fakeAlpha + ) + whenever(mockReflectionUtils.getBrushColors(mockBrush)) doReturn listOf(firstStop) + whenever(mockLayoutInfo.getModifierInfo()) doReturn listOf(mockBackgroundModifierInfo) + + // When + val result = testedSemanticsUtils.resolveBackgroundColor(mockSemanticsNode) + + // Then — use the Color.copy() round-trip to derive the expected alpha, + // matching the half-float encoding used by Compose internally. + val resultColor = result?.let { Color(it.toULong()) } + val expectedAlpha = firstStop.copy(alpha = (firstStop.alpha * fakeAlpha).coerceIn(0f, 1f)).alpha + assertThat(resultColor?.alpha).isCloseTo(expectedAlpha, org.assertj.core.data.Offset.offset(0.01f)) + assertThat(resultColor?.green).isCloseTo(firstStop.green, org.assertj.core.data.Offset.offset(0.01f)) + } + + @Test + fun `M apply modifier alpha to solid color W resolveBackgroundInfo { alpha less than 1 }`( + forge: Forge, + @FloatForgery(min = Float.MIN_VALUE, max = 1f) fakeAlpha: Float + ) { + // Given — opaque blue solid color with a modifier-level alpha + val (fakeRect, fakeBounds) = forgeBackgroundBounds(forge) + val opaqueBlue = Color.Blue + val mockBackgroundModifierInfo = backgroundModifierStub( + color = opaqueBlue.value.toLong(), + brush = null, + alpha = fakeAlpha + ) + whenever(mockLayoutInfo.getModifierInfo()) doReturn listOf(mockBackgroundModifierInfo) + whenever(mockSemanticsNode.boundsInRoot) doReturn fakeRect + whenever(mockSemanticsNode.positionInRoot) doReturn Offset(fakeRect.left, fakeRect.top) + + // When + val result = testedSemanticsUtils.resolveBackgroundInfo(mockSemanticsNode) + + // Then — BackgroundInfo.color must carry the blended alpha; bounds must be preserved. + // Use Color.copy() to derive expectedAlpha through the same half-float round-trip. + assertThat(result).hasSize(1) + val resultColor = result.single().color?.let { Color(it.toULong()) } + val expectedAlpha = opaqueBlue.copy(alpha = (opaqueBlue.alpha * fakeAlpha).coerceIn(0f, 1f)).alpha + assertThat(resultColor?.alpha).isCloseTo(expectedAlpha, org.assertj.core.data.Offset.offset(0.01f)) + assertThat(resultColor?.blue).isCloseTo(opaqueBlue.blue, org.assertj.core.data.Offset.offset(0.01f)) + assertThat(result.single().globalBounds).isEqualTo( + GlobalBounds(x = fakeBounds.x, y = fakeBounds.y, width = fakeBounds.width, height = fakeBounds.height) + ) + } + + @Test + fun `M apply modifier alpha to brush first stop W resolveBackgroundInfo { brush with alpha less than 1 }`( + forge: Forge, + @FloatForgery(min = Float.MIN_VALUE, max = 1f) fakeAlpha: Float + ) { + // Given — opaque red as first stop of a gradient brush, plus a modifier-level alpha + val (fakeRect, _) = forgeBackgroundBounds(forge) + val firstStop = Color.Red + val mockBrush = mock() + val mockBackgroundModifierInfo = backgroundModifierStub( + color = COLOR_UNSPECIFIED, + brush = mockBrush, + alpha = fakeAlpha + ) + whenever(mockReflectionUtils.getBrushColors(mockBrush)) doReturn listOf(firstStop, Color.Blue) + whenever(mockLayoutInfo.getModifierInfo()) doReturn listOf(mockBackgroundModifierInfo) + whenever(mockSemanticsNode.boundsInRoot) doReturn fakeRect + whenever(mockSemanticsNode.positionInRoot) doReturn Offset(fakeRect.left, fakeRect.top) + + // When + val result = testedSemanticsUtils.resolveBackgroundInfo(mockSemanticsNode) + + // Then — use Color.copy() round-trip to derive expectedAlpha after half-float encoding. + assertThat(result).hasSize(1) + val resultColor = result.single().color?.let { Color(it.toULong()) } + val expectedAlpha = firstStop.copy(alpha = (firstStop.alpha * fakeAlpha).coerceIn(0f, 1f)).alpha + assertThat(resultColor?.alpha).isCloseTo(expectedAlpha, org.assertj.core.data.Offset.offset(0.01f)) + assertThat(resultColor?.red).isCloseTo(firstStop.red, org.assertj.core.data.Offset.offset(0.01f)) + } + + @Test + fun `M return COLOR_UNSPECIFIED W resolveBackgroundColor { alpha is 0f }`( + @LongForgery(min = 17L) fakeColorValue: Long + ) { + // Given — a solid-color background with alpha = 0f (fully invisible). This exercises + // the color path of resolveBackgroundElementColor (brush = null, color != Unspecified). + // applyAlphaToColor normalises alpha=0f to COLOR_UNSPECIFIED so that AbstractSemanticsNodeMapper + // .convertColor() returns null (no fill), consistent with the existing "no color" contract. + val mockBackgroundModifierInfo = backgroundModifierStub( + color = fakeColorValue, + brush = null, + alpha = 0f + ) + whenever(mockLayoutInfo.getModifierInfo()) doReturn listOf(mockBackgroundModifierInfo) + + // When + val result = testedSemanticsUtils.resolveBackgroundColor(mockSemanticsNode) + + // Then — COLOR_UNSPECIFIED signals "no fill" to downstream convertColor. + assertThat(result).isEqualTo(COLOR_UNSPECIFIED) + } + + @Test + fun `M emit null color W resolveBackgroundInfo { alpha is 0f }`(forge: Forge) { + // Given — alpha=0f makes the element fully invisible. applyAlphaToColor returns + // COLOR_UNSPECIFIED, so BackgroundInfo.color should be COLOR_UNSPECIFIED (convertColor + // will produce null backgroundColor in the wireframe). + val (fakeRect, _) = forgeBackgroundBounds(forge) + val opaqueRed = Color.Red + val mockBackgroundModifierInfo = backgroundModifierStub( + color = opaqueRed.value.toLong(), + brush = null, + alpha = 0f + ) + whenever(mockLayoutInfo.getModifierInfo()) doReturn listOf(mockBackgroundModifierInfo) + whenever(mockSemanticsNode.boundsInRoot) doReturn fakeRect + whenever(mockSemanticsNode.positionInRoot) doReturn Offset(fakeRect.left, fakeRect.top) + + // When + val result = testedSemanticsUtils.resolveBackgroundInfo(mockSemanticsNode) + + // Then — wireframe is emitted (size=1) but color is COLOR_UNSPECIFIED ("no fill"). + assertThat(result).hasSize(1) + assertThat(result.single().color).isEqualTo(COLOR_UNSPECIFIED) + } + + @Test + fun `M return COLOR_UNSPECIFIED W resolveBackgroundColor { brush with alpha is 0f }`() { + // Given — the realistic Modifier.background(brush = ..., alpha = 0f) scenario. + // The color field is Unspecified (brush overload), so we go through the brush path. + // applyAlphaToColor still normalises alpha=0f to COLOR_UNSPECIFIED. + val firstStop = Color.Red + val mockBrush = mock() + val mockBackgroundModifierInfo = backgroundModifierStub( + color = COLOR_UNSPECIFIED, + brush = mockBrush, + alpha = 0f + ) + whenever(mockReflectionUtils.getBrushColors(mockBrush)) doReturn listOf(firstStop) + whenever(mockLayoutInfo.getModifierInfo()) doReturn listOf(mockBackgroundModifierInfo) + + // When + val result = testedSemanticsUtils.resolveBackgroundColor(mockSemanticsNode) + + // Then — fully invisible brush background yields COLOR_UNSPECIFIED (→ null fill in wireframe). + assertThat(result).isEqualTo(COLOR_UNSPECIFIED) + } + + @Test + fun `M emit COLOR_UNSPECIFIED color W resolveBackgroundInfo { brush with alpha is 0f }`(forge: Forge) { + // Given — Modifier.background(brush = linearGradient(...), alpha = 0f). + // Even though the brush has a valid first stop, the modifier-level alpha=0f + // makes the element invisible → BackgroundInfo.color should be COLOR_UNSPECIFIED. + val (fakeRect, _) = forgeBackgroundBounds(forge) + val firstStop = Color.Red + val mockBrush = mock() + val mockBackgroundModifierInfo = backgroundModifierStub( + color = COLOR_UNSPECIFIED, + brush = mockBrush, + alpha = 0f + ) + whenever(mockReflectionUtils.getBrushColors(mockBrush)) doReturn listOf(firstStop) + whenever(mockLayoutInfo.getModifierInfo()) doReturn listOf(mockBackgroundModifierInfo) + whenever(mockSemanticsNode.boundsInRoot) doReturn fakeRect + whenever(mockSemanticsNode.positionInRoot) doReturn Offset(fakeRect.left, fakeRect.top) + + // When + val result = testedSemanticsUtils.resolveBackgroundInfo(mockSemanticsNode) + + // Then — wireframe is still emitted (size=1) but color is COLOR_UNSPECIFIED ("no fill"). + assertThat(result).hasSize(1) + assertThat(result.single().color).isEqualTo(COLOR_UNSPECIFIED) + } + + @Test + fun `M apply compounded alpha W resolveBackgroundColor { source color and modifier both semi-transparent }`( + @FloatForgery(min = Float.MIN_VALUE, max = 1f) sourceAlpha: Float, + @FloatForgery(min = Float.MIN_VALUE, max = 1f) modifierAlpha: Float + ) { + // Given — a source color that is itself semi-transparent (alpha < 1f), combined with a + // modifier-level alpha. The result should multiply both alphas. For example, a 50%-alpha + // source combined with a 50%-alpha modifier yields ~25% visible. + val semiColor = Color.Red.copy(alpha = sourceAlpha) + val mockBackgroundModifierInfo = backgroundModifierStub( + color = semiColor.value.toLong(), + brush = null, + alpha = modifierAlpha + ) + whenever(mockLayoutInfo.getModifierInfo()) doReturn listOf(mockBackgroundModifierInfo) + + // When + val result = testedSemanticsUtils.resolveBackgroundColor(mockSemanticsNode) + + // Then — Use the Color.copy() round-trip for expectedAlpha to match half-float precision. + val resultColor = result?.let { Color(it.toULong()) } + val blended = (semiColor.alpha * modifierAlpha).coerceIn(0f, 1f) + val expectedAlpha = semiColor.copy(alpha = blended).alpha + assertThat(resultColor?.alpha).isCloseTo(expectedAlpha, org.assertj.core.data.Offset.offset(0.01f)) + // RGB channels must survive the multiplication unchanged. + assertThat(resultColor?.red).isCloseTo(semiColor.red, org.assertj.core.data.Offset.offset(0.01f)) + } + + @Test + fun `M return backgroundInfo W resolveBackgroundInfo { brush with padding and shape }`( + forge: Forge, + @IntForgery fakeCornerSizeValue: Int + ) { + // Given — exercises the full modifier chain (clip-shape → padding → brush background) + // through resolveBackgroundInfo, mirroring the colour-path test but for the brush path. + val (fakeRect, fakeBounds) = forgeBackgroundBounds(forge) + val density = Density(fakeDensity) + val mockShape = mock() + val mockPaddingModifier = mock() + val mockShapeModifier = mock() + val mockPaddingModifierInfo = mock() + val mockShapeModifierInfo = mock() + val fakeCornerSize = CornerSize(fakeCornerSizeValue.dp) + val topPadding: Float = forge.aSmallInt().toFloat() + val startPadding: Float = forge.aSmallInt().toFloat() + val endPadding: Float = forge.aSmallInt().toFloat() + val bottomPadding: Float = forge.aSmallInt().toFloat() + val firstStop = Color.Magenta + val mockBrush = mock() + val mockBackgroundModifierInfo = backgroundModifierStub( + color = COLOR_UNSPECIFIED, + brush = mockBrush + ) + whenever(mockShape.topStart) doReturn fakeCornerSize + whenever(mockShapeModifierInfo.modifier) doReturn mockShapeModifier + whenever(mockPaddingModifierInfo.modifier) doReturn mockPaddingModifier + whenever(mockReflectionUtils.getBrushColors(mockBrush)) doReturn listOf(firstStop) + whenever(mockReflectionUtils.getClipShape(mockShapeModifier)) doReturn mockShape + whenever(mockReflectionUtils.getTopPadding(mockPaddingModifier)) doReturn topPadding + whenever(mockReflectionUtils.getStartPadding(mockPaddingModifier)) doReturn startPadding + whenever(mockReflectionUtils.getBottomPadding(mockPaddingModifier)) doReturn bottomPadding + whenever(mockReflectionUtils.getEndPadding(mockPaddingModifier)) doReturn endPadding + whenever(mockReflectionUtils.isPaddingElement(mockPaddingModifier)) doReturn true + whenever(mockReflectionUtils.isGraphicsLayerElement(mockShapeModifier)) doReturn true + whenever(mockLayoutInfo.getModifierInfo()) doReturn listOf( + mockShapeModifierInfo, + mockPaddingModifierInfo, + mockBackgroundModifierInfo + ) + whenever(mockSemanticsNode.boundsInRoot) doReturn fakeRect + whenever(mockSemanticsNode.positionInRoot) doReturn Offset(fakeRect.left, fakeRect.top) + val size = Size( + fakeBounds.width.toFloat() * density.density, + fakeBounds.height.toFloat() * density.density + ) + val expectedCornerRadius = fakeCornerSize.toPx(size, density) / density.density + + // When + val result = testedSemanticsUtils.resolveBackgroundInfo(mockSemanticsNode) + + // Then — first brush stop is used as color; padding shrinks bounds; corner radius is set. + assertThat(result).hasSize(1) + val info = result.single() + assertThat(info.color).isEqualTo(firstStop.value.toLong()) + assertThat(info.cornerRadius).isEqualTo(expectedCornerRadius) + assertThat(info.globalBounds).isEqualTo( + GlobalBounds( + x = fakeBounds.x, + y = fakeBounds.y, + width = fakeBounds.width, + height = fakeBounds.height + ) + ) + } + + // endregion + + private fun forgeBackgroundBounds(forge: Forge): Pair { + val leftPos = forge.aSmallInt() + val rightPos = forge.anInt(leftPos + MIN_VISIBLE_PX, 0x2000) + val topPos = forge.aSmallInt() + val bottomPos = forge.anInt(topPos + MIN_VISIBLE_PX, 0x2000) + val rect = Rect( + left = leftPos.toFloat(), + top = topPos.toFloat(), + right = rightPos.toFloat(), + bottom = bottomPos.toFloat() + ) + return rect to rectToBounds(rect, fakeDensity) + } + + private fun backgroundModifierStub( + color: Long?, + brush: Brush?, + alpha: Float? = null + ): ModifierInfo { + val modifier = mock() + val info = mock() + whenever(info.modifier) doReturn modifier + whenever(mockReflectionUtils.isBackgroundElement(modifier)) doReturn true + whenever(mockReflectionUtils.getColor(modifier)) doReturn color + whenever(mockReflectionUtils.getBrush(modifier)) doReturn brush + whenever(mockReflectionUtils.getAlpha(modifier)) doReturn alpha + return info + } + @Test fun `M return local bitmap W resolveSemanticsPainter { local image }`() { // Given @@ -657,6 +1357,16 @@ class SemanticsUtilsTest { } companion object { + /** + * Minimum rect span (in pixels) that guarantees a non-zero GlobalBounds dimension after + * dividing by the maximum possible fakeDensity (10f). Without this guard, + * `(rightPos - leftPos) / density` can truncate to 0L, causing `isGlobalBoundsValid` to + * return false and `resolveBackgroundInfo` to return an empty list, which causes + * `result.single()` to throw NoSuchElementException. + * 11px / 10f = 1.1 → toLong() = 1 > 0, so 11 is the minimum safe span. + */ + private const val MIN_VISIBLE_PX = 11 + /** * Constant representing an unknown/unsupported TextOverflow Int value. * Used in tests to verify behavior when encountering unknown overflow modes. diff --git a/sample/kotlin/src/main/kotlin/com/datadog/android/sample/compose/BackgroundSample.kt b/sample/kotlin/src/main/kotlin/com/datadog/android/sample/compose/BackgroundSample.kt new file mode 100644 index 0000000000..4731432c0c --- /dev/null +++ b/sample/kotlin/src/main/kotlin/com/datadog/android/sample/compose/BackgroundSample.kt @@ -0,0 +1,342 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +@file:Suppress("LongMethod", "MagicNumber") + +package com.datadog.android.sample.compose + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Card +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Sample screen demonstrating how background colors and brushes are captured in Compose + * session replay. Covers solid colors, gradients, Material2/Material3 containers, + * modifier ordering, corner radii, and nested/stacked backgrounds. + */ +@Composable +internal fun BackgroundSample() { + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + SectionLabel("A — Color literal variants (baseline)") + + CaseLabel("A1 — bare Column · expect RED") + Column( + modifier = Modifier + .fillMaxWidth() + .height(BAND) + .background(Color.Red) + .padding(8.dp) + ) { Text("A1", color = Color.White) } + + CaseLabel("A2 — Column · MaterialTheme.colors.primary") + Column( + modifier = Modifier + .fillMaxWidth() + .height(BAND) + .background(MaterialTheme.colors.primary) + .padding(8.dp) + ) { Text("A2", color = Color.White) } + + CaseLabel("A3 — Column · Color.Red.copy(alpha = 0.5f)") + Column( + modifier = Modifier + .fillMaxWidth() + .height(BAND) + .background(Color.Red.copy(alpha = 0.5f)) + .padding(8.dp) + ) { Text("A3", color = Color.Black) } + + SectionLabel("B — Brush backgrounds") + + CaseLabel("B1 — Modifier.background(Brush.linearGradient(Red→Blue))") + Column( + modifier = Modifier + .fillMaxWidth() + .height(BAND) + .background(Brush.linearGradient(listOf(Color.Red, Color.Blue))) + .padding(8.dp) + ) { Text("B1", color = Color.White) } + + CaseLabel("B2 — Modifier.background(Brush.horizontalGradient(Yellow→Green))") + Column( + modifier = Modifier + .fillMaxWidth() + .height(BAND) + .background(Brush.horizontalGradient(listOf(Color.Yellow, Color.Green))) + .padding(8.dp) + ) { Text("B2", color = Color.Black) } + + SectionLabel("C — Material containers") + + CaseLabel("C1 — Material2 Surface(color = Yellow)") + Surface( + color = Color.Yellow, + modifier = Modifier + .fillMaxWidth() + .height(BAND) + ) { Text("C1", modifier = Modifier.padding(8.dp), color = Color.Black) } + + CaseLabel("C2 — Material2 Card(backgroundColor = Magenta)") + Card( + backgroundColor = Color.Magenta, + modifier = Modifier + .fillMaxWidth() + .height(BAND) + ) { Text("C2", modifier = Modifier.padding(8.dp), color = Color.White) } + + CaseLabel("C3 — Material3 Surface(color = Cyan)") + androidx.compose.material3.Surface( + color = Color.Cyan, + modifier = Modifier + .fillMaxWidth() + .height(BAND) + ) { Text("C3", modifier = Modifier.padding(8.dp), color = Color.Black) } + + CaseLabel("C4 — Material3 Card containing Text") + androidx.compose.material3.Card( + modifier = Modifier + .fillMaxWidth() + .height(BAND) + ) { Text("C4", modifier = Modifier.padding(8.dp), color = Color.Black) } + + SectionLabel("D — Modifier order") + + CaseLabel("D1 — background BEFORE padding · expect CYAN edge-to-edge") + Column( + modifier = Modifier + .fillMaxWidth() + .height(BAND) + .background(Color.Cyan) + .padding(16.dp) + ) { Text("D1", color = Color.Black) } + + CaseLabel("D2 — background AFTER padding · expect YELLOW inset") + Column( + modifier = Modifier + .fillMaxWidth() + .height(BAND) + .padding(16.dp) + .background(Color.Yellow) + ) { Text("D2", color = Color.Black) } + + CaseLabel("D3 — fillMaxWidth → background → no padding · expect RED") + Column( + modifier = Modifier + .fillMaxWidth() + .height(BAND) + .background(Color.Red) + ) { Text("D3", color = Color.White) } + + CaseLabel("D4 — size(80) + background · expect 80dp RED square (left-aligned)") + Row(modifier = Modifier.fillMaxWidth().height(BAND)) { + Column( + modifier = Modifier + .size(80.dp) + .background(Color.Red) + ) { Text("D4", color = Color.White) } + } + + SectionLabel("E — Shape / corner radius") + + CaseLabel("E1 — background(Magenta, RoundedCornerShape(16))") + Column( + modifier = Modifier + .fillMaxWidth() + .height(BAND) + .background(Color.Magenta, RoundedCornerShape(16.dp)) + .padding(8.dp) + ) { Text("E1", color = Color.White) } + + CaseLabel("E2 — clip(RoundedCornerShape(16)) → background(Green)") + Column( + modifier = Modifier + .fillMaxWidth() + .height(BAND) + .clip(RoundedCornerShape(16.dp)) + .background(Color.Green) + .padding(8.dp) + ) { Text("E2", color = Color.Black) } + + SectionLabel("F — Stacked & nested") + + CaseLabel("F1 — background(Red).background(Blue) · expect BLUE on top of RED") + Column( + modifier = Modifier + .fillMaxWidth() + .height(BAND) + .background(Color.Red) + .background(Color.Blue) + .padding(8.dp) + ) { Text("F1", color = Color.White) } + + CaseLabel("F2 — Outer Column(Red) wrapping Inner Column(Green)") + Column( + modifier = Modifier + .fillMaxWidth() + .height(BAND * 2) + .background(Color.Red) + .padding(12.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .height(BAND) + .background(Color.Green) + .padding(8.dp) + ) { Text("F2 inner (parent should be RED)", color = Color.Black) } + } + + CaseLabel("F3 — Surface(Yellow) wrapping Column(Cyan)") + Surface( + color = Color.Yellow, + modifier = Modifier + .fillMaxWidth() + .height(BAND * 2) + ) { + Column( + modifier = Modifier + .padding(12.dp) + .fillMaxWidth() + .height(BAND) + .background(Color.Cyan) + .padding(8.dp) + ) { Text("F3 inner (parent should be YELLOW)", color = Color.Black) } + } + + SectionLabel("G — Edge cases") + + CaseLabel("G1 — Column with no children · expect 80dp RED square") + Row(modifier = Modifier.fillMaxWidth().height(BAND)) { + Column( + modifier = Modifier + .size(80.dp) + .background(Color.Red) + .semantics { contentDescription = "G1 empty column" } + ) { /* no children */ } + } + + CaseLabel("G2 — Column wrapping a Box (no semantic descendants)") + Row(modifier = Modifier.fillMaxWidth().height(BAND)) { + Column( + modifier = Modifier + .size(80.dp) + .background(Color.Red) + .semantics { contentDescription = "G2 column with box" } + ) { + Box(modifier = Modifier.size(20.dp).background(Color.White)) + } + } + + CaseLabel("G3 — Column with .semantics{} (control · should always work)") + Column( + modifier = Modifier + .fillMaxWidth() + .height(BAND) + .background(Color.Green) + .semantics { contentDescription = "G3 control" } + .padding(8.dp) + ) { Text("G3 control", color = Color.Black) } + + SectionLabel("H — Modifier.background alpha parameter (bug-fix verification)") + + // The brush overload of Modifier.background has an explicit alpha parameter: + // Modifier.background(brush, shape, alpha) + // This is what BackgroundElement.alpha captures and what was previously ignored. + + CaseLabel("H1 — background(SolidColor(Red), alpha=0.5f) · expect semi-transparent red") + Column( + modifier = Modifier + .fillMaxWidth() + .height(BAND) + .background(brush = SolidColor(Color.Red), alpha = 0.5f) + .padding(8.dp) + ) { Text("H1 SolidColor alpha=0.5", color = Color.Black) } + + CaseLabel("H2 — background(SolidColor(Blue), alpha=0.25f) · expect 25% blue") + Column( + modifier = Modifier + .fillMaxWidth() + .height(BAND) + .background(brush = SolidColor(Color.Blue), alpha = 0.25f) + .padding(8.dp) + ) { Text("H2 SolidColor alpha=0.25", color = Color.Black) } + + CaseLabel("H3 — background(linearGradient(Red→Blue), alpha=0.5f) · expect semi-transparent gradient") + Column( + modifier = Modifier + .fillMaxWidth() + .height(BAND) + .background( + brush = Brush.linearGradient(listOf(Color.Red, Color.Blue)), + alpha = 0.5f + ) + .padding(8.dp) + ) { Text("H3 gradient alpha=0.5", color = Color.Black) } + + CaseLabel("H4 — background(linearGradient(Yellow→Green), alpha=1f) · expect fully opaque (baseline)") + Column( + modifier = Modifier + .fillMaxWidth() + .height(BAND) + .background( + brush = Brush.linearGradient(listOf(Color.Yellow, Color.Green)), + alpha = 1f + ) + .padding(8.dp) + ) { Text("H4 gradient alpha=1.0 (opaque)", color = Color.Black) } + } +} + +@Composable +private fun SectionLabel(title: String) { + Text( + text = title, + style = MaterialTheme.typography.subtitle2, + color = MaterialTheme.colors.onBackground, + modifier = Modifier.padding(start = 12.dp, top = 16.dp, bottom = 4.dp) + ) +} + +@Composable +private fun CaseLabel(label: String) { + Text( + text = label, + style = MaterialTheme.typography.caption, + color = MaterialTheme.colors.onBackground, + modifier = Modifier.padding(start = 12.dp, top = 8.dp, bottom = 2.dp) + ) +} + +private val BAND = 48.dp + +@Preview(showBackground = true) +@Composable +@Suppress("UnusedPrivateMember") +private fun PreviewBackgroundSample() { + BackgroundSample() +} diff --git a/sample/kotlin/src/main/kotlin/com/datadog/android/sample/compose/SampleSelectionScreen.kt b/sample/kotlin/src/main/kotlin/com/datadog/android/sample/compose/SampleSelectionScreen.kt index cc331555de..2a02677231 100644 --- a/sample/kotlin/src/main/kotlin/com/datadog/android/sample/compose/SampleSelectionScreen.kt +++ b/sample/kotlin/src/main/kotlin/com/datadog/android/sample/compose/SampleSelectionScreen.kt @@ -35,7 +35,8 @@ internal fun SampleSelectionScreen( onFgmClicked: () -> Unit, onTabsClicked: () -> Unit, onInteropViewClicked: () -> Unit, - onNav3Clicked: () -> Unit + onNav3Clicked: () -> Unit, + onBackgroundClicked: () -> Unit ) { Column( modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()), @@ -86,6 +87,10 @@ internal fun SampleSelectionScreen( text = "Navigation 3", onClick = onNav3Clicked ) + StyledButton( + text = "Backgrounds", + onClick = onBackgroundClicked + ) } } @@ -137,6 +142,9 @@ internal fun NavGraphBuilder.selectionNavigation(navController: NavHostControlle }, onNav3Clicked = { navController.navigate(SampleScreen.Navigation3.navigationRoute) + }, + onBackgroundClicked = { + navController.navigate(SampleScreen.Background.navigationRoute) } ) } @@ -173,6 +181,10 @@ internal fun NavGraphBuilder.selectionNavigation(navController: NavHostControlle InteropViewSample() } + composable(SampleScreen.Background.navigationRoute) { + BackgroundSample() + } + activity(SampleScreen.Legacy.navigationRoute) { activityClass = LegacyComposeActivity::class } @@ -197,6 +209,7 @@ internal sealed class SampleScreen( object Legacy : SampleScreen("$COMPOSE_ROOT/legacy") object InteropView : SampleScreen("$COMPOSE_ROOT/interop_view") object Navigation3 : SampleScreen("$COMPOSE_ROOT/nav3") + object Background : SampleScreen("$COMPOSE_ROOT/background") companion object { private const val COMPOSE_ROOT = "compose" @@ -227,6 +240,8 @@ private fun PreviewSampleSelectionScreen() { onInteropViewClicked = { }, onNav3Clicked = { + }, + onBackgroundClicked = { } ) }