Skip to content

Commit

Permalink
RUM-7467: Apply contrasting color to semantics component
Browse files Browse the repository at this point in the history
  • Loading branch information
ambushwork committed Jan 8, 2025
1 parent ce2ed62 commit 30f4ecd
Show file tree
Hide file tree
Showing 7 changed files with 178 additions and 29 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 @@ -29,8 +31,11 @@ internal class RadioButtonSemanticsNodeMapper(
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, wireframeIndex++, color)
val dotWireframe = resolveDotWireframe(semanticsNode, wireframeIndex, color)
return SemanticsWireframe(
uiContext = null,
wireframes = listOfNotNull(boxWireframe, dotWireframe)
Expand All @@ -39,7 +44,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 +58,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 +79,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 +91,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 @@ -28,6 +28,26 @@ internal class ColorUtils(
}
}

@Suppress("MagicNumber")
// Luma formula: L=0.2126×R+0.7152×G+0.0722×B
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 = if (red <= 0.03928) red / 12.92 else Math.pow((red + 0.055) / 1.055, 2.4)
val g = if (green <= 0.03928) green / 12.92 else Math.pow((green + 0.055) / 1.055, 2.4)
val b = if (blue <= 0.03928) blue / 12.92 else Math.pow((blue + 0.055) / 1.055, 2.4)

// Calculate luminance
val luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b

luminance <= 0.5
} ?: false
}

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 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 true W 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"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,21 @@ package com.datadog.android.sample.compose
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.padding
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.material.darkColors
import androidx.compose.material.lightColors
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.rememberNavController
import com.datadog.android.compose.ExperimentalTrackingApi
import com.datadog.android.compose.NavigationViewTrackingEffect
import com.datadog.android.rum.tracking.AcceptAllNavDestinations
import com.google.accompanist.appcompattheme.AppCompatTheme

/**
* An activity to showcase Jetpack Compose instrumentation.
Expand All @@ -30,7 +33,13 @@ class JetpackComposeActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
AppCompatTheme {
MaterialTheme(
colors = if (isSystemInDarkTheme()) {
darkColors()
} else {
lightColors()
}
) {
AppScaffold()
}
}
Expand Down
Loading

0 comments on commit 30f4ecd

Please sign in to comment.