Skip to content

Commit

Permalink
Merge pull request #2296 from DataDog/yl/compose/semantics-button-role
Browse files Browse the repository at this point in the history
RUM-6194: Add Semantics Mapper for Button role
  • Loading branch information
ambushwork authored Oct 1, 2024
2 parents c37edce + afe85c6 commit 4f6ed19
Show file tree
Hide file tree
Showing 8 changed files with 229 additions and 8 deletions.
1 change: 1 addition & 0 deletions detekt_custom.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
<fields>;
}
-keepclassmembers class androidx.compose.ui.platform.AbstractComposeView {
<fields>;
}
-keepclassmembers class androidx.compose.ui.platform.AndroidComposeView {
<fields>;
}

-keepclassmembers class androidx.compose.foundation.text.modifiers.TextStringSimpleElement {
<fields>;
}
-keepclassmembers class androidx.compose.foundation.BackgroundElement {
<fields>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ internal class SemanticsWireframeMapper(
private val semanticsUtils: SemanticsUtils = SemanticsUtils(),
private val semanticsNodeMapper: Map<Role, SemanticsNodeMapper> = 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand All @@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}

0 comments on commit 4f6ed19

Please sign in to comment.