Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RUM-6199: Add Semantics mapper for Switch #2471

Merged
merged 1 commit into from
Jan 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,12 @@ internal class RootSemanticsNodeMapper(
private val colorStringFormatter: ColorStringFormatter,
private val semanticsUtils: SemanticsUtils = SemanticsUtils(),
private val semanticsNodeMapper: Map<Role, SemanticsNodeMapper> = 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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ class RootSemanticsNodeMapperTest {
@Mock
private lateinit var mockCheckboxSemanticsNodeMapper: CheckboxSemanticsNodeMapper

@Mock
private lateinit var mockSwitchSemanticsNodeMapper: SwitchSemanticsNodeMapper

@Mock
private lateinit var mockComposeHiddenMapper: ComposeHiddenMapper

Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Loading