Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RUM-6195: Add support for Compose Checkbox #2414

Merged
merged 5 commits into from
Dec 18, 2024
Merged
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
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()) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor: you probably can just do return wireframes.ifEmpty { createManualCheckedWireframe }

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")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tbh, this is very fragile - once new classes are added there, it won't be 1 anymore. But I guess we have no other way to deal with it?

We probably need also update proguard consumer rules.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1, updating proguard consumer rules is necessary

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've updated the proguard rules. I'm not sure we have a way to avoid this as this is the identifier for the class that we get

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

well, we have telemetry, but I'm pretty sure this may break very often.

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