diff --git a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/RootSemanticsNodeMapper.kt b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/RootSemanticsNodeMapper.kt index e38b3d5dad..cb55af5ff3 100644 --- a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/RootSemanticsNodeMapper.kt +++ b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/RootSemanticsNodeMapper.kt @@ -27,12 +27,12 @@ internal class RootSemanticsNodeMapper( private val colorStringFormatter: ColorStringFormatter, 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, semanticsUtils), - Role.Checkbox to CheckboxSemanticsNodeMapper(colorStringFormatter, semanticsUtils) + Role.Checkbox to CheckboxSemanticsNodeMapper(colorStringFormatter, semanticsUtils), + Role.Switch to SwitchSemanticsNodeMapper(colorStringFormatter, semanticsUtils) ), // Text doesn't have a role in semantics, so it should be a fallback mapper. private val textSemanticsNodeMapper: TextSemanticsNodeMapper = TextSemanticsNodeMapper( diff --git a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/SwitchSemanticsNodeMapper.kt b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/SwitchSemanticsNodeMapper.kt new file mode 100644 index 0000000000..942a8e4c5c --- /dev/null +++ b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/SwitchSemanticsNodeMapper.kt @@ -0,0 +1,162 @@ +/* + * 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 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.SemanticsUtils +import com.datadog.android.sessionreplay.model.MobileSegment +import com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback +import com.datadog.android.sessionreplay.utils.ColorStringFormatter +import com.datadog.android.sessionreplay.utils.GlobalBounds + +internal class SwitchSemanticsNodeMapper( + colorStringFormatter: ColorStringFormatter, + semanticsUtils: SemanticsUtils = SemanticsUtils() +) : AbstractSemanticsNodeMapper(colorStringFormatter, semanticsUtils) { + override fun map( + semanticsNode: SemanticsNode, + parentContext: UiContext, + asyncJobStatusCallback: AsyncJobStatusCallback + ): SemanticsWireframe { + val isSwitchOn = isSwitchOn(semanticsNode) + val globalBounds = resolveBounds(semanticsNode) + + val switchWireframes = if (isSwitchMasked(parentContext)) { + listOf( + resolveMaskedWireframes( + semanticsNode = semanticsNode, + globalBounds = globalBounds, + wireframeIndex = 0 + ) + ) + } else { + val trackWireframe = createTrackWireframe( + semanticsNode = semanticsNode, + globalBounds = globalBounds, + wireframeIndex = 0, + isSwitchOn = isSwitchOn + ) + + val thumbWireframe = createThumbWireframe( + semanticsNode = semanticsNode, + globalBounds = globalBounds, + wireframeIndex = 1, + isSwitchOn = isSwitchOn + ) + + listOfNotNull(trackWireframe, thumbWireframe) + } + + return SemanticsWireframe( + uiContext = null, + wireframes = switchWireframes + ) + } + + private fun createTrackWireframe( + semanticsNode: SemanticsNode, + globalBounds: GlobalBounds, + wireframeIndex: Int, + isSwitchOn: Boolean + ): MobileSegment.Wireframe { + val trackColor = if (isSwitchOn) { + DEFAULT_COLOR_BLACK + } else { + DEFAULT_COLOR_WHITE + } + + @Suppress("MagicNumber") + return MobileSegment.Wireframe.ShapeWireframe( + resolveId(semanticsNode, wireframeIndex), + x = globalBounds.x, + y = globalBounds.y + (globalBounds.height / 4), + width = TRACK_WIDTH_DP, + height = THUMB_DIAMETER_DP.toLong() / 2, + shapeStyle = MobileSegment.ShapeStyle( + cornerRadius = CORNER_RADIUS_DP, + backgroundColor = trackColor + ), + border = MobileSegment.ShapeBorder( + color = DEFAULT_COLOR_BLACK, + width = BORDER_WIDTH_DP + ) + ) + } + + private fun createThumbWireframe( + semanticsNode: SemanticsNode, + globalBounds: GlobalBounds, + wireframeIndex: Int, + isSwitchOn: Boolean + ): MobileSegment.Wireframe { + val xPosition = if (!isSwitchOn) { + globalBounds.x + } else { + globalBounds.x + globalBounds.width - THUMB_DIAMETER_DP + } + + @Suppress("MagicNumber") + val yPosition = globalBounds.y + (globalBounds.height / 4) - (THUMB_DIAMETER_DP / 4) + + val thumbColor = if (!isSwitchOn) { + DEFAULT_COLOR_WHITE + } else { + DEFAULT_COLOR_BLACK + } + + return MobileSegment.Wireframe.ShapeWireframe( + resolveId(semanticsNode, wireframeIndex), + x = xPosition, + y = yPosition, + width = THUMB_DIAMETER_DP.toLong(), + height = THUMB_DIAMETER_DP.toLong(), + shapeStyle = MobileSegment.ShapeStyle( + cornerRadius = CORNER_RADIUS_DP, + backgroundColor = thumbColor + ), + border = MobileSegment.ShapeBorder( + color = DEFAULT_COLOR_BLACK, + width = BORDER_WIDTH_DP + ) + ) + } + + private fun isSwitchOn(semanticsNode: SemanticsNode): Boolean = + semanticsNode.config.getOrNull(SemanticsProperties.ToggleableState) == ToggleableState.On + + private fun isSwitchMasked(parentContext: UiContext): Boolean = + parentContext.textAndInputPrivacy != TextAndInputPrivacy.MASK_SENSITIVE_INPUTS + + private fun resolveMaskedWireframes( + semanticsNode: SemanticsNode, + globalBounds: GlobalBounds, + wireframeIndex: Int + ): MobileSegment.Wireframe { + // TODO RUM-5118: Decide how to display masked, currently use empty track, + return createTrackWireframe( + semanticsNode = semanticsNode, + globalBounds = globalBounds, + wireframeIndex = wireframeIndex, + isSwitchOn = false + ) + } + + 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" + } +} diff --git a/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/RootSemanticsNodeMapperTest.kt b/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/RootSemanticsNodeMapperTest.kt index 30928b6934..4db5816222 100644 --- a/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/RootSemanticsNodeMapperTest.kt +++ b/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/RootSemanticsNodeMapperTest.kt @@ -75,6 +75,9 @@ class RootSemanticsNodeMapperTest { @Mock private lateinit var mockCheckboxSemanticsNodeMapper: CheckboxSemanticsNodeMapper + @Mock + private lateinit var mockSwitchSemanticsNodeMapper: SwitchSemanticsNodeMapper + @Mock private lateinit var mockComposeHiddenMapper: ComposeHiddenMapper @@ -99,7 +102,8 @@ class RootSemanticsNodeMapperTest { Role.Tab to mockTabSemanticsNodeMapper, Role.Button to mockButtonSemanticsNodeMapper, Role.Image to mockImageSemanticsNodeMapper, - Role.Checkbox to mockCheckboxSemanticsNodeMapper + Role.Checkbox to mockCheckboxSemanticsNodeMapper, + Role.Switch to mockSwitchSemanticsNodeMapper ), textSemanticsNodeMapper = mockTextSemanticsNodeMapper, containerSemanticsNodeMapper = mockContainerSemanticsNodeMapper, @@ -171,6 +175,27 @@ class RootSemanticsNodeMapperTest { ) } + @Test + fun `M use SwitchSemanticsNodeMapper W createComposeWireframes { role is Switch }`() { + // Given + val mockSemanticsNode = mockSemanticsNode(Role.Switch) + + // When + testedRootSemanticsNodeMapper.createComposeWireframes( + mockSemanticsNode, + fakeMappingContext.systemInformation.screenDensity, + fakeMappingContext, + mockAsyncJobStatusCallback + ) + + // Then + verify(mockSwitchSemanticsNodeMapper).map( + eq(mockSemanticsNode), + any(), + eq(mockAsyncJobStatusCallback) + ) + } + @Test fun `M use TabSemanticsNodeMapper W createComposeWireframes { role is Tab }`() { // Given diff --git a/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/SwitchSemanticsNodeMapperTest.kt b/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/SwitchSemanticsNodeMapperTest.kt new file mode 100644 index 0000000000..1a5a74a6a6 --- /dev/null +++ b/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/SwitchSemanticsNodeMapperTest.kt @@ -0,0 +1,240 @@ +/* + * 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 androidx.compose.ui.state.ToggleableState +import com.datadog.android.sessionreplay.TextAndInputPrivacy +import com.datadog.android.sessionreplay.compose.internal.data.UiContext +import com.datadog.android.sessionreplay.compose.internal.mappers.semantics.SwitchSemanticsNodeMapper.Companion.BORDER_WIDTH_DP +import com.datadog.android.sessionreplay.compose.internal.mappers.semantics.SwitchSemanticsNodeMapper.Companion.CORNER_RADIUS_DP +import com.datadog.android.sessionreplay.compose.internal.mappers.semantics.SwitchSemanticsNodeMapper.Companion.DEFAULT_COLOR_BLACK +import com.datadog.android.sessionreplay.compose.internal.mappers.semantics.SwitchSemanticsNodeMapper.Companion.DEFAULT_COLOR_WHITE +import com.datadog.android.sessionreplay.compose.internal.mappers.semantics.SwitchSemanticsNodeMapper.Companion.THUMB_DIAMETER_DP +import com.datadog.android.sessionreplay.compose.internal.mappers.semantics.SwitchSemanticsNodeMapper.Companion.TRACK_WIDTH_DP +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 com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback +import com.datadog.android.sessionreplay.utils.ColorStringFormatter +import com.datadog.android.sessionreplay.utils.GlobalBounds +import fr.xgouchet.elmyr.annotation.Forgery +import fr.xgouchet.elmyr.annotation.IntForgery +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 SwitchSemanticsNodeMapperTest { + private lateinit var testedMapper: SwitchSemanticsNodeMapper + + @Forgery + lateinit var fakeGlobalBounds: GlobalBounds + + @Mock + lateinit var mockColorStringFormatter: ColorStringFormatter + + @Mock + lateinit var mockSemanticsNode: SemanticsNode + + @Mock + lateinit var mockSemanticsUtils: SemanticsUtils + + @Mock + lateinit var mockParentContext: UiContext + + @Mock + lateinit var mockConfig: SemanticsConfiguration + + @IntForgery + var fakeSemanticsId: Int = 0 + + @BeforeEach + fun `set up`() { + whenever(mockSemanticsNode.id) doReturn fakeSemanticsId + whenever(mockSemanticsUtils.resolveInnerBounds(mockSemanticsNode)) doReturn fakeGlobalBounds + whenever(mockSemanticsNode.config).thenReturn(mockConfig) + whenever(mockParentContext.textAndInputPrivacy).thenReturn(TextAndInputPrivacy.MASK_SENSITIVE_INPUTS) + + testedMapper = SwitchSemanticsNodeMapper( + colorStringFormatter = mockColorStringFormatter, + semanticsUtils = mockSemanticsUtils + ) + } + + @Test + fun `M return correct wireframe W map { switch off }`( + @Mock mockAsyncJobStatusCallback: AsyncJobStatusCallback + ) { + // Given + whenever(mockConfig.getOrNull(SemanticsProperties.ToggleableState)) + .thenReturn(ToggleableState.Off) + + val expectedTrackWireframe = MobileSegment.Wireframe.ShapeWireframe( + id = fakeSemanticsId.toLong() shl 32, + x = fakeGlobalBounds.x, + y = fakeGlobalBounds.y + (fakeGlobalBounds.height / 4), + width = TRACK_WIDTH_DP, + height = THUMB_DIAMETER_DP.toLong() / 2, + shapeStyle = MobileSegment.ShapeStyle( + backgroundColor = DEFAULT_COLOR_WHITE, + cornerRadius = CORNER_RADIUS_DP + ), + border = MobileSegment.ShapeBorder( + color = DEFAULT_COLOR_BLACK, + width = BORDER_WIDTH_DP + ) + ) + + val expectedThumbWireframe = MobileSegment.Wireframe.ShapeWireframe( + id = (fakeSemanticsId.toLong() shl 32) + 1, + x = fakeGlobalBounds.x, + y = fakeGlobalBounds.y + (fakeGlobalBounds.height / 4) - (THUMB_DIAMETER_DP / 4), + width = THUMB_DIAMETER_DP.toLong(), + height = THUMB_DIAMETER_DP.toLong(), + shapeStyle = MobileSegment.ShapeStyle( + backgroundColor = DEFAULT_COLOR_WHITE, + cornerRadius = CORNER_RADIUS_DP + ), + border = MobileSegment.ShapeBorder( + color = DEFAULT_COLOR_BLACK, + width = BORDER_WIDTH_DP + ) + ) + + // When + val semanticsWireframe = testedMapper.map( + semanticsNode = mockSemanticsNode, + parentContext = mockParentContext, + asyncJobStatusCallback = mockAsyncJobStatusCallback + ) + + // Then + assertThat(semanticsWireframe.wireframes).hasSize(2) + val trackWireframe = semanticsWireframe.wireframes[0] as MobileSegment.Wireframe.ShapeWireframe + val thumbWireframe = semanticsWireframe.wireframes[1] as MobileSegment.Wireframe.ShapeWireframe + + assertThat(trackWireframe).isEqualTo(expectedTrackWireframe) + assertThat(thumbWireframe).isEqualTo(expectedThumbWireframe) + } + + @Test + fun `M return correct wireframe W map { switch on }`( + @Mock mockAsyncJobStatusCallback: AsyncJobStatusCallback + ) { + // Given + whenever(mockConfig.getOrNull(SemanticsProperties.ToggleableState)) + .thenReturn(ToggleableState.On) + + val expectedTrackWireframe = MobileSegment.Wireframe.ShapeWireframe( + id = fakeSemanticsId.toLong() shl 32, + x = fakeGlobalBounds.x, + y = fakeGlobalBounds.y + (fakeGlobalBounds.height / 4), + width = TRACK_WIDTH_DP, + height = THUMB_DIAMETER_DP.toLong() / 2, + shapeStyle = MobileSegment.ShapeStyle( + backgroundColor = DEFAULT_COLOR_BLACK, + cornerRadius = CORNER_RADIUS_DP + ), + border = MobileSegment.ShapeBorder( + color = DEFAULT_COLOR_BLACK, + width = BORDER_WIDTH_DP + ) + ) + + val expectedThumbWireframe = MobileSegment.Wireframe.ShapeWireframe( + id = (fakeSemanticsId.toLong() shl 32) + 1, + x = fakeGlobalBounds.x + fakeGlobalBounds.width - THUMB_DIAMETER_DP, + y = fakeGlobalBounds.y + (fakeGlobalBounds.height / 4) - (THUMB_DIAMETER_DP / 4), + width = THUMB_DIAMETER_DP.toLong(), + height = THUMB_DIAMETER_DP.toLong(), + shapeStyle = MobileSegment.ShapeStyle( + backgroundColor = DEFAULT_COLOR_BLACK, + cornerRadius = CORNER_RADIUS_DP + ), + border = MobileSegment.ShapeBorder( + color = DEFAULT_COLOR_BLACK, + width = BORDER_WIDTH_DP + ) + ) + + // When + val semanticsWireframe = testedMapper.map( + semanticsNode = mockSemanticsNode, + parentContext = mockParentContext, + asyncJobStatusCallback = mockAsyncJobStatusCallback + ) + + // Then + assertThat(semanticsWireframe.wireframes).hasSize(2) + val trackWireframe = semanticsWireframe.wireframes[0] as MobileSegment.Wireframe.ShapeWireframe + val thumbWireframe = semanticsWireframe.wireframes[1] as MobileSegment.Wireframe.ShapeWireframe + + assertThat(trackWireframe).isEqualTo(expectedTrackWireframe) + assertThat(thumbWireframe).isEqualTo(expectedThumbWireframe) + } + + @Test + fun `M return empty track W map { masked }`( + @Mock mockAsyncJobStatusCallback: AsyncJobStatusCallback + ) { + // Given + whenever(mockConfig.getOrNull(SemanticsProperties.ToggleableState)) + .thenReturn(ToggleableState.On) + + whenever(mockParentContext.textAndInputPrivacy).thenReturn( + TextAndInputPrivacy.MASK_ALL_INPUTS + ) + + val expectedTrackWireframe = MobileSegment.Wireframe.ShapeWireframe( + id = fakeSemanticsId.toLong() shl 32, + x = fakeGlobalBounds.x, + y = fakeGlobalBounds.y + (fakeGlobalBounds.height / 4), + width = TRACK_WIDTH_DP, + height = THUMB_DIAMETER_DP.toLong() / 2, + shapeStyle = MobileSegment.ShapeStyle( + backgroundColor = DEFAULT_COLOR_WHITE, + cornerRadius = CORNER_RADIUS_DP + ), + border = MobileSegment.ShapeBorder( + color = DEFAULT_COLOR_BLACK, + width = BORDER_WIDTH_DP + ) + ) + + // When + val semanticsWireframe = testedMapper.map( + semanticsNode = mockSemanticsNode, + parentContext = mockParentContext, + asyncJobStatusCallback = mockAsyncJobStatusCallback + ) + + // Then + assertThat(semanticsWireframe.wireframes).hasSize(1) + val trackWireframe = semanticsWireframe.wireframes[0] as MobileSegment.Wireframe.ShapeWireframe + + assertThat(trackWireframe).isEqualTo(expectedTrackWireframe) + } +}