From afe85c65c0328b6eeb3d7baeeceb3989a230f696 Mon Sep 17 00:00:00 2001 From: luyi Date: Fri, 27 Sep 2024 15:10:26 +0200 Subject: [PATCH] RUM-6194: Add Semantics Mapper for Button role --- detekt_custom.yml | 1 + .../consumer-rules.pro | 19 ++- .../semantics/AbstractSemanticsNodeMapper.kt | 7 -- .../semantics/ButtonSemanticsNodeMapper.kt | 54 ++++++++ .../semantics/SemanticsWireframeMapper.kt | 1 + .../internal/reflection/ComposeReflection.kt | 4 + .../compose/internal/utils/SemanticsUtils.kt | 35 ++++++ .../ButtonSemanticsNodeMapperTest.kt | 116 ++++++++++++++++++ 8 files changed, 229 insertions(+), 8 deletions(-) create mode 100644 features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/ButtonSemanticsNodeMapper.kt create mode 100644 features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/ButtonSemanticsNodeMapperTest.kt diff --git a/detekt_custom.yml b/detekt_custom.yml index 87ecddf3dd..d604fedc74 100644 --- a/detekt_custom.yml +++ b/detekt_custom.yml @@ -505,6 +505,7 @@ datadog: - "androidx.compose.ui.layout.LayoutCoordinates.positionInWindow()" - "androidx.compose.ui.layout.LayoutInfo.getModifierInfo()" - "androidx.compose.ui.unit.Density(kotlin.Float, kotlin.Float)" + - "androidx.compose.ui.geometry.Size(kotlin.Float, kotlin.Float)" - "androidx.compose.ui.geometry.Size.copy(kotlin.Float, kotlin.Float)" - "androidx.compose.ui.semantics.SemanticsConfiguration.firstOrNull(kotlin.Function1)" - "androidx.compose.ui.semantics.SemanticsConfiguration.getOrNull(androidx.compose.ui.semantics.SemanticsPropertyKey)" diff --git a/features/dd-sdk-android-session-replay-compose/consumer-rules.pro b/features/dd-sdk-android-session-replay-compose/consumer-rules.pro index 89a2872665..835b5d9db1 100644 --- a/features/dd-sdk-android-session-replay-compose/consumer-rules.pro +++ b/features/dd-sdk-android-session-replay-compose/consumer-rules.pro @@ -4,5 +4,22 @@ -keepnames class androidx.compose.runtime.ComposerImpl$CompositionContextImpl -keepnames class androidx.compose.runtime.CompositionImpl -keepnames class androidx.compose.runtime.RecomposeScopeImpl --keepnames class androidx.compose.ui.platform.WrappedComposition +-keepnames class androidx.compose.runtime.Composition +-keepnames class androidx.compose.ui.platform.ComposeView -keepnames class androidx.compose.material.DefaultButtonColors +-keepclassmembers class androidx.compose.ui.platform.WrappedComposition { + ; +} +-keepclassmembers class androidx.compose.ui.platform.AbstractComposeView { + ; +} +-keepclassmembers class androidx.compose.ui.platform.AndroidComposeView { + ; +} + +-keepclassmembers class androidx.compose.foundation.text.modifiers.TextStringSimpleElement { + ; +} +-keepclassmembers class androidx.compose.foundation.BackgroundElement { + ; +} diff --git a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/AbstractSemanticsNodeMapper.kt b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/AbstractSemanticsNodeMapper.kt index c95855354f..a8c98c4795 100644 --- a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/AbstractSemanticsNodeMapper.kt +++ b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/AbstractSemanticsNodeMapper.kt @@ -27,13 +27,6 @@ internal abstract class AbstractSemanticsNodeMapper( return GlobalBounds(x, y, width, height) } - protected fun convertColor(color: Color): String { - return colorStringFormatter.formatColorAndAlphaAsHexString( - color.toArgb(), - (color.alpha * MAX_ALPHA).roundToInt() - ) - } - protected fun convertColor(color: Long): String? { return if (color == UNSPECIFIED_COLOR) { null diff --git a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/ButtonSemanticsNodeMapper.kt b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/ButtonSemanticsNodeMapper.kt new file mode 100644 index 0000000000..7858374f4c --- /dev/null +++ b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/ButtonSemanticsNodeMapper.kt @@ -0,0 +1,54 @@ +/* + * 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.mappers.semantics + +import androidx.compose.ui.semantics.SemanticsNode +import androidx.compose.ui.unit.Density +import com.datadog.android.sessionreplay.compose.internal.data.ComposeWireframe +import com.datadog.android.sessionreplay.compose.internal.data.UiContext +import com.datadog.android.sessionreplay.compose.internal.utils.SemanticsUtils +import com.datadog.android.sessionreplay.model.MobileSegment +import com.datadog.android.sessionreplay.utils.ColorStringFormatter +import com.datadog.android.sessionreplay.utils.GlobalBounds + +internal class ButtonSemanticsNodeMapper( + colorStringFormatter: ColorStringFormatter, + private val semanticsUtils: SemanticsUtils = SemanticsUtils() +) : AbstractSemanticsNodeMapper(colorStringFormatter) { + + override fun map(semanticsNode: SemanticsNode, parentContext: UiContext): ComposeWireframe { + val density = semanticsNode.layoutInfo.density + val bounds = resolveBound(semanticsNode) + val buttonStyle = resolveSemanticsButtonStyle(semanticsNode, bounds, density) + return ComposeWireframe( + MobileSegment.Wireframe.ShapeWireframe( + id = semanticsNode.id.toLong(), + x = bounds.x, + y = bounds.y, + width = bounds.width, + height = bounds.height, + shapeStyle = buttonStyle + ), + uiContext = parentContext.copy( + parentContentColor = buttonStyle.backgroundColor ?: parentContext.parentContentColor + ) + ) + } + + private fun resolveSemanticsButtonStyle( + semanticsNode: SemanticsNode, + globalBounds: GlobalBounds, + density: Density + ): MobileSegment.ShapeStyle { + val color = semanticsUtils.resolveSemanticsModifierColor(semanticsNode) + val cornerRadius = semanticsUtils.resolveSemanticsModifierCornerRadius(semanticsNode, globalBounds, density) + return MobileSegment.ShapeStyle( + backgroundColor = color?.let { convertColor(it) }, + cornerRadius = cornerRadius + ) + } +} diff --git a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/SemanticsWireframeMapper.kt b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/SemanticsWireframeMapper.kt index 952fd760f5..934491303d 100644 --- a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/SemanticsWireframeMapper.kt +++ b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/SemanticsWireframeMapper.kt @@ -32,6 +32,7 @@ internal class SemanticsWireframeMapper( private val semanticsUtils: SemanticsUtils = SemanticsUtils(), private val semanticsNodeMapper: Map = mapOf( // TODO RUM-6189 Add Mappers for each Semantics Role + Role.Button to ButtonSemanticsNodeMapper(colorStringFormatter) ), // Text doesn't have a role in semantics, so it should be a fallback mapper. private val textSemanticsNodeMapper: TextSemanticsNodeMapper = TextSemanticsNodeMapper(colorStringFormatter) diff --git a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/reflection/ComposeReflection.kt b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/reflection/ComposeReflection.kt index 8415298ce9..1a9536cda8 100644 --- a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/reflection/ComposeReflection.kt +++ b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/reflection/ComposeReflection.kt @@ -40,6 +40,10 @@ internal object ComposeReflection { val TextStringSimpleElement = getClassSafe("androidx.compose.foundation.text.modifiers.TextStringSimpleElement") val ColorProducerField = TextStringSimpleElement?.getDeclaredFieldSafe("color") + + val BackgroundElementClass = getClassSafe("androidx.compose.foundation.BackgroundElement") + val ColorField = BackgroundElementClass?.getDeclaredFieldSafe("color") + val ShapeField = BackgroundElementClass?.getDeclaredFieldSafe("shape") } internal fun Field.accessible(): Field { diff --git a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/SemanticsUtils.kt b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/SemanticsUtils.kt index b7792534b7..98f5dbc737 100644 --- a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/SemanticsUtils.kt +++ b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/SemanticsUtils.kt @@ -6,13 +6,19 @@ package com.datadog.android.sessionreplay.compose.internal.utils +import android.annotation.SuppressLint import android.view.View +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composition +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Size import androidx.compose.ui.semantics.SemanticsNode import androidx.compose.ui.semantics.SemanticsOwner +import androidx.compose.ui.unit.Density import com.datadog.android.sessionreplay.compose.internal.reflection.ComposeReflection import com.datadog.android.sessionreplay.compose.internal.reflection.ComposeReflection.CompositionField import com.datadog.android.sessionreplay.compose.internal.reflection.getSafe +import com.datadog.android.sessionreplay.utils.GlobalBounds internal class SemanticsUtils { @@ -28,4 +34,33 @@ internal class SemanticsUtils { } return null } + + internal fun resolveSemanticsModifierColor( + semanticsNode: SemanticsNode + ): Long? { + val modifier = resolveSemanticsModifier(semanticsNode) + return ComposeReflection.ColorField?.getSafe(modifier) as? Long + } + + internal fun resolveSemanticsModifierCornerRadius( + semanticsNode: SemanticsNode, + globalBounds: GlobalBounds, + density: Density + ): Float? { + val size = Size(globalBounds.width.toFloat(), globalBounds.height.toFloat()) + val modifier = resolveSemanticsModifier(semanticsNode) + val shape = ComposeReflection.ShapeField?.getSafe(modifier) as? RoundedCornerShape + return shape?.let { + // We only have a single value for corner radius, so we default to using the + // top left (i.e.: topStart) corner's value and apply it to all corners + it.topStart.toPx(size, density) / density.density + } + } + + @SuppressLint("ModifierFactoryExtensionFunction") + internal fun resolveSemanticsModifier(semanticsNode: SemanticsNode): Modifier? { + return semanticsNode.layoutInfo.getModifierInfo().firstOrNull { + ComposeReflection.BackgroundElementClass?.isInstance(it.modifier) == true + }?.modifier + } } diff --git a/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/ButtonSemanticsNodeMapperTest.kt b/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/ButtonSemanticsNodeMapperTest.kt new file mode 100644 index 0000000000..dc4acbf5da --- /dev/null +++ b/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/ButtonSemanticsNodeMapperTest.kt @@ -0,0 +1,116 @@ +/* + * 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.mappers.semantics + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.SemanticsNode +import com.datadog.android.sessionreplay.compose.internal.data.UiContext +import com.datadog.android.sessionreplay.compose.internal.utils.SemanticsUtils +import com.datadog.android.sessionreplay.compose.test.elmyr.SessionReplayComposeForgeConfigurator +import com.datadog.android.sessionreplay.model.MobileSegment +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.annotation.FloatForgery +import fr.xgouchet.elmyr.annotation.Forgery +import fr.xgouchet.elmyr.annotation.LongForgery +import fr.xgouchet.elmyr.annotation.StringForgery +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.Extensions +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(SessionReplayComposeForgeConfigurator::class) +internal class ButtonSemanticsNodeMapperTest : AbstractCompositionGroupMapperTest() { + + private lateinit var testedButtonSemanticsNodeMapper: ButtonSemanticsNodeMapper + + @Mock + private lateinit var mockSemanticsNode: SemanticsNode + + @Mock + private lateinit var mockSemanticsUtils: SemanticsUtils + + @LongForgery(min = 0L, max = 0xffffff) + var fakeBackgroundColor: Long = 0L + + @FloatForgery + var fakeCornerRadius: Float = 0f + + @StringForgery(regex = "#[0-9A-F]{8}") + lateinit var fakeBackgroundColorHexString: String + + @Forgery + lateinit var fakeUiContext: UiContext + + @BeforeEach + override fun `set up`(forge: Forge) { + super.`set up`(forge) + mockColorStringFormatter(fakeBackgroundColor, fakeBackgroundColorHexString) + + testedButtonSemanticsNodeMapper = ButtonSemanticsNodeMapper( + colorStringFormatter = mockColorStringFormatter, + semanticsUtils = mockSemanticsUtils + ) + } + + private fun mockSemanticsNode(): SemanticsNode { + return mockSemanticsNodeWithBound { + whenever(mockSemanticsNode.layoutInfo).doReturn(mockLayoutInfo) + } + } + + @Test + fun `M return the correct wireframe W map`() { + // Given + val mockSemanticsNode = mockSemanticsNode() + whenever(mockSemanticsUtils.resolveSemanticsModifierColor(mockSemanticsNode)).thenReturn( + Color(fakeBackgroundColor).value.toLong() + ) + whenever( + mockSemanticsUtils.resolveSemanticsModifierCornerRadius( + eq(mockSemanticsNode), + any(), + eq(mockDensity) + ) + ).thenReturn(fakeCornerRadius) + + // When + val actual = testedButtonSemanticsNodeMapper.map( + mockSemanticsNode, + fakeUiContext + ) + + // Then + val expected = MobileSegment.Wireframe.ShapeWireframe( + id = fakeSemanticsId.toLong(), + x = (fakeBounds.left / fakeDensity).toLong(), + y = (fakeBounds.top / fakeDensity).toLong(), + width = (fakeBounds.size.width / fakeDensity).toLong(), + height = (fakeBounds.size.height / fakeDensity).toLong(), + shapeStyle = MobileSegment.ShapeStyle( + backgroundColor = fakeBackgroundColorHexString, + cornerRadius = fakeCornerRadius + ) + ) + assertThat(actual.wireframe).isEqualTo(expected) + } +}