Skip to content

Commit

Permalink
Merge pull request #2477 from DataDog/yl/compose/contrast-color
Browse files Browse the repository at this point in the history
RUM-7467: Apply contrasting color to semantics component
  • Loading branch information
ambushwork authored Jan 9, 2025
2 parents 7444761 + 6140c91 commit cad0d62
Show file tree
Hide file tree
Showing 7 changed files with 182 additions and 30 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ plugins {
id("com.github.ben-manes.versions")

// Tests
id("de.mobilej.unmock")
id("org.jetbrains.kotlinx.kover")

// Internal Generation
Expand Down Expand Up @@ -75,6 +76,11 @@ dependencies {
testImplementation(testFixtures(project(":dd-sdk-android-core")))
testImplementation(libs.bundles.jUnit5)
testImplementation(libs.bundles.testTools)
unmock(libs.robolectric)
}

unMock {
keep("android.graphics.Color")
}

kotlinConfig(jvmBytecodeTarget = JvmTarget.JVM_11)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,16 @@ import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.getOrNull
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.SemanticsUtils
import com.datadog.android.sessionreplay.model.MobileSegment
import com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback
import com.datadog.android.sessionreplay.utils.ColorStringFormatter

internal class RadioButtonSemanticsNodeMapper(
colorStringFormatter: ColorStringFormatter,
semanticsUtils: SemanticsUtils = SemanticsUtils()
semanticsUtils: SemanticsUtils = SemanticsUtils(),
private val colorUtils: ColorUtils = ColorUtils()
) : AbstractSemanticsNodeMapper(
colorStringFormatter,
semanticsUtils
Expand All @@ -28,9 +30,11 @@ internal class RadioButtonSemanticsNodeMapper(
parentContext: UiContext,
asyncJobStatusCallback: AsyncJobStatusCallback
): SemanticsWireframe {
var wireframeIndex = 0
val boxWireframe = resolveBoxWireframe(semanticsNode, wireframeIndex++)
val dotWireframe = resolveDotWireframe(semanticsNode, wireframeIndex)
val color = parentContext.parentContentColor?.takeIf { colorUtils.isDarkColor(it) }?.let {
DEFAULT_COLOR_WHITE
} ?: DEFAULT_COLOR_BLACK
val boxWireframe = resolveBoxWireframe(semanticsNode, 0, color)
val dotWireframe = resolveDotWireframe(semanticsNode, 1, color)
return SemanticsWireframe(
uiContext = null,
wireframes = listOfNotNull(boxWireframe, dotWireframe)
Expand All @@ -39,7 +43,8 @@ internal class RadioButtonSemanticsNodeMapper(

private fun resolveBoxWireframe(
semanticsNode: SemanticsNode,
wireframeIndex: Int
wireframeIndex: Int,
color: String
): MobileSegment.Wireframe {
val globalBounds = resolveBounds(semanticsNode)
return MobileSegment.Wireframe.ShapeWireframe(
Expand All @@ -52,15 +57,16 @@ internal class RadioButtonSemanticsNodeMapper(
cornerRadius = globalBounds.width / 2
),
border = MobileSegment.ShapeBorder(
color = DEFAULT_COLOR_BLACK,
color = color,
width = BOX_BORDER_WIDTH
)
)
}

private fun resolveDotWireframe(
semanticsNode: SemanticsNode,
wireframeIndex: Int
wireframeIndex: Int,
color: String
): MobileSegment.Wireframe? {
val selected = semanticsNode.config.getOrNull(SemanticsProperties.Selected) ?: false
val globalBounds = resolveBounds(semanticsNode)
Expand All @@ -72,7 +78,7 @@ internal class RadioButtonSemanticsNodeMapper(
width = globalBounds.width - DOT_PADDING_DP * 2,
height = globalBounds.height - DOT_PADDING_DP * 2,
shapeStyle = MobileSegment.ShapeStyle(
backgroundColor = DEFAULT_COLOR_BLACK,
backgroundColor = color,
cornerRadius = (globalBounds.width - DOT_PADDING_DP * 2) / 2
)
)
Expand All @@ -84,6 +90,7 @@ internal class RadioButtonSemanticsNodeMapper(
companion object {
private const val DOT_PADDING_DP = 4
private const val DEFAULT_COLOR_BLACK = "#000000FF"
private const val DEFAULT_COLOR_WHITE = "#FFFFFFFF"
private const val BOX_BORDER_WIDTH = 1L
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import androidx.compose.ui.state.ToggleableState
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.SemanticsUtils
import com.datadog.android.sessionreplay.model.MobileSegment
import com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback
Expand All @@ -21,7 +22,8 @@ import com.datadog.android.sessionreplay.utils.GlobalBounds

internal class SwitchSemanticsNodeMapper(
colorStringFormatter: ColorStringFormatter,
semanticsUtils: SemanticsUtils = SemanticsUtils()
semanticsUtils: SemanticsUtils = SemanticsUtils(),
private val colorUtils: ColorUtils = ColorUtils()
) : AbstractSemanticsNodeMapper(colorStringFormatter, semanticsUtils) {
override fun map(
semanticsNode: SemanticsNode,
Expand All @@ -30,28 +32,32 @@ internal class SwitchSemanticsNodeMapper(
): SemanticsWireframe {
val isSwitchOn = isSwitchOn(semanticsNode)
val globalBounds = resolveBounds(semanticsNode)

val isDarkBackground =
parentContext.parentContentColor?.let { colorUtils.isDarkColor(it) } ?: false
val switchWireframes = if (isSwitchMasked(parentContext)) {
listOf(
resolveMaskedWireframes(
semanticsNode = semanticsNode,
globalBounds = globalBounds,
wireframeIndex = 0
wireframeIndex = 0,
isDarkBackground = isDarkBackground
)
)
} else {
val trackWireframe = createTrackWireframe(
semanticsNode = semanticsNode,
globalBounds = globalBounds,
wireframeIndex = 0,
isSwitchOn = isSwitchOn
isSwitchOn = isSwitchOn,
isDarkBackground = isDarkBackground
)

val thumbWireframe = createThumbWireframe(
semanticsNode = semanticsNode,
globalBounds = globalBounds,
wireframeIndex = 1,
isSwitchOn = isSwitchOn
isSwitchOn = isSwitchOn,
isDarkBackground = isDarkBackground
)

listOfNotNull(trackWireframe, thumbWireframe)
Expand All @@ -67,9 +73,10 @@ internal class SwitchSemanticsNodeMapper(
semanticsNode: SemanticsNode,
globalBounds: GlobalBounds,
wireframeIndex: Int,
isSwitchOn: Boolean
isSwitchOn: Boolean,
isDarkBackground: Boolean
): MobileSegment.Wireframe {
val trackColor = if (isSwitchOn) {
val trackColor = if (isSwitchOn != isDarkBackground) {
DEFAULT_COLOR_BLACK
} else {
DEFAULT_COLOR_WHITE
Expand All @@ -87,7 +94,7 @@ internal class SwitchSemanticsNodeMapper(
backgroundColor = trackColor
),
border = MobileSegment.ShapeBorder(
color = DEFAULT_COLOR_BLACK,
color = getContentColor(isDarkBackground),
width = BORDER_WIDTH_DP
)
)
Expand All @@ -97,7 +104,8 @@ internal class SwitchSemanticsNodeMapper(
semanticsNode: SemanticsNode,
globalBounds: GlobalBounds,
wireframeIndex: Int,
isSwitchOn: Boolean
isSwitchOn: Boolean,
isDarkBackground: Boolean
): MobileSegment.Wireframe {
val xPosition = if (!isSwitchOn) {
globalBounds.x
Expand All @@ -108,10 +116,10 @@ internal class SwitchSemanticsNodeMapper(
@Suppress("MagicNumber")
val yPosition = globalBounds.y + (globalBounds.height / 4) - (THUMB_DIAMETER_DP / 4)

val thumbColor = if (!isSwitchOn) {
DEFAULT_COLOR_WHITE
} else {
val thumbColor = if (isSwitchOn != isDarkBackground) {
DEFAULT_COLOR_BLACK
} else {
DEFAULT_COLOR_WHITE
}

return MobileSegment.Wireframe.ShapeWireframe(
Expand All @@ -125,7 +133,7 @@ internal class SwitchSemanticsNodeMapper(
backgroundColor = thumbColor
),
border = MobileSegment.ShapeBorder(
color = DEFAULT_COLOR_BLACK,
color = getContentColor(isDarkBackground),
width = BORDER_WIDTH_DP
)
)
Expand All @@ -140,23 +148,33 @@ internal class SwitchSemanticsNodeMapper(
private fun resolveMaskedWireframes(
semanticsNode: SemanticsNode,
globalBounds: GlobalBounds,
wireframeIndex: Int
wireframeIndex: Int,
isDarkBackground: Boolean
): MobileSegment.Wireframe {
// TODO RUM-5118: Decide how to display masked, currently use empty track,
return createTrackWireframe(
semanticsNode = semanticsNode,
globalBounds = globalBounds,
wireframeIndex = wireframeIndex,
isSwitchOn = false
isSwitchOn = false,
isDarkBackground
)
}

private fun getContentColor(isDarkBackground: Boolean): String {
return if (isDarkBackground) {
DEFAULT_COLOR_WHITE
} else {
DEFAULT_COLOR_BLACK
}
}

internal companion object {
const val TRACK_WIDTH_DP = 34L
const val CORNER_RADIUS_DP = 20
const val THUMB_DIAMETER_DP = 20
const val BORDER_WIDTH_DP = 1L
const val DEFAULT_COLOR_BLACK = "#000000"
const val DEFAULT_COLOR_WHITE = "#FFFFFF"
const val DEFAULT_COLOR_BLACK = "#000000FF"
const val DEFAULT_COLOR_WHITE = "#FFFFFFFF"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ package com.datadog.android.sessionreplay.compose.internal.utils
import android.graphics.Color
import com.datadog.android.api.InternalLogger
import java.util.Locale
import kotlin.math.pow

internal class ColorUtils(
private val logger: InternalLogger = InternalLogger.UNBOUND
Expand All @@ -28,6 +29,30 @@ internal class ColorUtils(
}
}

// Luma formula: L=0.2126×R+0.7152×G+0.0722×B
@Suppress("MagicNumber")
internal fun isDarkColor(hexColor: String): Boolean {
return parseColorSafe(hexColor)?.let {
val red = Color.red(it) / 255.0
val green = Color.green(it) / 255.0
val blue = Color.blue(it) / 255.0

// Linearize the RGB components
val r = linearize(red)
val g = linearize(green)
val b = linearize(blue)

// Calculate luminance
val luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b
luminance <= 0.5
} ?: false
}

@Suppress("MagicNumber")
private fun linearize(channel: Double): Double {
return if (channel <= 0.03928) channel / 12.92 else ((channel + 0.055) / 1.055).pow(2.4)
}

internal companion object {
internal const val COLOR_PARSE_ERROR = "Failed to parse color: %s"
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/*
* 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 fr.xgouchet.elmyr.Forge
import fr.xgouchet.elmyr.junit5.ForgeExtension
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.junit.jupiter.api.extension.Extensions
import org.mockito.junit.jupiter.MockitoExtension
import org.mockito.junit.jupiter.MockitoSettings
import org.mockito.quality.Strictness

@Extensions(
ExtendWith(MockitoExtension::class),
ExtendWith(ForgeExtension::class)
)
@MockitoSettings(strictness = Strictness.LENIENT)
class ColorUtilsTest {

private val testedColorUtils = ColorUtils()

private val lightColors = listOf(
PURE_WHITE_HEX,
BRIGHT_GREEN_HEX,
BRIGHT_YELLOW_HEX,
LIGHT_GRAY_HEX,
VERY_LIGHT_GRAY_HEX
)

private val darkColors = listOf(
PURE_BLACK_HEX,
BRIGHT_RED_HEX,
BRIGHT_BLUE_HEX,
NEUTRAL_GRAY_HEX,
DARK_INDIGO_HEX
)

@Test
fun `M return true W isDarkColor { color is dark }`(forge: Forge) {
// Given
val color = forge.anElementFrom(darkColors)

// When
val result = testedColorUtils.isDarkColor(color)

// Then
assertThat(result).isTrue()
}

@Test
fun `M return false W isDarkColor { color is light }`(forge: Forge) {
// Given
val color = forge.anElementFrom(lightColors)

// When
val result = testedColorUtils.isDarkColor(color)

// Then
assertThat(result).isFalse()
}

companion object {
// light colors
private const val PURE_WHITE_HEX = "#FFFFFFFF"
private const val BRIGHT_GREEN_HEX = "#FF00FF00"
private const val BRIGHT_YELLOW_HEX = "#FFFFFF00"
private const val LIGHT_GRAY_HEX = "#FFC0C0C0"
private const val VERY_LIGHT_GRAY_HEX = "#FFF5F5F5"

// dark colors
private const val PURE_BLACK_HEX = "#FF000000"
private const val BRIGHT_RED_HEX = "#FFFF0000"
private const val BRIGHT_BLUE_HEX = "#FF0000FF"
private const val NEUTRAL_GRAY_HEX = "#FF808080"
private const val DARK_INDIGO_HEX = "#FF4B0082"
}
}
Loading

0 comments on commit cad0d62

Please sign in to comment.