Skip to content

Commit

Permalink
Merge pull request #2414 from DataDog/jmoskovich/rum-6195/checkbox-se…
Browse files Browse the repository at this point in the history
…mantics-mapper

RUM-6195: Add support for Compose Checkbox
  • Loading branch information
jonathanmos authored Dec 18, 2024
2 parents 4e7d937 + c1b4f66 commit f9bc2b5
Show file tree
Hide file tree
Showing 28 changed files with 2,118 additions and 168 deletions.
20 changes: 20 additions & 0 deletions detekt_custom.yml
Original file line number Diff line number Diff line change
Expand Up @@ -130,8 +130,10 @@ datadog:
- "android.graphics.Bitmap.compress(android.graphics.Bitmap.CompressFormat, kotlin.Int, java.io.OutputStream):java.lang.NullPointerException,java.lang.IllegalArgumentException"
- "android.graphics.Bitmap.copy(android.graphics.Bitmap.Config, kotlin.Boolean):java.lang.IllegalArgumentException"
- "android.graphics.Bitmap.createBitmap(android.util.DisplayMetrics?, kotlin.Int, kotlin.Int, android.graphics.Bitmap.Config):java.lang.IllegalArgumentException"
- "android.graphics.Bitmap.createBitmap(kotlin.Int, kotlin.Int, android.graphics.Bitmap.Config):java.lang.IllegalArgumentException"
- "android.graphics.Bitmap.createScaledBitmap(android.graphics.Bitmap, kotlin.Int, kotlin.Int, kotlin.Boolean):java.lang.IllegalArgumentException"
- "android.graphics.Canvas.constructor(android.graphics.Bitmap):java.lang.IllegalStateException"
- "android.graphics.Color.parseColor(kotlin.String?):java.lang.IllegalArgumentException"
- "android.graphics.drawable.LayerDrawable.getDrawable(kotlin.Int):java.lang.IndexOutOfBoundsException"
- "android.net.ConnectivityManager.registerDefaultNetworkCallback(android.net.ConnectivityManager.NetworkCallback):java.lang.IllegalArgumentException,java.lang.SecurityException"
- "android.net.ConnectivityManager.unregisterNetworkCallback(android.net.ConnectivityManager.NetworkCallback):java.lang.SecurityException"
Expand Down Expand Up @@ -468,6 +470,7 @@ datadog:
# endregion
# region Android Graphics
- "android.graphics.Bitmap.recycle()"
- "android.graphics.Canvas.drawColor(kotlin.Int)"
- "android.graphics.Canvas.drawColor(kotlin.Int, android.graphics.PorterDuff.Mode)"
- "android.graphics.Color.argb(kotlin.Int, kotlin.Int, kotlin.Int, kotlin.Int)"
- "android.graphics.Color.blue(kotlin.Int)"
Expand All @@ -484,6 +487,14 @@ datadog:
- "android.graphics.drawable.Drawable.setTintList(android.content.res.ColorStateList?)"
- "android.graphics.drawable.RippleDrawable.findIndexByLayerId(kotlin.Int)"
- "android.graphics.drawable.DrawableContainer.DrawableContainerState.getChild(kotlin.Int)"
- "android.graphics.Matrix.constructor()"
- "android.graphics.Matrix.preScale(kotlin.Float, kotlin.Float)"
- "android.graphics.Matrix.preTranslate(kotlin.Float, kotlin.Float)"
- "android.graphics.Paint.constructor()"
- "android.graphics.Path.computeBounds(android.graphics.RectF, kotlin.Boolean)"
- "android.graphics.Path.transform(android.graphics.Matrix)"
- "android.graphics.PathMeasure.constructor(android.graphics.Path?, kotlin.Boolean)"
- "android.graphics.PathMeasure.nextContour()"
- "android.graphics.Point.constructor()"
- "android.graphics.Point.constructor(kotlin.Int, kotlin.Int)"
- "android.graphics.Rect.centerX()"
Expand All @@ -492,6 +503,9 @@ datadog:
- "android.graphics.Rect.constructor(kotlin.Int, kotlin.Int, kotlin.Int, kotlin.Int)"
- "android.graphics.Rect.height()"
- "android.graphics.Rect.width()"
- "android.graphics.RectF.constructor()"
- "android.graphics.RectF.width()"
- "android.graphics.RectF.height()"
# endregion
# region Androidx APIs
- "androidx.appcompat.widget.DatadogActionBarContainerAccessor.constructor(androidx.appcompat.widget.ActionBarContainer)"
Expand All @@ -510,6 +524,11 @@ datadog:
- "androidx.compose.runtime.tooling.CompositionGroup.stableId()"
- "androidx.compose.ui.graphics.Color(kotlin.Long)"
- "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)"
- "androidx.compose.ui.graphics.Matrix.translate(kotlin.Float, kotlin.Float, kotlin.Float)"
- "androidx.compose.ui.graphics.Path.getBounds()"
- "androidx.compose.ui.graphics.Path.transform(androidx.compose.ui.graphics.Matrix)"
- "androidx.compose.ui.layout.LayoutCoordinates.positionInWindow()"
- "androidx.compose.ui.layout.LayoutInfo.getModifierInfo()"
- "androidx.compose.ui.unit.Density(kotlin.Float, kotlin.Float)"
Expand Down Expand Up @@ -1141,6 +1160,7 @@ datadog:
- "kotlin.Float.toFloat()"
- "kotlin.Float.toInt()"
- "kotlin.Float.toLong()"
- "kotlin.FloatArray.constructor(kotlin.Int)"
- "kotlin.Int.and(kotlin.Int)"
- "kotlin.Int.coerceAtMost(kotlin.Int)"
- "kotlin.Int.inv()"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@
-keepclassmembers class androidx.compose.foundation.text.modifiers.TextStringSimpleElement {
<fields>;
}
-keepclassmembers class androidx.compose.material.CheckDrawingCache {
<fields>;
}
-keepclassmembers class androidx.compose.material.CheckboxKt {
<fields>;
}
-keepclassmembers class androidx.compose.ui.draw.DrawBehindElement {
<fields>;
}
-keepclassmembers class androidx.compose.foundation.BackgroundElement {
<fields>;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
/*
* 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 CHECKBOX_SIZE16-Present Datadog, Inc.
*/

package com.datadog.android.sessionreplay.compose.internal.mappers.semantics

import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.getOrNull
import androidx.compose.ui.state.ToggleableState
import com.datadog.android.api.InternalLogger
import com.datadog.android.sessionreplay.ImagePrivacy
import com.datadog.android.sessionreplay.TextAndInputPrivacy
import com.datadog.android.sessionreplay.compose.internal.data.SemanticsWireframe
import com.datadog.android.sessionreplay.compose.internal.data.UiContext
import com.datadog.android.sessionreplay.compose.internal.utils.ColorUtils
import com.datadog.android.sessionreplay.compose.internal.utils.PathUtils
import com.datadog.android.sessionreplay.compose.internal.utils.SemanticsUtils
import com.datadog.android.sessionreplay.model.MobileSegment
import com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback
import com.datadog.android.sessionreplay.utils.ColorStringFormatter
import com.datadog.android.sessionreplay.utils.GlobalBounds

internal class CheckboxSemanticsNodeMapper(
colorStringFormatter: ColorStringFormatter,
private val semanticsUtils: SemanticsUtils = SemanticsUtils(),
private val colorUtils: ColorUtils = ColorUtils(),
private val logger: InternalLogger = InternalLogger.UNBOUND,
private val pathUtils: PathUtils = PathUtils(logger)
) : AbstractSemanticsNodeMapper(colorStringFormatter, semanticsUtils) {

override fun map(
semanticsNode: SemanticsNode,
parentContext: UiContext,
asyncJobStatusCallback: AsyncJobStatusCallback
): SemanticsWireframe {
val globalBounds = resolveBounds(semanticsNode)

val checkableWireframes = if (isCheckboxMasked(parentContext)) {
listOf(
resolveMaskedCheckable(
semanticsNode = semanticsNode,
globalBounds = globalBounds
)
)
} else {
createCheckboxWireframes(
parentContext = parentContext,
asyncJobStatusCallback = asyncJobStatusCallback,
semanticsNode = semanticsNode,
globalBounds = globalBounds,
currentIndex = 0
)
}

return SemanticsWireframe(
uiContext = null,
wireframes = checkableWireframes
)
}

private fun isCheckboxMasked(parentContext: UiContext): Boolean =
parentContext.textAndInputPrivacy != TextAndInputPrivacy.MASK_SENSITIVE_INPUTS

private fun resolveMaskedCheckable(
semanticsNode: SemanticsNode,
globalBounds: GlobalBounds
): MobileSegment.Wireframe {
// TODO RUM-5118: Decide how to display masked checkbox, Currently use old unchecked shape wireframe,
return createUncheckedState(
semanticsNode = semanticsNode,
globalBounds = globalBounds,
backgroundColor = DEFAULT_COLOR_WHITE,
borderColor = DEFAULT_COLOR_BLACK,
currentIndex = 0
)
}

private fun createCheckboxWireframes(
parentContext: UiContext,
asyncJobStatusCallback: AsyncJobStatusCallback,
semanticsNode: SemanticsNode,
globalBounds: GlobalBounds,
currentIndex: Int
): List<MobileSegment.Wireframe> {
val borderColor = resolveBorderColor(semanticsNode)
val rawFillColor = semanticsUtils.resolveCheckboxFillColor(semanticsNode)
val rawCheckmarkColor = semanticsUtils.resolveCheckmarkColor(semanticsNode)
val fillColorRgba = rawFillColor?.let { convertColor(it) } ?: DEFAULT_COLOR_WHITE
val checkmarkColorRgba = rawCheckmarkColor?.let { convertColor(it) }
?: getFallbackCheckmarkColor(DEFAULT_COLOR_WHITE)
val parsedFillColor = colorUtils.parseColorSafe(fillColorRgba)
val isChecked = isCheckboxChecked(semanticsNode)
val checkmarkColor = resolveCheckmarkColor(isChecked, checkmarkColorRgba, parsedFillColor)

val wireframes = mutableListOf<MobileSegment.Wireframe>()

if (parsedFillColor != null && checkmarkColor != null) {
val composePath = semanticsUtils
.resolveCheckPath(semanticsNode)

val androidPath = composePath?.let { checkPath ->
pathUtils.asAndroidPathSafe(checkPath)
}

if (androidPath != null) {
parentContext.imageWireframeHelper.createImageWireframeByPath(
id = resolveId(semanticsNode, currentIndex),
globalBounds = globalBounds,
path = androidPath,
strokeColor = checkmarkColor,
strokeWidth = STROKE_WIDTH_DP.toInt(),
targetWidth = CHECKBOX_SIZE_DP,
targetHeight = CHECKBOX_SIZE_DP,
density = parentContext.density,
isContextualImage = false,
imagePrivacy = ImagePrivacy.MASK_NONE,
asyncJobStatusCallback = asyncJobStatusCallback,
clipping = null,
shapeStyle = MobileSegment.ShapeStyle(
backgroundColor = fillColorRgba,
opacity = 1f,
cornerRadius = CHECKBOX_CORNER_RADIUS
),
border = MobileSegment.ShapeBorder(
color = borderColor,
width = BOX_BORDER_WIDTH_DP
),
customResourceIdCacheKey = null
)?.let { imageWireframe ->
wireframes.add(imageWireframe)
}
}
}

if (wireframes.isNotEmpty()) {
return wireframes
}

// if we failed to create a wireframe from the path
return createManualCheckedWireframes(
semanticsNode = semanticsNode,
globalBounds = globalBounds,
backgroundColor = fillColorRgba,
borderColor = borderColor
)
}

private fun resolveCheckmarkColor(isChecked: Boolean, checkmarkColorRgba: String, fillColor: Int?): Int? =
if (isChecked) {
colorUtils.parseColorSafe(checkmarkColorRgba)
} else {
fillColor
}

private fun resolveBorderColor(semanticsNode: SemanticsNode): String {
return semanticsUtils.resolveBorderColor(semanticsNode)
?.let { rawColor ->
convertColor(rawColor)
} ?: DEFAULT_COLOR_BLACK
}

private fun createManualCheckedWireframes(
semanticsNode: SemanticsNode,
globalBounds: GlobalBounds,
backgroundColor: String,
borderColor: String
): List<MobileSegment.Wireframe> {
val strokeColor = getFallbackCheckmarkColor(backgroundColor)

val wireframes = mutableListOf<MobileSegment.Wireframe>()

val background = createUncheckedState(
semanticsNode = semanticsNode,
globalBounds = globalBounds,
backgroundColor = backgroundColor,
borderColor = borderColor,
currentIndex = 0
)

wireframes.add(background)

val checkmarkWidth = globalBounds.width * CHECKMARK_SIZE_FACTOR
val checkmarkHeight = globalBounds.height * CHECKMARK_SIZE_FACTOR
val xPos = globalBounds.x + ((globalBounds.width / 2) - (checkmarkWidth / 2))
val yPos = globalBounds.y + ((globalBounds.height / 2) - (checkmarkHeight / 2))
val foreground = MobileSegment.Wireframe.ShapeWireframe(
id = resolveId(semanticsNode, 1),
x = xPos.toLong(),
y = yPos.toLong(),
width = checkmarkWidth.toLong(),
height = checkmarkHeight.toLong(),
shapeStyle = MobileSegment.ShapeStyle(
backgroundColor = strokeColor,
opacity = 1f,
cornerRadius = CHECKBOX_CORNER_RADIUS
),
border = MobileSegment.ShapeBorder(
color = DEFAULT_COLOR_BLACK,
width = BOX_BORDER_WIDTH_DP
)
)

wireframes.add(foreground)
return wireframes
}

private fun createUncheckedState(
semanticsNode: SemanticsNode,
globalBounds: GlobalBounds,
backgroundColor: String,
borderColor: String,
currentIndex: Int
) = MobileSegment.Wireframe.ShapeWireframe(
id = resolveId(semanticsNode, currentIndex),
x = globalBounds.x,
y = globalBounds.y,
width = CHECKBOX_SIZE_DP.toLong(),
height = CHECKBOX_SIZE_DP.toLong(),
shapeStyle = MobileSegment.ShapeStyle(
backgroundColor = backgroundColor,
opacity = 1f,
cornerRadius = CHECKBOX_CORNER_RADIUS
),
border = MobileSegment.ShapeBorder(
color = borderColor,
width = BOX_BORDER_WIDTH_DP
)
)

private fun isCheckboxChecked(semanticsNode: SemanticsNode): Boolean =
semanticsNode.config.getOrNull(SemanticsProperties.ToggleableState) == ToggleableState.On

private fun getFallbackCheckmarkColor(backgroundColor: String?) =
if (backgroundColor == DEFAULT_COLOR_WHITE) {
DEFAULT_COLOR_BLACK
} else {
DEFAULT_COLOR_WHITE
}

internal companion object {
internal const val DEFAULT_COLOR_BLACK = "#000000FF"
internal const val DEFAULT_COLOR_WHITE = "#FFFFFFFF"

// when we create the checkmark manually, what % of the checkbox size should it be
internal const val CHECKMARK_SIZE_FACTOR = 0.5

// values from Compose Checkbox sourcecode
internal const val BOX_BORDER_WIDTH_DP = 2L
internal const val STROKE_WIDTH_DP = 2f
internal const val CHECKBOX_SIZE_DP = 20
internal const val CHECKBOX_CORNER_RADIUS = 2f
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ internal class RootSemanticsNodeMapper(
Role.RadioButton to RadioButtonSemanticsNodeMapper(colorStringFormatter, semanticsUtils),
Role.Tab to TabSemanticsNodeMapper(colorStringFormatter, semanticsUtils),
Role.Button to ButtonSemanticsNodeMapper(colorStringFormatter, semanticsUtils),
Role.Image to ImageSemanticsNodeMapper(colorStringFormatter, semanticsUtils)
Role.Image to ImageSemanticsNodeMapper(colorStringFormatter, semanticsUtils),
Role.Checkbox to CheckboxSemanticsNodeMapper(colorStringFormatter, semanticsUtils)
),
// Text doesn't have a role in semantics, so it should be a fallback mapper.
private val textSemanticsNodeMapper: TextSemanticsNodeMapper = TextSemanticsNodeMapper(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,16 @@ internal object ComposeReflection {
val ColorField = BackgroundElementClass?.getDeclaredFieldSafe("color")
val ShapeField = BackgroundElementClass?.getDeclaredFieldSafe("shape")

val CheckDrawingCacheClass = getClassSafe("androidx.compose.material.CheckDrawingCache")
val CheckboxKtClass = getClassSafe("androidx.compose.material.CheckboxKt\$CheckboxImpl\$1\$1")
val DrawBehindElementClass = getClassSafe("androidx.compose.ui.draw.DrawBehindElement")
val BorderColorField = CheckboxKtClass?.getDeclaredFieldSafe("\$borderColor\$delegate")
val BoxColorField = CheckboxKtClass?.getDeclaredFieldSafe("\$boxColor\$delegate")
val CheckCacheField = CheckboxKtClass?.getDeclaredFieldSafe("\$checkCache")
val CheckColorField = CheckboxKtClass?.getDeclaredFieldSafe("\$checkColor\$delegate")
val CheckPathField = CheckDrawingCacheClass?.getDeclaredFieldSafe("checkPath")
val OnDrawField = DrawBehindElementClass?.getDeclaredFieldSafe("onDraw")

val PaddingElementClass = getClassSafe("androidx.compose.foundation.layout.PaddingElement")
val StartField = PaddingElementClass?.getDeclaredFieldSafe("start")
val EndField = PaddingElementClass?.getDeclaredFieldSafe("end")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
* 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 android.graphics.Color
import com.datadog.android.api.InternalLogger
import java.util.Locale

internal class ColorUtils(
private val logger: InternalLogger = InternalLogger.UNBOUND
) {
internal fun parseColorSafe(color: String): Int? {
return try {
@Suppress("UnsafeThirdPartyFunctionCall") // handling IllegalArgumentException
Color.parseColor(color)
} catch (e: IllegalArgumentException) {
logger.log(
target = InternalLogger.Target.MAINTAINER,
level = InternalLogger.Level.WARN,
messageBuilder = { COLOR_PARSE_ERROR.format(Locale.US, color) },
throwable = e
)
null
}
}

internal companion object {
internal const val COLOR_PARSE_ERROR = "Failed to parse color: %s"
}
}
Loading

0 comments on commit f9bc2b5

Please sign in to comment.