Skip to content

Commit

Permalink
RUM-6195: Add support for Compose Checkbox
Browse files Browse the repository at this point in the history
  • Loading branch information
jonathanmos committed Nov 25, 2024
1 parent a67406e commit e951a44
Show file tree
Hide file tree
Showing 15 changed files with 942 additions and 27 deletions.
9 changes: 9 additions & 0 deletions detekt_custom.yml
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,10 @@ datadog:
- "android.database.sqlite.SQLiteDatabase.setTransactionSuccessful():java.lang.IllegalStateException"
- "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(kotlin.Int, kotlin.Int, android.graphics.Bitmap.Config):java.lang.IllegalArgumentException"
- "android.graphics.Bitmap.createBitmap(android.util.DisplayMetrics?, 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.Color.parseColor(kotlin.String?):java.lang.IllegalArgumentException"
- "android.graphics.Canvas.constructor(android.graphics.Bitmap):java.lang.IllegalStateException"
- "android.graphics.drawable.LayerDrawable.getDrawable(kotlin.Int):java.lang.IndexOutOfBoundsException"
- "android.net.ConnectivityManager.registerDefaultNetworkCallback(android.net.ConnectivityManager.NetworkCallback):java.lang.IllegalArgumentException,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,7 @@ 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.Paint.constructor()"
- "android.graphics.Point.constructor()"
- "android.graphics.Point.constructor(kotlin.Int, kotlin.Int)"
- "android.graphics.Rect.centerX()"
Expand All @@ -510,6 +514,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
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
/*
* 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 android.graphics.Bitmap
import android.graphics.Paint
import androidx.compose.ui.graphics.Matrix
import androidx.compose.ui.graphics.Path
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.compose.internal.data.SemanticsWireframe
import com.datadog.android.sessionreplay.compose.internal.data.UiContext
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.recorder.wrappers.BitmapWrapper
import com.datadog.android.sessionreplay.recorder.wrappers.CanvasWrapper
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 pathUtils: PathUtils = PathUtils(),
private val bitmapWrapper: BitmapWrapper = BitmapWrapper(),
private val canvasWrapper: CanvasWrapper = CanvasWrapper(InternalLogger.UNBOUND)
) : AbstractSemanticsNodeMapper(colorStringFormatter, semanticsUtils) {

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

val wireframes = if (isCheckboxChecked(semanticsNode)) {
resolveCheckedCheckbox(parentContext, asyncJobStatusCallback, semanticsNode, globalBounds)
} else {
listOf(resolveUncheckedCheckbox(semanticsNode, globalBounds))
}

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

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

private fun resolveCheckedCheckbox(
parentContext: UiContext,
asyncJobStatusCallback: AsyncJobStatusCallback,
semanticsNode: SemanticsNode,
globalBounds: GlobalBounds
): List<MobileSegment.Wireframe> {
val checkMarkBitmap = semanticsUtils.resolveCheckPath(semanticsNode)?.let {
convertPathToBitmap(semanticsNode, it)
}

return if (checkMarkBitmap != null) {
parentContext.imageWireframeHelper.createImageWireframeByBitmap(
id = resolveId(semanticsNode, 0),
globalBounds = globalBounds,
bitmap = checkMarkBitmap,
density = parentContext.density,
isContextualImage = false,
imagePrivacy = ImagePrivacy.MASK_NONE,
asyncJobStatusCallback = asyncJobStatusCallback,
clipping = null,
shapeStyle = null,
border = null
)?.let {
listOf(it)
} ?: createFallbackCheckmarkWireframe(semanticsNode, globalBounds)
} else {
createFallbackCheckmarkWireframe(semanticsNode, globalBounds)
}
}

private fun createFallbackCheckmarkWireframe(
semanticsNode: SemanticsNode,
globalBounds: GlobalBounds
): List<MobileSegment.Wireframe> {
val backgroundColor = resolveCheckboxFillColor(semanticsNode)

val background: MobileSegment.Wireframe = resolveUncheckedCheckbox(
semanticsNode = semanticsNode,
globalBounds = globalBounds,
backgroundColor = backgroundColor
)

// ensure contrast
val strokeColor = if (backgroundColor == DEFAULT_COLOR_WHITE) {
DEFAULT_COLOR_BLACK
} else {
DEFAULT_COLOR_WHITE
}

val checkmarkWidth = globalBounds.width * STROKE_SIZE_FACTOR
val checkmarkHeight = globalBounds.height * STROKE_SIZE_FACTOR
val xPos = globalBounds.x + ((CHECKBOX_SIZE / 2) - (checkmarkWidth / 2))
val yPos = globalBounds.y + ((CHECKBOX_SIZE / 2) - (checkmarkHeight / 2))
val foreground: MobileSegment.Wireframe = 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
)
)
return listOf(background, foreground)
}

private fun resolveUncheckedCheckbox(
semanticsNode: SemanticsNode,
globalBounds: GlobalBounds,
backgroundColor: String? = DEFAULT_COLOR_WHITE
): MobileSegment.Wireframe {
val borderColor =
semanticsUtils.resolveBorderColor(semanticsNode)
?.let {
convertColor(it)
} ?: DEFAULT_COLOR_BLACK

return MobileSegment.Wireframe.ShapeWireframe(
id = resolveId(semanticsNode, 0),
x = globalBounds.x,
y = globalBounds.y,
width = globalBounds.width,
height = globalBounds.height,
shapeStyle = MobileSegment.ShapeStyle(
backgroundColor = backgroundColor,
opacity = 1f,
cornerRadius = CHECKBOX_CORNER_RADIUS
),
border = MobileSegment.ShapeBorder(
color = borderColor,
width = BOX_BORDER_WIDTH
)
)
}

private fun convertPathToBitmap(semanticsNode: SemanticsNode, checkPath: Path): Bitmap? {
val scaledPath = scalePathToBitmapSize(checkPath)

// Create a Bitmap
val bitmap = bitmapWrapper.createBitmap(CHECKBOX_SIZE, CHECKBOX_SIZE, Bitmap.Config.ARGB_8888)
?: return null

val canvas = canvasWrapper.createCanvas(bitmap)

val fillColor = pathUtils.convertRgbaToArgb(
resolveCheckboxFillColor(semanticsNode)
)

pathUtils.parseColorSafe(fillColor)?.let {
canvas?.drawColor(it)
}

val checkMarkColor = semanticsUtils.resolveCheckmarkColor(
semanticsNode
)?.let { rawColor ->
convertColor(rawColor)?.let {
// Flip the alpha value position
pathUtils.convertRgbaToArgb(it)
}
} ?: DEFAULT_COLOR_WHITE

val parsedCheckmarkColor = pathUtils.parseColorSafe(checkMarkColor)
?: return null

val paint = Paint().apply {
color = parsedCheckmarkColor
style = Paint.Style.STROKE
strokeWidth = STROKE_WIDTH
isAntiAlias = true
}

// Draw the Path onto the Canvas
pathUtils.asAndroidPathSafe(scaledPath)?.let {
pathUtils.drawPathSafe(canvas, it, paint)
} ?: return null

return bitmap
}

private fun resolveCheckboxFillColor(semanticsNode: SemanticsNode): String =
semanticsUtils.resolveCheckboxFillColor(semanticsNode)
?.let { rawColor ->
convertColor(rawColor)
}
?: DEFAULT_COLOR_WHITE

private fun scalePathToBitmapSize(path: Path): Path {
val originalBounds = path.getBounds()

val scaleX = CHECKBOX_SIZE / originalBounds.width
val scaleY = CHECKBOX_SIZE / originalBounds.height
val scaleFactor = minOf(scaleX, scaleY)
val scaledWidth = originalBounds.width * scaleFactor
val scaledHeight = originalBounds.height * scaleFactor
val canvasCenterX = CHECKBOX_SIZE / 2
val canvasCenterY = CHECKBOX_SIZE / 2
val translateX = canvasCenterX - (scaledWidth / 2)
val translateY = canvasCenterY - (scaledHeight / 2)

val matrix = Matrix().apply {
scale(scaleFactor, scaleFactor)
translate(
translateX / scaleFactor - originalBounds.left,
translateY / scaleFactor - originalBounds.top
)
}

path.transform(matrix)

return path
}

internal companion object {
internal enum class CheckmarkFieldType {
FILL_COLOR,
CHECKMARK_COLOR,
BORDER_COLOR
}

internal const val DEFAULT_COLOR_BLACK = "#000000FF"
internal const val DEFAULT_COLOR_WHITE = "#FFFFFFFF"

internal const val STROKE_SIZE_FACTOR = 0.6

// values from Checkbox sourcecode
internal const val BOX_BORDER_WIDTH = 2L
internal const val STROKE_WIDTH = 2f
internal const val CHECKBOX_SIZE = 20 // dp
internal const val CHECKBOX_CORNER_RADIUS = 2f
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,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)
Role.Image to ImageSemanticsNodeMapper(colorStringFormatter),
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 @@ -37,6 +37,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
Loading

0 comments on commit e951a44

Please sign in to comment.