-
Notifications
You must be signed in to change notification settings - Fork 64
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
Changes from all commits
9aaadcd
c5216f9
6bd6400
1f3e4af
c1b4f66
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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?) = | ||
jonathanmos marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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 |
---|---|---|
|
@@ -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") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 We probably need also update proguard consumer rules. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. +1, updating proguard consumer rules is necessary There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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") | ||
|
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" | ||
} | ||
} |
There was a problem hiding this comment.
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 }