Skip to content

Commit 9d12c72

Browse files
committed
RUM-6195: Add support for Compose Checkbox
1 parent a67406e commit 9d12c72

File tree

15 files changed

+1172
-28
lines changed

15 files changed

+1172
-28
lines changed

detekt_custom.yml

+9
Original file line numberDiff line numberDiff line change
@@ -129,8 +129,10 @@ datadog:
129129
- "android.database.sqlite.SQLiteDatabase.setTransactionSuccessful():java.lang.IllegalStateException"
130130
- "android.graphics.Bitmap.compress(android.graphics.Bitmap.CompressFormat, kotlin.Int, java.io.OutputStream):java.lang.NullPointerException,java.lang.IllegalArgumentException"
131131
- "android.graphics.Bitmap.copy(android.graphics.Bitmap.Config, kotlin.Boolean):java.lang.IllegalArgumentException"
132+
- "android.graphics.Bitmap.createBitmap(kotlin.Int, kotlin.Int, android.graphics.Bitmap.Config):java.lang.IllegalArgumentException"
132133
- "android.graphics.Bitmap.createBitmap(android.util.DisplayMetrics?, kotlin.Int, kotlin.Int, android.graphics.Bitmap.Config):java.lang.IllegalArgumentException"
133134
- "android.graphics.Bitmap.createScaledBitmap(android.graphics.Bitmap, kotlin.Int, kotlin.Int, kotlin.Boolean):java.lang.IllegalArgumentException"
135+
- "android.graphics.Color.parseColor(kotlin.String?):java.lang.IllegalArgumentException"
134136
- "android.graphics.Canvas.constructor(android.graphics.Bitmap):java.lang.IllegalStateException"
135137
- "android.graphics.drawable.LayerDrawable.getDrawable(kotlin.Int):java.lang.IndexOutOfBoundsException"
136138
- "android.net.ConnectivityManager.registerDefaultNetworkCallback(android.net.ConnectivityManager.NetworkCallback):java.lang.IllegalArgumentException,java.lang.SecurityException"
@@ -468,6 +470,7 @@ datadog:
468470
# endregion
469471
# region Android Graphics
470472
- "android.graphics.Bitmap.recycle()"
473+
- "android.graphics.Canvas.drawColor(kotlin.Int)"
471474
- "android.graphics.Canvas.drawColor(kotlin.Int, android.graphics.PorterDuff.Mode)"
472475
- "android.graphics.Color.argb(kotlin.Int, kotlin.Int, kotlin.Int, kotlin.Int)"
473476
- "android.graphics.Color.blue(kotlin.Int)"
@@ -484,6 +487,7 @@ datadog:
484487
- "android.graphics.drawable.Drawable.setTintList(android.content.res.ColorStateList?)"
485488
- "android.graphics.drawable.RippleDrawable.findIndexByLayerId(kotlin.Int)"
486489
- "android.graphics.drawable.DrawableContainer.DrawableContainerState.getChild(kotlin.Int)"
490+
- "android.graphics.Paint.constructor()"
487491
- "android.graphics.Point.constructor()"
488492
- "android.graphics.Point.constructor(kotlin.Int, kotlin.Int)"
489493
- "android.graphics.Rect.centerX()"
@@ -510,6 +514,11 @@ datadog:
510514
- "androidx.compose.runtime.tooling.CompositionGroup.stableId()"
511515
- "androidx.compose.ui.graphics.Color(kotlin.Long)"
512516
- "androidx.compose.ui.graphics.Color.toArgb()"
517+
- "androidx.compose.ui.graphics.Matrix.constructor(kotlin.FloatArray)"
518+
- "androidx.compose.ui.graphics.Matrix.scale(kotlin.Float, kotlin.Float, kotlin.Float)"
519+
- "androidx.compose.ui.graphics.Matrix.translate(kotlin.Float, kotlin.Float, kotlin.Float)"
520+
- "androidx.compose.ui.graphics.Path.getBounds()"
521+
- "androidx.compose.ui.graphics.Path.transform(androidx.compose.ui.graphics.Matrix)"
513522
- "androidx.compose.ui.layout.LayoutCoordinates.positionInWindow()"
514523
- "androidx.compose.ui.layout.LayoutInfo.getModifierInfo()"
515524
- "androidx.compose.ui.unit.Density(kotlin.Float, kotlin.Float)"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
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 CHECKBOX_SIZE16-Present Datadog, Inc.
5+
*/
6+
7+
package com.datadog.android.sessionreplay.compose.internal.mappers.semantics
8+
9+
import androidx.compose.ui.semantics.SemanticsNode
10+
import androidx.compose.ui.semantics.SemanticsProperties
11+
import androidx.compose.ui.semantics.getOrNull
12+
import androidx.compose.ui.state.ToggleableState
13+
import com.datadog.android.sessionreplay.ImagePrivacy
14+
import com.datadog.android.sessionreplay.compose.internal.data.SemanticsWireframe
15+
import com.datadog.android.sessionreplay.compose.internal.data.UiContext
16+
import com.datadog.android.sessionreplay.compose.internal.utils.PathUtils
17+
import com.datadog.android.sessionreplay.compose.internal.utils.SemanticsUtils
18+
import com.datadog.android.sessionreplay.model.MobileSegment
19+
import com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback
20+
import com.datadog.android.sessionreplay.utils.ColorStringFormatter
21+
import com.datadog.android.sessionreplay.utils.GlobalBounds
22+
23+
internal class CheckboxSemanticsNodeMapper(
24+
colorStringFormatter: ColorStringFormatter,
25+
private val semanticsUtils: SemanticsUtils = SemanticsUtils(),
26+
private val pathUtils: PathUtils = PathUtils()
27+
) : AbstractSemanticsNodeMapper(colorStringFormatter, semanticsUtils) {
28+
29+
override fun map(
30+
semanticsNode: SemanticsNode,
31+
parentContext: UiContext,
32+
asyncJobStatusCallback: AsyncJobStatusCallback
33+
): SemanticsWireframe {
34+
val globalBounds = resolveBounds(semanticsNode)
35+
36+
val wireframes = if (isCheckboxChecked(semanticsNode)) {
37+
createCheckedWireframes(
38+
parentContext = parentContext,
39+
asyncJobStatusCallback = asyncJobStatusCallback,
40+
semanticsNode = semanticsNode,
41+
globalBounds = globalBounds
42+
)
43+
} else {
44+
createUncheckedWireframes(
45+
semanticsNode = semanticsNode,
46+
globalBounds = globalBounds,
47+
backgroundColor = DEFAULT_COLOR_WHITE
48+
)
49+
}
50+
51+
return SemanticsWireframe(
52+
uiContext = null,
53+
wireframes = wireframes
54+
)
55+
}
56+
57+
private fun createCheckedWireframes(
58+
parentContext: UiContext,
59+
asyncJobStatusCallback: AsyncJobStatusCallback,
60+
semanticsNode: SemanticsNode,
61+
globalBounds: GlobalBounds
62+
): List<MobileSegment.Wireframe> {
63+
val rawFillColor = semanticsUtils.resolveCheckboxFillColor(semanticsNode)
64+
val rawCheckmarkColor = semanticsUtils.resolveCheckmarkColor(semanticsNode)
65+
66+
val fillColorRgba = rawFillColor?.let {
67+
convertColor(it)
68+
} ?: DEFAULT_COLOR_WHITE
69+
val checkmarkColorRgba = rawCheckmarkColor?.let {
70+
convertColor(it)
71+
} ?: getFallbackCheckmarkColor(DEFAULT_COLOR_WHITE)
72+
73+
val parsedFillColor = pathUtils.parseColorSafe(fillColorRgba)
74+
val parsedCheckmarkColor = pathUtils.parseColorSafe(checkmarkColorRgba)
75+
76+
if (parsedFillColor != null && parsedCheckmarkColor != null) {
77+
val checkMarkBitmap = semanticsUtils
78+
.resolveCheckPath(semanticsNode)?.let { checkPath ->
79+
pathUtils.convertPathToBitmap(
80+
checkPath = checkPath,
81+
fillColor = parsedFillColor,
82+
checkmarkColor = parsedCheckmarkColor
83+
)
84+
}
85+
86+
if (checkMarkBitmap != null) {
87+
parentContext.imageWireframeHelper.createImageWireframeByBitmap(
88+
id = resolveId(semanticsNode, 0),
89+
globalBounds = globalBounds,
90+
bitmap = checkMarkBitmap,
91+
density = parentContext.density,
92+
isContextualImage = false,
93+
imagePrivacy = ImagePrivacy.MASK_NONE,
94+
asyncJobStatusCallback = asyncJobStatusCallback,
95+
clipping = null,
96+
shapeStyle = MobileSegment.ShapeStyle(
97+
backgroundColor = fillColorRgba,
98+
opacity = 1f,
99+
cornerRadius = CHECKBOX_CORNER_RADIUS
100+
),
101+
border = MobileSegment.ShapeBorder(
102+
color = fillColorRgba,
103+
width = BOX_BORDER_WIDTH_DP
104+
)
105+
)?.let { imageWireframe ->
106+
return listOf(imageWireframe)
107+
}
108+
}
109+
}
110+
111+
// if we failed to create a wireframe from the path
112+
return createManualCheckedWireframe(semanticsNode, globalBounds, fillColorRgba)
113+
}
114+
115+
private fun createManualCheckedWireframe(
116+
semanticsNode: SemanticsNode,
117+
globalBounds: GlobalBounds,
118+
backgroundColor: String
119+
): List<MobileSegment.Wireframe> {
120+
val strokeColor = getFallbackCheckmarkColor(backgroundColor)
121+
122+
val background: MobileSegment.Wireframe = createUncheckedWireframes(
123+
semanticsNode = semanticsNode,
124+
globalBounds = globalBounds,
125+
backgroundColor = backgroundColor
126+
)[0]
127+
128+
val checkmarkWidth = globalBounds.width * CHECKMARK_SIZE_FACTOR
129+
val checkmarkHeight = globalBounds.height * CHECKMARK_SIZE_FACTOR
130+
val xPos = globalBounds.x + ((globalBounds.width / 2) - (checkmarkWidth / 2))
131+
val yPos = globalBounds.y + ((globalBounds.height / 2) - (checkmarkHeight / 2))
132+
val foreground: MobileSegment.Wireframe = MobileSegment.Wireframe.ShapeWireframe(
133+
id = resolveId(semanticsNode, 1),
134+
x = xPos.toLong(),
135+
y = yPos.toLong(),
136+
width = checkmarkWidth.toLong(),
137+
height = checkmarkHeight.toLong(),
138+
shapeStyle = MobileSegment.ShapeStyle(
139+
backgroundColor = strokeColor,
140+
opacity = 1f,
141+
cornerRadius = CHECKBOX_CORNER_RADIUS
142+
),
143+
border = MobileSegment.ShapeBorder(
144+
color = DEFAULT_COLOR_BLACK,
145+
width = BOX_BORDER_WIDTH_DP
146+
)
147+
)
148+
return listOf(background, foreground)
149+
}
150+
151+
private fun createUncheckedWireframes(
152+
semanticsNode: SemanticsNode,
153+
globalBounds: GlobalBounds,
154+
backgroundColor: String
155+
): List<MobileSegment.Wireframe> {
156+
val borderColor =
157+
semanticsUtils.resolveBorderColor(semanticsNode)
158+
?.let { rawColor ->
159+
convertColor(rawColor)
160+
} ?: DEFAULT_COLOR_BLACK
161+
162+
return listOf(
163+
MobileSegment.Wireframe.ShapeWireframe(
164+
id = resolveId(semanticsNode, 0),
165+
x = globalBounds.x,
166+
y = globalBounds.y,
167+
width = globalBounds.width,
168+
height = globalBounds.height,
169+
shapeStyle = MobileSegment.ShapeStyle(
170+
backgroundColor = backgroundColor,
171+
opacity = 1f,
172+
cornerRadius = CHECKBOX_CORNER_RADIUS
173+
),
174+
border = MobileSegment.ShapeBorder(
175+
color = borderColor,
176+
width = BOX_BORDER_WIDTH_DP
177+
)
178+
)
179+
)
180+
}
181+
182+
private fun isCheckboxChecked(semanticsNode: SemanticsNode): Boolean =
183+
semanticsNode.config.getOrNull(SemanticsProperties.ToggleableState) == ToggleableState.On
184+
185+
private fun getFallbackCheckmarkColor(backgroundColor: String?) =
186+
if (backgroundColor == DEFAULT_COLOR_WHITE) {
187+
DEFAULT_COLOR_BLACK
188+
} else {
189+
DEFAULT_COLOR_WHITE
190+
}
191+
192+
internal companion object {
193+
internal const val DEFAULT_COLOR_BLACK = "#000000FF"
194+
internal const val DEFAULT_COLOR_WHITE = "#FFFFFFFF"
195+
196+
// when we create the checkmark manually, what % of the checkbox size should it be
197+
internal const val CHECKMARK_SIZE_FACTOR = 0.5
198+
199+
// values from Compose Checkbox sourcecode
200+
internal const val BOX_BORDER_WIDTH_DP = 2L
201+
internal const val STROKE_WIDTH_DP = 2f
202+
internal const val CHECKBOX_SIZE_DP = 20
203+
internal const val CHECKBOX_CORNER_RADIUS = 2f
204+
}
205+
}

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

+2-1
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ internal class RootSemanticsNodeMapper(
2727
Role.RadioButton to RadioButtonSemanticsNodeMapper(colorStringFormatter, semanticsUtils),
2828
Role.Tab to TabSemanticsNodeMapper(colorStringFormatter, semanticsUtils),
2929
Role.Button to ButtonSemanticsNodeMapper(colorStringFormatter, semanticsUtils),
30-
Role.Image to ImageSemanticsNodeMapper(colorStringFormatter)
30+
Role.Image to ImageSemanticsNodeMapper(colorStringFormatter),
31+
Role.Checkbox to CheckboxSemanticsNodeMapper(colorStringFormatter, semanticsUtils)
3132
),
3233
// Text doesn't have a role in semantics, so it should be a fallback mapper.
3334
private val textSemanticsNodeMapper: TextSemanticsNodeMapper = TextSemanticsNodeMapper(

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

+10
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,16 @@ internal object ComposeReflection {
3737
val ColorField = BackgroundElementClass?.getDeclaredFieldSafe("color")
3838
val ShapeField = BackgroundElementClass?.getDeclaredFieldSafe("shape")
3939

40+
val CheckDrawingCacheClass = getClassSafe("androidx.compose.material.CheckDrawingCache")
41+
val CheckboxKtClass = getClassSafe("androidx.compose.material.CheckboxKt\$CheckboxImpl\$1\$1")
42+
val DrawBehindElementClass = getClassSafe("androidx.compose.ui.draw.DrawBehindElement")
43+
val BorderColorField = CheckboxKtClass?.getDeclaredFieldSafe("\$borderColor\$delegate")
44+
val BoxColorField = CheckboxKtClass?.getDeclaredFieldSafe("\$boxColor\$delegate")
45+
val CheckCacheField = CheckboxKtClass?.getDeclaredFieldSafe("\$checkCache")
46+
val CheckColorField = CheckboxKtClass?.getDeclaredFieldSafe("\$checkColor\$delegate")
47+
val CheckPathField = CheckDrawingCacheClass?.getDeclaredFieldSafe("checkPath")
48+
val OnDrawField = DrawBehindElementClass?.getDeclaredFieldSafe("onDraw")
49+
4050
val PaddingElementClass = getClassSafe("androidx.compose.foundation.layout.PaddingElement")
4151
val StartField = PaddingElementClass?.getDeclaredFieldSafe("start")
4252
val EndField = PaddingElementClass?.getDeclaredFieldSafe("end")

0 commit comments

Comments
 (0)