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 72fa527554..2a382f8989 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 @@ -21,6 +21,12 @@ internal abstract class AbstractSemanticsNodeMapper( private val semanticsUtils: SemanticsUtils = SemanticsUtils() ) : SemanticsNodeMapper { + protected fun resolveId(semanticsNode: SemanticsNode, currentIndex: Int = 0): Long { + // Use semantics node intrinsic id as the higher endian of Long type and the index of + // the wireframe inside the node as the lower endian to generate a unique id. + return semanticsNode.id.toLong() shl SEMANTICS_ID_BIT_SHIFT + currentIndex + } + protected fun resolveBounds(semanticsNode: SemanticsNode): GlobalBounds { return semanticsUtils.resolveInnerBounds(semanticsNode) } @@ -65,5 +71,6 @@ internal abstract class AbstractSemanticsNodeMapper( private const val UNSPECIFIED_COLOR = 16L private const val COMPOSE_COLOR_SHIFT = 32 private const val MAX_ALPHA = 255 + private const val SEMANTICS_ID_BIT_SHIFT = 32 } } diff --git a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/RadioButtonSemanticsNodeMapper.kt b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/RadioButtonSemanticsNodeMapper.kt new file mode 100644 index 0000000000..6fc8302acd --- /dev/null +++ b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/RadioButtonSemanticsNodeMapper.kt @@ -0,0 +1,89 @@ +/* + * 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.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.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() +) : AbstractSemanticsNodeMapper( + colorStringFormatter, + semanticsUtils +) { + override fun map( + semanticsNode: SemanticsNode, + parentContext: UiContext, + asyncJobStatusCallback: AsyncJobStatusCallback + ): SemanticsWireframe { + var wireframeIndex = 0 + val boxWireframe = resolveBoxWireframe(semanticsNode, wireframeIndex++) + val dotWireframe = resolveDotWireframe(semanticsNode, wireframeIndex) + return SemanticsWireframe( + uiContext = null, + wireframes = listOfNotNull(boxWireframe, dotWireframe) + ) + } + + private fun resolveBoxWireframe( + semanticsNode: SemanticsNode, + wireframeIndex: Int + ): MobileSegment.Wireframe { + val globalBounds = resolveBounds(semanticsNode) + return MobileSegment.Wireframe.ShapeWireframe( + id = resolveId(semanticsNode, wireframeIndex), + x = globalBounds.x, + y = globalBounds.y, + width = globalBounds.width, + height = globalBounds.height, + shapeStyle = MobileSegment.ShapeStyle( + cornerRadius = globalBounds.width / 2 + ), + border = MobileSegment.ShapeBorder( + color = DEFAULT_COLOR_BLACK, + width = BOX_BORDER_WIDTH + ) + ) + } + + private fun resolveDotWireframe( + semanticsNode: SemanticsNode, + wireframeIndex: Int + ): MobileSegment.Wireframe? { + val selected = semanticsNode.config.getOrNull(SemanticsProperties.Selected) ?: false + val globalBounds = resolveBounds(semanticsNode) + return if (selected) { + MobileSegment.Wireframe.ShapeWireframe( + id = resolveId(semanticsNode, wireframeIndex), + x = globalBounds.x + DOT_PADDING_DP, + y = globalBounds.y + DOT_PADDING_DP, + width = globalBounds.width - DOT_PADDING_DP * 2, + height = globalBounds.height - DOT_PADDING_DP * 2, + shapeStyle = MobileSegment.ShapeStyle( + backgroundColor = DEFAULT_COLOR_BLACK, + cornerRadius = (globalBounds.width - DOT_PADDING_DP * 2) / 2 + ) + ) + } else { + null + } + } + + companion object { + private const val DOT_PADDING_DP = 4 + private const val DEFAULT_COLOR_BLACK = "#000000FF" + private const val BOX_BORDER_WIDTH = 1L + } +} 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 23b270cd58..c5d959460b 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.RadioButton to RadioButtonSemanticsNodeMapper(colorStringFormatter, semanticsUtils), Role.Tab to TabSemanticsNodeMapper(colorStringFormatter, semanticsUtils), Role.Button to ButtonSemanticsNodeMapper(colorStringFormatter, semanticsUtils), Role.Image to ImageSemanticsNodeMapper(colorStringFormatter) diff --git a/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/RadioButtonSemanticsNodeMapperTest.kt b/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/RadioButtonSemanticsNodeMapperTest.kt new file mode 100644 index 0000000000..31983b916f --- /dev/null +++ b/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/RadioButtonSemanticsNodeMapperTest.kt @@ -0,0 +1,163 @@ +/* + * 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.SemanticsConfiguration +import androidx.compose.ui.semantics.SemanticsNode +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import com.datadog.android.sessionreplay.compose.internal.data.UiContext +import com.datadog.android.sessionreplay.compose.test.elmyr.SessionReplayComposeForgeConfigurator +import com.datadog.android.sessionreplay.model.MobileSegment +import com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback +import fr.xgouchet.elmyr.Forge +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.doReturn +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 RadioButtonSemanticsNodeMapperTest : AbstractCompositionGroupMapperTest() { + + private lateinit var testedRadioButtonSemanticsNodeMapper: RadioButtonSemanticsNodeMapper + + @Mock + private lateinit var mockSemanticsConfig: SemanticsConfiguration + + @Mock + private lateinit var mockAsyncJobStatusCallback: AsyncJobStatusCallback + + @LongForgery(min = 0xffffffff) + var fakeBackgroundColor: Long = 0L + + @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) + + testedRadioButtonSemanticsNodeMapper = RadioButtonSemanticsNodeMapper( + colorStringFormatter = mockColorStringFormatter, + semanticsUtils = mockSemanticsUtils + ) + } + + @Test + fun `M return the box wireframe W map {selected = false}`() { + // Given + val mockSemanticsNode = mockSemanticsNode() + whenever(mockSemanticsNode.config) doReturn mockSemanticsConfig + whenever(mockSemanticsConfig.getOrNull(SemanticsProperties.Selected)) doReturn false + whenever(mockSemanticsUtils.resolveInnerBounds(mockSemanticsNode)) doReturn rectToBounds( + fakeBounds, + fakeDensity + ) + // When + val actual = testedRadioButtonSemanticsNodeMapper.map( + mockSemanticsNode, + fakeUiContext, + mockAsyncJobStatusCallback + ) + + // Then + val expected = MobileSegment.Wireframe.ShapeWireframe( + id = fakeSemanticsId.toLong() shl 32, + 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( + cornerRadius = (fakeBounds.size.width / fakeDensity).toLong() / 2 + ), + border = MobileSegment.ShapeBorder( + color = DEFAULT_COLOR_BLACK, + width = BOX_BORDER_WIDTH + ) + ) + assertThat(actual.wireframes).containsExactly(expected) + } + + @Test + fun `M return the box wireframe W map {selected = true}`() { + // Given + val mockSemanticsNode = mockSemanticsNode() + whenever(mockSemanticsNode.config) doReturn mockSemanticsConfig + whenever(mockSemanticsConfig.getOrNull(SemanticsProperties.Selected)) doReturn true + whenever(mockSemanticsUtils.resolveInnerBounds(mockSemanticsNode)) doReturn rectToBounds( + fakeBounds, + fakeDensity + ) + + // When + val actual = testedRadioButtonSemanticsNodeMapper.map( + mockSemanticsNode, + fakeUiContext, + mockAsyncJobStatusCallback + ) + + // Then + val boxFrame = MobileSegment.Wireframe.ShapeWireframe( + id = fakeSemanticsId.toLong() shl 32, + 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( + cornerRadius = (fakeBounds.size.width / fakeDensity).toLong() / 2 + ), + border = MobileSegment.ShapeBorder( + color = DEFAULT_COLOR_BLACK, + width = BOX_BORDER_WIDTH + ) + ) + + val dotFrame = MobileSegment.Wireframe.ShapeWireframe( + id = fakeSemanticsId.toLong() shl 32 + 1, + x = (fakeBounds.left / fakeDensity).toLong() + DOT_PADDING_DP, + y = (fakeBounds.top / fakeDensity).toLong() + DOT_PADDING_DP, + width = (fakeBounds.size.width / fakeDensity).toLong() - 2 * DOT_PADDING_DP, + height = (fakeBounds.size.height / fakeDensity).toLong() - 2 * DOT_PADDING_DP, + shapeStyle = MobileSegment.ShapeStyle( + backgroundColor = DEFAULT_COLOR_BLACK, + cornerRadius = ((fakeBounds.size.width / fakeDensity).toLong() - 2 * DOT_PADDING_DP) / 2 + ) + ) + assertThat(actual.wireframes).containsAll(listOf(boxFrame, dotFrame)) + } + + private fun mockSemanticsNode(): SemanticsNode { + return mockSemanticsNodeWithBound {} + } + + companion object { + private const val DOT_PADDING_DP = 4 + private const val DEFAULT_COLOR_BLACK = "#000000FF" + private const val BOX_BORDER_WIDTH = 1L + } +} diff --git a/sample/kotlin/src/main/kotlin/com/datadog/android/sample/compose/SampleSelectionScreen.kt b/sample/kotlin/src/main/kotlin/com/datadog/android/sample/compose/SampleSelectionScreen.kt index b611b4af0b..90b019c805 100644 --- a/sample/kotlin/src/main/kotlin/com/datadog/android/sample/compose/SampleSelectionScreen.kt +++ b/sample/kotlin/src/main/kotlin/com/datadog/android/sample/compose/SampleSelectionScreen.kt @@ -27,6 +27,7 @@ internal fun SampleSelectionScreen( onTypographyClicked: () -> Unit, onLegacyClicked: () -> Unit, onImageClicked: () -> Unit, + onToggleClicked: () -> Unit, onTabsClicked: () -> Unit ) { Column( @@ -46,6 +47,10 @@ internal fun SampleSelectionScreen( text = "Image Sample", onClick = onImageClicked ) + StyledButton( + text = "Toggle Buttons Sample", + onClick = onToggleClicked + ) StyledButton( text = "Tabs Sample", onClick = onTabsClicked @@ -81,6 +86,9 @@ internal fun NavGraphBuilder.selectionNavigation(navController: NavHostControlle onImageClicked = { navController.navigate(SampleScreen.Image.navigationRoute) }, + onToggleClicked = { + navController.navigate(SampleScreen.Toggle.navigationRoute) + }, onTabsClicked = { navController.navigate(SampleScreen.Tabs.navigationRoute) }, @@ -98,6 +106,10 @@ internal fun NavGraphBuilder.selectionNavigation(navController: NavHostControlle ImageSample() } + composable(SampleScreen.Toggle.navigationRoute) { + ToggleSample() + } + composable(SampleScreen.Tabs.navigationRoute) { TabsSample() } @@ -114,6 +126,7 @@ internal sealed class SampleScreen( object Root : SampleScreen(COMPOSE_ROOT) object Typography : SampleScreen("$COMPOSE_ROOT/typography") object Image : SampleScreen("$COMPOSE_ROOT/image") + object Toggle : SampleScreen("$COMPOSE_ROOT/toggle") object Tabs : SampleScreen("$COMPOSE_ROOT/tabs") object Legacy : SampleScreen("$COMPOSE_ROOT/legacy") @@ -131,6 +144,8 @@ private fun PreviewSampleSelectionScreen() { }, onImageClicked = { }, + onToggleClicked = { + }, onTypographyClicked = { }, onTabsClicked = { diff --git a/sample/kotlin/src/main/kotlin/com/datadog/android/sample/compose/ToggleSample.kt b/sample/kotlin/src/main/kotlin/com/datadog/android/sample/compose/ToggleSample.kt new file mode 100644 index 0000000000..47139b0fc9 --- /dev/null +++ b/sample/kotlin/src/main/kotlin/com/datadog/android/sample/compose/ToggleSample.kt @@ -0,0 +1,109 @@ +/* + * 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.sample.compose + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Checkbox +import androidx.compose.material.RadioButton +import androidx.compose.material.Switch +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +@Composable +internal fun ToggleSample() { + Column(modifier = Modifier.padding(16.dp)) { + SampleSwitch() + SampleRadioButton() + SampleCheckbox() + } +} + +@Composable +private fun SampleSwitch(modifier: Modifier = Modifier) { + var checked by remember { mutableStateOf(false) } + SampleButtonContainer(title = "Switch Button") { + Switch( + modifier = modifier, + checked = checked, + onCheckedChange = { + checked = it + } + ) + } +} + +@Composable +private fun SampleRadioButton(modifier: Modifier = Modifier) { + var selected by remember { mutableStateOf(false) } + SampleButtonContainer(title = "Radio Button") { + RadioButton( + modifier = modifier, + selected = selected, + onClick = { + selected = !selected + } + ) + } +} + +@Composable +private fun SampleCheckbox(modifier: Modifier = Modifier) { + var checked by remember { mutableStateOf(false) } + SampleButtonContainer(title = "Checkbox") { + Checkbox( + modifier = modifier, + checked = checked, + onCheckedChange = { + checked = it + } + ) + } +} + +@Composable +private fun SampleButtonContainer( + modifier: Modifier = Modifier, + title: String, + content: @Composable () -> Unit +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(4.dp) + .clip(RoundedCornerShape(4.dp)) + .background(color = Color.Gray), + verticalAlignment = Alignment.CenterVertically + ) { + content.invoke() + Text( + title, + color = Color.Black, + modifier = Modifier.padding(8.dp) + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun PreviewToggleSample() { + ToggleSample() +}