Skip to content

Commit 33e6311

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

File tree

15 files changed

+955
-28
lines changed

15 files changed

+955
-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,252 @@
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 android.graphics.Bitmap
10+
import android.graphics.Paint
11+
import androidx.compose.ui.graphics.Matrix
12+
import androidx.compose.ui.graphics.Path
13+
import androidx.compose.ui.semantics.SemanticsNode
14+
import androidx.compose.ui.semantics.SemanticsProperties
15+
import androidx.compose.ui.semantics.getOrNull
16+
import androidx.compose.ui.state.ToggleableState
17+
import com.datadog.android.api.InternalLogger
18+
import com.datadog.android.sessionreplay.ImagePrivacy
19+
import com.datadog.android.sessionreplay.compose.internal.data.SemanticsWireframe
20+
import com.datadog.android.sessionreplay.compose.internal.data.UiContext
21+
import com.datadog.android.sessionreplay.compose.internal.utils.PathUtils
22+
import com.datadog.android.sessionreplay.compose.internal.utils.SemanticsUtils
23+
import com.datadog.android.sessionreplay.model.MobileSegment
24+
import com.datadog.android.sessionreplay.recorder.wrappers.BitmapWrapper
25+
import com.datadog.android.sessionreplay.recorder.wrappers.CanvasWrapper
26+
import com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback
27+
import com.datadog.android.sessionreplay.utils.ColorStringFormatter
28+
import com.datadog.android.sessionreplay.utils.GlobalBounds
29+
30+
internal class CheckboxSemanticsNodeMapper(
31+
colorStringFormatter: ColorStringFormatter,
32+
private val semanticsUtils: SemanticsUtils = SemanticsUtils(),
33+
private val pathUtils: PathUtils = PathUtils(),
34+
private val bitmapWrapper: BitmapWrapper = BitmapWrapper(),
35+
private val canvasWrapper: CanvasWrapper = CanvasWrapper(InternalLogger.UNBOUND)
36+
) : AbstractSemanticsNodeMapper(colorStringFormatter, semanticsUtils) {
37+
38+
override fun map(
39+
semanticsNode: SemanticsNode,
40+
parentContext: UiContext,
41+
asyncJobStatusCallback: AsyncJobStatusCallback
42+
): SemanticsWireframe {
43+
val globalBounds = resolveBounds(semanticsNode)
44+
45+
val wireframes = if (isCheckboxChecked(semanticsNode)) {
46+
resolveCheckedCheckbox(parentContext, asyncJobStatusCallback, semanticsNode, globalBounds)
47+
} else {
48+
listOf(resolveUncheckedCheckbox(semanticsNode, globalBounds))
49+
}
50+
51+
return SemanticsWireframe(
52+
uiContext = null,
53+
wireframes = wireframes
54+
)
55+
}
56+
57+
private fun isCheckboxChecked(semanticsNode: SemanticsNode): Boolean =
58+
semanticsNode.config.getOrNull(SemanticsProperties.ToggleableState) == ToggleableState.On
59+
60+
private fun resolveCheckedCheckbox(
61+
parentContext: UiContext,
62+
asyncJobStatusCallback: AsyncJobStatusCallback,
63+
semanticsNode: SemanticsNode,
64+
globalBounds: GlobalBounds
65+
): List<MobileSegment.Wireframe> {
66+
val checkMarkBitmap = semanticsUtils.resolveCheckPath(semanticsNode)?.let {
67+
convertPathToBitmap(semanticsNode, it)
68+
}
69+
70+
return if (checkMarkBitmap != null) {
71+
parentContext.imageWireframeHelper.createImageWireframeByBitmap(
72+
id = resolveId(semanticsNode, 0),
73+
globalBounds = globalBounds,
74+
bitmap = checkMarkBitmap,
75+
density = parentContext.density,
76+
isContextualImage = false,
77+
imagePrivacy = ImagePrivacy.MASK_NONE,
78+
asyncJobStatusCallback = asyncJobStatusCallback,
79+
clipping = null,
80+
shapeStyle = null,
81+
border = null
82+
)?.let {
83+
listOf(it)
84+
} ?: createFallbackCheckmarkWireframe(semanticsNode, globalBounds)
85+
} else {
86+
createFallbackCheckmarkWireframe(semanticsNode, globalBounds)
87+
}
88+
}
89+
90+
private fun createFallbackCheckmarkWireframe(
91+
semanticsNode: SemanticsNode,
92+
globalBounds: GlobalBounds
93+
): List<MobileSegment.Wireframe> {
94+
val backgroundColor = resolveCheckboxFillColor(semanticsNode)
95+
96+
val background: MobileSegment.Wireframe = resolveUncheckedCheckbox(
97+
semanticsNode = semanticsNode,
98+
globalBounds = globalBounds,
99+
backgroundColor = backgroundColor
100+
)
101+
102+
// ensure contrast
103+
val strokeColor = if (backgroundColor == DEFAULT_COLOR_WHITE) {
104+
DEFAULT_COLOR_BLACK
105+
} else {
106+
DEFAULT_COLOR_WHITE
107+
}
108+
109+
val checkmarkWidth = globalBounds.width * STROKE_SIZE_FACTOR
110+
val checkmarkHeight = globalBounds.height * STROKE_SIZE_FACTOR
111+
val xPos = globalBounds.x + ((CHECKBOX_SIZE / 2) - (checkmarkWidth / 2))
112+
val yPos = globalBounds.y + ((CHECKBOX_SIZE / 2) - (checkmarkHeight / 2))
113+
val foreground: MobileSegment.Wireframe = MobileSegment.Wireframe.ShapeWireframe(
114+
id = resolveId(semanticsNode, 1),
115+
x = xPos.toLong(),
116+
y = yPos.toLong(),
117+
width = checkmarkWidth.toLong(),
118+
height = checkmarkHeight.toLong(),
119+
shapeStyle = MobileSegment.ShapeStyle(
120+
backgroundColor = strokeColor,
121+
opacity = 1f,
122+
cornerRadius = CHECKBOX_CORNER_RADIUS
123+
)
124+
)
125+
return listOf(background, foreground)
126+
}
127+
128+
private fun resolveUncheckedCheckbox(
129+
semanticsNode: SemanticsNode,
130+
globalBounds: GlobalBounds,
131+
backgroundColor: String? = DEFAULT_COLOR_WHITE
132+
): MobileSegment.Wireframe {
133+
val borderColor =
134+
semanticsUtils.resolveBorderColor(semanticsNode)
135+
?.let {
136+
convertColor(it)
137+
} ?: DEFAULT_COLOR_BLACK
138+
139+
return MobileSegment.Wireframe.ShapeWireframe(
140+
id = resolveId(semanticsNode, 0),
141+
x = globalBounds.x,
142+
y = globalBounds.y,
143+
width = globalBounds.width,
144+
height = globalBounds.height,
145+
shapeStyle = MobileSegment.ShapeStyle(
146+
backgroundColor = backgroundColor,
147+
opacity = 1f,
148+
cornerRadius = CHECKBOX_CORNER_RADIUS
149+
),
150+
border = MobileSegment.ShapeBorder(
151+
color = borderColor,
152+
width = BOX_BORDER_WIDTH
153+
)
154+
)
155+
}
156+
157+
private fun convertPathToBitmap(semanticsNode: SemanticsNode, checkPath: Path): Bitmap? {
158+
val scaledPath = scalePathToBitmapSize(checkPath)
159+
160+
// Create a Bitmap
161+
val bitmap = bitmapWrapper.createBitmap(CHECKBOX_SIZE, CHECKBOX_SIZE, Bitmap.Config.ARGB_8888)
162+
?: return null
163+
164+
val canvas = canvasWrapper.createCanvas(bitmap)
165+
166+
val fillColor = pathUtils.convertRgbaToArgb(
167+
resolveCheckboxFillColor(semanticsNode)
168+
)
169+
170+
pathUtils.parseColorSafe(fillColor)?.let {
171+
canvas?.drawColor(it)
172+
}
173+
174+
val checkMarkColor = semanticsUtils.resolveCheckmarkColor(
175+
semanticsNode
176+
)?.let { rawColor ->
177+
convertColor(rawColor)?.let {
178+
// Flip the alpha value position
179+
pathUtils.convertRgbaToArgb(it)
180+
}
181+
} ?: DEFAULT_COLOR_WHITE
182+
183+
val parsedCheckmarkColor = pathUtils.parseColorSafe(checkMarkColor)
184+
?: return null
185+
186+
val paint = Paint().apply {
187+
color = parsedCheckmarkColor
188+
style = Paint.Style.STROKE
189+
strokeWidth = STROKE_WIDTH
190+
isAntiAlias = true
191+
}
192+
193+
// Draw the Path onto the Canvas
194+
pathUtils.asAndroidPathSafe(scaledPath)?.let {
195+
pathUtils.drawPathSafe(canvas, it, paint)
196+
} ?: return null
197+
198+
return bitmap
199+
}
200+
201+
private fun resolveCheckboxFillColor(semanticsNode: SemanticsNode): String =
202+
semanticsUtils.resolveCheckboxFillColor(semanticsNode)
203+
?.let { rawColor ->
204+
convertColor(rawColor)
205+
}
206+
?: DEFAULT_COLOR_WHITE
207+
208+
private fun scalePathToBitmapSize(path: Path): Path {
209+
val originalBounds = path.getBounds()
210+
211+
val scaleX = CHECKBOX_SIZE / originalBounds.width
212+
val scaleY = CHECKBOX_SIZE / originalBounds.height
213+
val scaleFactor = minOf(scaleX, scaleY)
214+
val scaledWidth = originalBounds.width * scaleFactor
215+
val scaledHeight = originalBounds.height * scaleFactor
216+
val canvasCenterX = CHECKBOX_SIZE / 2
217+
val canvasCenterY = CHECKBOX_SIZE / 2
218+
val translateX = canvasCenterX - (scaledWidth / 2)
219+
val translateY = canvasCenterY - (scaledHeight / 2)
220+
221+
val matrix = Matrix().apply {
222+
scale(scaleFactor, scaleFactor)
223+
translate(
224+
translateX / scaleFactor - originalBounds.left,
225+
translateY / scaleFactor - originalBounds.top
226+
)
227+
}
228+
229+
path.transform(matrix)
230+
231+
return path
232+
}
233+
234+
internal companion object {
235+
internal enum class CheckmarkFieldType {
236+
FILL_COLOR,
237+
CHECKMARK_COLOR,
238+
BORDER_COLOR
239+
}
240+
241+
internal const val DEFAULT_COLOR_BLACK = "#000000FF"
242+
internal const val DEFAULT_COLOR_WHITE = "#FFFFFFFF"
243+
244+
internal const val STROKE_SIZE_FACTOR = 0.6
245+
246+
// values from Checkbox sourcecode
247+
internal const val BOX_BORDER_WIDTH = 2L
248+
internal const val STROKE_WIDTH = 2f
249+
internal const val CHECKBOX_SIZE = 20 // dp
250+
internal const val CHECKBOX_CORNER_RADIUS = 2f
251+
}
252+
}

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)