Skip to content

Commit d2a6dd0

Browse files
committed
RUM-8112: Fix gradients not showing in compose
1 parent d8e09d6 commit d2a6dd0

10 files changed

Lines changed: 1574 additions & 125 deletions

File tree

detekt_custom_safe_calls.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,8 @@ datadog:
262262
- "androidx.compose.runtime.tooling.CompositionGroup.parameters(com.datadog.android.sessionreplay.compose.internal.data.ComposeContext)"
263263
- "androidx.compose.runtime.tooling.CompositionGroup.stableId()"
264264
- "androidx.compose.ui.graphics.Color(kotlin.Long)"
265+
- "androidx.compose.ui.graphics.Color.constructor(kotlin.ULong)"
266+
- "androidx.compose.ui.graphics.Color.copy(kotlin.Float, kotlin.Float, kotlin.Float, kotlin.Float)"
265267
- "androidx.compose.ui.graphics.Color.toArgb()"
266268
- "androidx.compose.ui.graphics.Matrix.constructor(kotlin.FloatArray)"
267269
- "androidx.compose.ui.graphics.Matrix.scale(kotlin.Float, kotlin.Float, kotlin.Float)"
@@ -818,6 +820,7 @@ datadog:
818820
- "kotlin.collections.List.elementAtOrNull(kotlin.Int)"
819821
- "kotlin.collections.List.filter(kotlin.Function1)"
820822
- "kotlin.collections.List.filterIndexed(kotlin.Function2)"
823+
- "kotlin.collections.List.filterIsInstance()"
821824
- "kotlin.collections.List.filterNot(kotlin.Function1)"
822825
- "kotlin.collections.List.filterNotNull(kotlin.Function1)"
823826
- "kotlin.collections.List.findFirstForType(java.lang.Class)"
@@ -1054,6 +1057,7 @@ datadog:
10541057
- "kotlin.collections.emptySet()"
10551058
- "kotlin.collections.listOf()"
10561059
- "kotlin.collections.listOf(android.view.Window)"
1060+
- "kotlin.collections.listOf(androidx.compose.ui.graphics.Color)"
10571061
- "kotlin.collections.listOf(com.datadog.android.api.InternalLogger.Target)"
10581062
- "kotlin.collections.listOf(com.datadog.android.rum.internal.vitals.FPSVitalListener)"
10591063
- "kotlin.collections.listOf(com.datadog.android.flags.model.BatchedFlagEvaluations.FlagEvaluation)"

features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/AbstractSemanticsNodeMapper.kt

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import androidx.compose.ui.text.style.TextAlign
1414
import com.datadog.android.sessionreplay.compose.internal.data.UiContext
1515
import com.datadog.android.sessionreplay.compose.internal.utils.BackgroundInfo
1616
import com.datadog.android.sessionreplay.compose.internal.utils.SemanticsUtils
17+
import com.datadog.android.sessionreplay.compose.internal.utils.SemanticsUtils.Companion.COLOR_UNSPECIFIED
1718
import com.datadog.android.sessionreplay.model.MobileSegment
1819
import com.datadog.android.sessionreplay.utils.ColorStringFormatter
1920
import com.datadog.android.sessionreplay.utils.GlobalBounds
@@ -108,7 +109,7 @@ internal abstract class AbstractSemanticsNodeMapper(
108109
}
109110

110111
protected fun convertColor(color: Long): String? {
111-
return if (color == UNSPECIFIED_COLOR) {
112+
return if (color == COLOR_UNSPECIFIED) {
112113
null
113114
} else {
114115
val c = Color(color shr COMPOSE_COLOR_SHIFT)
@@ -120,8 +121,6 @@ internal abstract class AbstractSemanticsNodeMapper(
120121
}
121122

122123
companion object {
123-
/** As defined in Compose's ColorSpaces. */
124-
private const val UNSPECIFIED_COLOR = 16L
125124
private const val COMPOSE_COLOR_SHIFT = 32
126125
private const val MAX_ALPHA = 255
127126
private const val SEMANTICS_ID_BIT_SHIFT = 32

features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/reflection/ComposeReflection.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,16 @@ internal object ComposeReflection {
5050
val ColorField = BackgroundElementClass?.getDeclaredFieldSafe("color")
5151
val ShapeField = BackgroundElementClass?.getDeclaredFieldSafe("shape")
5252

53+
val BrushField = BackgroundElementClass?.getDeclaredFieldSafe("brush", isCritical = false)
54+
val AlphaField = BackgroundElementClass?.getDeclaredFieldSafe("alpha", isCritical = false)
55+
56+
val LinearGradientClass = getClassSafe("androidx.compose.ui.graphics.LinearGradient", isCritical = false)
57+
val LinearGradientColorsField = LinearGradientClass?.getDeclaredFieldSafe("colors", isCritical = false)
58+
val RadialGradientClass = getClassSafe("androidx.compose.ui.graphics.RadialGradient", isCritical = false)
59+
val RadialGradientColorsField = RadialGradientClass?.getDeclaredFieldSafe("colors", isCritical = false)
60+
val SweepGradientClass = getClassSafe("androidx.compose.ui.graphics.SweepGradient", isCritical = false)
61+
val SweepGradientColorsField = SweepGradientClass?.getDeclaredFieldSafe("colors", isCritical = false)
62+
5363
val DrawBehindElementClass = getClassSafe("androidx.compose.ui.draw.DrawBehindElement")
5464
val CheckboxKtClass = getClassSafe("androidx.compose.material.CheckboxKt\$CheckboxImpl\$1\$1", false)
5565
val RadioButtonKtClass = getClassSafe("androidx.compose.material.RadioButtonKt\$RadioButton\$2\$1", false)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
/*
2+
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
3+
* This product includes software developed at Datadog (https://www.datadoghq.com/).
4+
* Copyright 2016-Present Datadog, Inc.
5+
*/
6+
7+
package com.datadog.android.sessionreplay.compose.internal.utils
8+
9+
import androidx.compose.foundation.shape.RoundedCornerShape
10+
import androidx.compose.ui.Modifier
11+
import androidx.compose.ui.geometry.Size
12+
import androidx.compose.ui.graphics.Color
13+
import androidx.compose.ui.graphics.Shape
14+
import androidx.compose.ui.semantics.SemanticsNode
15+
import androidx.compose.ui.unit.Density
16+
import com.datadog.android.api.InternalLogger
17+
import com.datadog.android.sessionreplay.compose.internal.utils.SemanticsUtils.Companion.COLOR_UNSPECIFIED
18+
import com.datadog.android.sessionreplay.utils.GlobalBounds
19+
20+
internal class BackgroundResolver(
21+
private val reflectionUtils: ReflectionUtils,
22+
private val innerBoundsOf: (SemanticsNode) -> GlobalBounds,
23+
private val internalLogger: InternalLogger = InternalLogger.UNBOUND
24+
) {
25+
internal fun resolveBackgroundInfo(semanticsNode: SemanticsNode): List<BackgroundInfo> {
26+
val backgroundInfoList = mutableListOf<BackgroundInfo>()
27+
// CurrentBackgroundInfo is to store bounds, color and shape information in sequence of modifiers.
28+
var currentBackgroundInfo = BackgroundInfo()
29+
var currentBounds: GlobalBounds = resolveOuterBounds(semanticsNode)
30+
// If the currentBounds is already invalid, return with the existing wireframes
31+
if (currentBounds.width <= 0 || currentBounds.height <= 0) {
32+
return backgroundInfoList
33+
}
34+
val density = semanticsNode.layoutInfo.density
35+
// Iterate all the modifiers in user calling sequence, when meet:
36+
// -> clip(): calculate the corner radius and update `currentBackgroundInfo`
37+
// -> padding(): shrink the bounds from the previous bounds and update `currentBackgroundInfo`
38+
// -> background(): retrieve the color and use `currentBackgroundInfo` to generate wireframes,
39+
// then reset `currentBackgroundInfo`.
40+
semanticsNode.layoutInfo.getModifierInfo().forEach { modifierInfo ->
41+
if (reflectionUtils.isBackgroundElement(modifierInfo.modifier)) {
42+
val color = resolveBackgroundElementColor(modifierInfo.modifier)
43+
currentBackgroundInfo = currentBackgroundInfo.copy(globalBounds = currentBounds, color = color)
44+
backgroundInfoList.add(currentBackgroundInfo)
45+
currentBackgroundInfo = BackgroundInfo()
46+
} else if (reflectionUtils.isPaddingElement(modifierInfo.modifier)) {
47+
currentBounds = shrinkBounds(modifierInfo.modifier, currentBounds)
48+
currentBackgroundInfo = currentBackgroundInfo.copy(globalBounds = currentBounds)
49+
} else if (reflectionUtils.isGraphicsLayerElement(modifierInfo.modifier)) {
50+
val cornerRadius = reflectionUtils.getClipShape(modifierInfo.modifier)
51+
?.let { resolveCornerRadius(it, currentBounds, density) } ?: 0f
52+
currentBackgroundInfo = currentBackgroundInfo.copy(cornerRadius = cornerRadius)
53+
}
54+
}
55+
return backgroundInfoList
56+
}
57+
58+
internal fun resolveBackgroundColor(semanticsNode: SemanticsNode): Long? {
59+
val topmostBackground =
60+
semanticsNode.layoutInfo.getModifierInfo().lastOrNull { modifierInfo ->
61+
reflectionUtils.isBackgroundElement(modifierInfo.modifier)
62+
}
63+
return topmostBackground?.let { resolveBackgroundElementColor(it.modifier) }
64+
}
65+
66+
internal fun resolveBackgroundShape(semanticsNode: SemanticsNode): Shape? {
67+
val backgroundModifier = semanticsNode.layoutInfo.getModifierInfo().lastOrNull {
68+
reflectionUtils.isBackgroundElement(it.modifier)
69+
}?.modifier
70+
return backgroundModifier?.let { reflectionUtils.getShape(it) }
71+
}
72+
73+
internal fun resolveCornerRadius(shape: Shape, currentBounds: GlobalBounds, density: Density): Float {
74+
val size = Size(
75+
currentBounds.width.toFloat() * density.density,
76+
currentBounds.height.toFloat() * density.density
77+
)
78+
// We only have a single value for corner radius, so we default to using the
79+
// top left (i.e.: topStart) corner's value and apply it to all corners
80+
return if (shape is RoundedCornerShape) {
81+
shape.topStart.toPx(size, density) / density.density
82+
} else {
83+
0f
84+
}
85+
}
86+
87+
private fun resolveOuterBounds(semanticsNode: SemanticsNode): GlobalBounds {
88+
var currentBounds = innerBoundsOf(semanticsNode)
89+
semanticsNode.layoutInfo.getModifierInfo().filter {
90+
reflectionUtils.isPaddingElement(it.modifier)
91+
}.forEach {
92+
val top = reflectionUtils.getTopPadding(it.modifier)
93+
val start = reflectionUtils.getStartPadding(it.modifier)
94+
val end = reflectionUtils.getEndPadding(it.modifier)
95+
val bottom = reflectionUtils.getBottomPadding(it.modifier)
96+
currentBounds = GlobalBounds(
97+
x = currentBounds.x - start.toLong(),
98+
y = currentBounds.y - top.toLong(),
99+
width = currentBounds.width + (end + start).toLong(),
100+
height = currentBounds.height + (bottom + top).toLong()
101+
)
102+
}
103+
return currentBounds
104+
}
105+
106+
private fun shrinkBounds(modifier: Modifier, currentBounds: GlobalBounds): GlobalBounds {
107+
val top = reflectionUtils.getTopPadding(modifier)
108+
val start = reflectionUtils.getStartPadding(modifier)
109+
val end = reflectionUtils.getEndPadding(modifier)
110+
val bottom = reflectionUtils.getBottomPadding(modifier)
111+
return GlobalBounds(
112+
x = currentBounds.x + start.toLong(),
113+
y = currentBounds.y + top.toLong(),
114+
width = currentBounds.width - (end + start).toLong(),
115+
height = currentBounds.height - (bottom + top).toLong()
116+
)
117+
}
118+
119+
private fun resolveBackgroundElementColor(modifier: Modifier): Long? {
120+
val rawColor = reflectionUtils.getColor(modifier)
121+
val alpha = reflectionUtils.getAlpha(modifier)
122+
if (rawColor != null && rawColor != COLOR_UNSPECIFIED) {
123+
return applyAlphaToColor(rawColor, alpha)
124+
}
125+
return resolveBrushColor(modifier, alpha)
126+
}
127+
128+
private fun resolveBrushColor(modifier: Modifier, alpha: Float?): Long? {
129+
val brush = reflectionUtils.getBrush(modifier) ?: return null
130+
val colors = reflectionUtils.getBrushColors(brush)
131+
return when {
132+
colors == null -> {
133+
logBrushIssue(brush, InternalLogger.Level.INFO) {
134+
"Unsupported Brush type for Compose background: ${brush.javaClass.name}"
135+
}
136+
null
137+
}
138+
colors.isEmpty() -> {
139+
logBrushIssue(brush, InternalLogger.Level.WARN) {
140+
"Known Brush type but failed to read color list via reflection: ${brush.javaClass.name}"
141+
}
142+
null
143+
}
144+
else -> {
145+
@Suppress("UnsafeThirdPartyFunctionCall")
146+
applyAlphaToColor(colors.first().value.toLong(), alpha)
147+
}
148+
}
149+
}
150+
151+
// alpha <= 0f is treated as invisible: returns COLOR_UNSPECIFIED so convertColor produces null fill
152+
private fun applyAlphaToColor(colorValue: Long, alpha: Float?): Long {
153+
return when {
154+
alpha == null || alpha >= 1f -> colorValue
155+
alpha <= 0f -> COLOR_UNSPECIFIED
156+
else -> {
157+
val color = Color(colorValue.toULong())
158+
color.copy(alpha = color.alpha * alpha).value.toLong()
159+
}
160+
}
161+
}
162+
163+
private fun logBrushIssue(brush: Any, level: InternalLogger.Level, messageBuilder: () -> String) {
164+
internalLogger.log(
165+
level = level,
166+
targets = listOf(InternalLogger.Target.MAINTAINER, InternalLogger.Target.TELEMETRY),
167+
messageBuilder = messageBuilder,
168+
onlyOnce = true,
169+
additionalProperties = mapOf("brush.type" to brush.javaClass.name, COMPONENT_KEY to COMPONENT_NAME)
170+
)
171+
}
172+
173+
private companion object {
174+
private const val COMPONENT_NAME = "BackgroundResolver"
175+
private const val COMPONENT_KEY = "component"
176+
}
177+
}

features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/ReflectionUtils.kt

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@ import androidx.compose.animation.core.AnimationState
1313
import androidx.compose.runtime.Composition
1414
import androidx.compose.ui.Alignment
1515
import androidx.compose.ui.Modifier
16+
import androidx.compose.ui.graphics.Brush
1617
import androidx.compose.ui.graphics.Color
1718
import androidx.compose.ui.graphics.ColorProducer
1819
import androidx.compose.ui.graphics.Path
1920
import androidx.compose.ui.graphics.Shape
21+
import androidx.compose.ui.graphics.SolidColor
2022
import androidx.compose.ui.graphics.painter.BitmapPainter
2123
import androidx.compose.ui.graphics.painter.Painter
2224
import androidx.compose.ui.graphics.vector.VectorPainter
@@ -133,6 +135,28 @@ internal class ReflectionUtils {
133135
return ComposeReflection.ColorField?.getSafe(modifier) as? Long
134136
}
135137

138+
fun getBrush(modifier: Modifier): Brush? {
139+
return ComposeReflection.BrushField?.getSafe(modifier) as? Brush
140+
}
141+
142+
fun getAlpha(modifier: Modifier): Float? {
143+
return ComposeReflection.AlphaField?.getSafe(modifier) as? Float
144+
}
145+
146+
fun getBrushColors(brush: Brush): List<Color>? = when {
147+
brush is SolidColor -> listOf(brush.value)
148+
ComposeReflection.LinearGradientClass?.isInstance(brush) == true ->
149+
extractColors(ComposeReflection.LinearGradientColorsField?.getSafe(brush))
150+
ComposeReflection.RadialGradientClass?.isInstance(brush) == true ->
151+
extractColors(ComposeReflection.RadialGradientColorsField?.getSafe(brush))
152+
ComposeReflection.SweepGradientClass?.isInstance(brush) == true ->
153+
extractColors(ComposeReflection.SweepGradientColorsField?.getSafe(brush))
154+
else -> null
155+
}
156+
157+
private fun extractColors(fieldValue: Any?): List<Color> =
158+
(fieldValue as? List<*>)?.filterIsInstance<Color>() ?: emptyList()
159+
136160
fun getShape(modifier: Modifier): Shape? {
137161
return ComposeReflection.ShapeField?.getSafe(modifier) as? Shape
138162
}

0 commit comments

Comments
 (0)