Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions detekt_custom_safe_calls.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down Expand Up @@ -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)"
Expand Down Expand Up @@ -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)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Color>? {
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<Color>()
}

fun getShape(modifier: Modifier): Shape? {
return ComposeReflection.ShapeField?.getSafe(modifier) as? Shape
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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? {
Expand Down Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Brush>()

// When
val result = testedReflectionUtils.getBrushColors(mockBrush)

// Then
assertThat(result).isNull()
}

// endregion
}
Loading
Loading