-
Notifications
You must be signed in to change notification settings - Fork 63
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
RUM-6189: Add Abstract and Text semantics mapper for compose SR
- Loading branch information
1 parent
6de40db
commit b7b556c
Showing
17 changed files
with
1,350 additions
and
4 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
55 changes: 55 additions & 0 deletions
55
...g/android/sessionreplay/compose/internal/mappers/semantics/AbstractSemanticsNodeMapper.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,55 @@ | ||
/* | ||
* 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.graphics.toArgb | ||
import androidx.compose.ui.semantics.SemanticsNode | ||
import com.datadog.android.sessionreplay.utils.ColorStringFormatter | ||
import com.datadog.android.sessionreplay.utils.GlobalBounds | ||
import kotlin.math.roundToInt | ||
|
||
internal abstract class AbstractSemanticsNodeMapper( | ||
private val colorStringFormatter: ColorStringFormatter | ||
) : SemanticsNodeMapper { | ||
|
||
protected fun resolveBound(semanticsNode: SemanticsNode): GlobalBounds { | ||
val rect = semanticsNode.boundsInRoot | ||
val density = semanticsNode.layoutInfo.density.density | ||
val width = ((rect.right - rect.left) / density).toLong() | ||
val height = ((rect.bottom - rect.top) / density).toLong() | ||
val x = (rect.left / density).toLong() | ||
val y = (rect.top / density).toLong() | ||
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 | ||
} else { | ||
val c = Color(color shr COMPOSE_COLOR_SHIFT) | ||
colorStringFormatter.formatColorAndAlphaAsHexString( | ||
c.toArgb(), | ||
(c.alpha * MAX_ALPHA).roundToInt() | ||
) | ||
} | ||
} | ||
|
||
companion object { | ||
/** As defined in Compose's ColorSpaces. */ | ||
private const val UNSPECIFIED_COLOR = 16L | ||
private const val COMPOSE_COLOR_SHIFT = 32 | ||
private const val MAX_ALPHA = 255 | ||
} | ||
} |
19 changes: 19 additions & 0 deletions
19
...m/datadog/android/sessionreplay/compose/internal/mappers/semantics/SemanticsNodeMapper.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
/* | ||
* 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 com.datadog.android.sessionreplay.compose.internal.data.ComposeWireframe | ||
import com.datadog.android.sessionreplay.compose.internal.data.UiContext | ||
|
||
internal interface SemanticsNodeMapper { | ||
|
||
fun map( | ||
semanticsNode: SemanticsNode, | ||
parentContext: UiContext | ||
): ComposeWireframe? | ||
} |
96 changes: 96 additions & 0 deletions
96
...adog/android/sessionreplay/compose/internal/mappers/semantics/SemanticsWireframeMapper.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,96 @@ | ||
/* | ||
* 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.platform.ComposeView | ||
import androidx.compose.ui.semantics.Role | ||
import androidx.compose.ui.semantics.SemanticsNode | ||
import androidx.compose.ui.semantics.SemanticsProperties | ||
import androidx.compose.ui.semantics.getOrNull | ||
import com.datadog.android.api.InternalLogger | ||
import com.datadog.android.sessionreplay.SessionReplayPrivacy | ||
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.recorder.MappingContext | ||
import com.datadog.android.sessionreplay.recorder.mapper.BaseWireframeMapper | ||
import com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback | ||
import com.datadog.android.sessionreplay.utils.ColorStringFormatter | ||
import com.datadog.android.sessionreplay.utils.DrawableToColorMapper | ||
import com.datadog.android.sessionreplay.utils.ViewBoundsResolver | ||
import com.datadog.android.sessionreplay.utils.ViewIdentifierResolver | ||
|
||
internal class SemanticsWireframeMapper( | ||
viewIdentifierResolver: ViewIdentifierResolver, | ||
colorStringFormatter: ColorStringFormatter, | ||
viewBoundsResolver: ViewBoundsResolver, | ||
drawableToColorMapper: DrawableToColorMapper, | ||
private val semanticsUtils: SemanticsUtils = SemanticsUtils(), | ||
private val semanticsNodeMapper: Map<Role, SemanticsNodeMapper> = mapOf( | ||
// TODO RUM-6189 Add Mappers for each Semantics Role | ||
), | ||
// Text doesn't have a role in semantics, so it should be a fallback mapper. | ||
private val textSemanticsNodeMapper: TextSemanticsNodeMapper = TextSemanticsNodeMapper(colorStringFormatter) | ||
) : BaseWireframeMapper<ComposeView>( | ||
viewIdentifierResolver, | ||
colorStringFormatter, | ||
viewBoundsResolver, | ||
drawableToColorMapper | ||
) { | ||
override fun map( | ||
view: ComposeView, | ||
mappingContext: MappingContext, | ||
asyncJobStatusCallback: AsyncJobStatusCallback, | ||
internalLogger: InternalLogger | ||
): List<MobileSegment.Wireframe> { | ||
val density = mappingContext.systemInformation.screenDensity.let { if (it == 0.0f) 1.0f else it } | ||
val privacy = mappingContext.privacy | ||
return semanticsUtils.findRootSemanticsNode(view)?.let { node -> | ||
createComposeWireframes(node, density, privacy) | ||
} ?: listOf() | ||
} | ||
|
||
private fun getSemanticsNodeMapper( | ||
semanticsNode: SemanticsNode | ||
): SemanticsNodeMapper { | ||
val role = semanticsNode.config.getOrNull(SemanticsProperties.Role) | ||
return semanticsNodeMapper[role] ?: textSemanticsNodeMapper | ||
} | ||
|
||
private fun createComposeWireframes( | ||
semanticsNode: SemanticsNode, | ||
density: Float, | ||
privacy: SessionReplayPrivacy | ||
): List<MobileSegment.Wireframe> { | ||
val wireframes = mutableListOf<MobileSegment.Wireframe>() | ||
createComposerWireframes( | ||
semanticsNode = semanticsNode, | ||
wireframes = wireframes, | ||
parentUiContext = UiContext( | ||
parentContentColor = null, | ||
density = density, | ||
privacy = privacy | ||
) | ||
) | ||
return wireframes | ||
} | ||
|
||
private fun createComposerWireframes( | ||
semanticsNode: SemanticsNode, | ||
wireframes: MutableList<MobileSegment.Wireframe>, | ||
parentUiContext: UiContext | ||
) { | ||
getSemanticsNodeMapper(semanticsNode) | ||
.map(semanticsNode, parentUiContext)?.wireframe?.let { | ||
wireframes.add(it) | ||
} | ||
val children = semanticsNode.children | ||
children.forEach { | ||
createComposerWireframes(it, wireframes, parentUiContext) | ||
} | ||
} | ||
} |
148 changes: 148 additions & 0 deletions
148
...tadog/android/sessionreplay/compose/internal/mappers/semantics/TextSemanticsNodeMapper.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,148 @@ | ||
/* | ||
* 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.graphics.ColorProducer | ||
import androidx.compose.ui.semantics.SemanticsActions | ||
import androidx.compose.ui.semantics.SemanticsConfiguration | ||
import androidx.compose.ui.semantics.SemanticsNode | ||
import androidx.compose.ui.semantics.getOrNull | ||
import androidx.compose.ui.text.AnnotatedString | ||
import androidx.compose.ui.text.TextLayoutResult | ||
import androidx.compose.ui.text.TextStyle | ||
import androidx.compose.ui.text.font.GenericFontFamily | ||
import androidx.compose.ui.text.style.TextAlign | ||
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.reflection.ComposeReflection | ||
import com.datadog.android.sessionreplay.compose.internal.reflection.getSafe | ||
import com.datadog.android.sessionreplay.model.MobileSegment | ||
import com.datadog.android.sessionreplay.utils.ColorStringFormatter | ||
|
||
internal class TextSemanticsNodeMapper(colorStringFormatter: ColorStringFormatter) : | ||
AbstractSemanticsNodeMapper(colorStringFormatter) { | ||
override fun map(semanticsNode: SemanticsNode, parentContext: UiContext): ComposeWireframe { | ||
val text = resolveText(semanticsNode.config) | ||
val textStyle = resolveTextStyle(semanticsNode, parentContext) ?: defaultTextStyle | ||
val bounds = resolveBound(semanticsNode) | ||
return ComposeWireframe( | ||
MobileSegment.Wireframe.TextWireframe( | ||
id = semanticsNode.id.toLong(), | ||
x = bounds.x, | ||
y = bounds.y, | ||
width = bounds.width, | ||
height = bounds.height, | ||
text = text ?: "", | ||
textStyle = textStyle, | ||
textPosition = resolveTextAlign(semanticsNode) | ||
), | ||
null | ||
) | ||
} | ||
|
||
private fun resolveTextAlign(semanticsNode: SemanticsNode): MobileSegment.TextPosition? { | ||
return resolveSemanticsTextStyle(semanticsNode)?.let { | ||
val align = when (it.textAlign) { | ||
TextAlign.Start, | ||
TextAlign.Left -> MobileSegment.Horizontal.LEFT | ||
|
||
TextAlign.End, | ||
TextAlign.Right -> MobileSegment.Horizontal.RIGHT | ||
|
||
TextAlign.Justify, | ||
TextAlign.Center -> MobileSegment.Horizontal.CENTER | ||
|
||
else -> MobileSegment.Horizontal.LEFT | ||
} | ||
MobileSegment.TextPosition( | ||
alignment = MobileSegment.Alignment( | ||
horizontal = align | ||
) | ||
) | ||
} | ||
} | ||
|
||
private fun resolveTextStyle(semanticsNode: SemanticsNode, parentContext: UiContext): MobileSegment.TextStyle? { | ||
return resolveSemanticsTextStyle(semanticsNode)?.let { textStyle -> | ||
val color = resolveModifierColor(semanticsNode) ?: textStyle.color | ||
MobileSegment.TextStyle( | ||
family = when (val value = textStyle.fontFamily) { | ||
is GenericFontFamily -> value.name | ||
else -> DEFAULT_FONT_FAMILY | ||
}, | ||
size = textStyle.fontSize.value.toLong(), | ||
color = convertColor(color.value.toLong()) ?: parentContext.parentContentColor | ||
?: DEFAULT_TEXT_COLOR | ||
) | ||
} | ||
} | ||
|
||
private fun resolveModifierColor(semanticsNode: SemanticsNode): Color? { | ||
val modifier = semanticsNode.layoutInfo.getModifierInfo().firstOrNull { | ||
ComposeReflection.TextStringSimpleElement?.isInstance(it.modifier) ?: false | ||
}?.modifier | ||
modifier?.let { | ||
if (ComposeReflection.TextStringSimpleElement?.isInstance(it) == true) { | ||
val colorProducer = ComposeReflection.ColorProducerField?.getSafe(it) as? ColorProducer | ||
return colorProducer?.invoke() | ||
} | ||
} | ||
return null | ||
} | ||
|
||
private fun resolveSemanticsTextStyle(semanticsNode: SemanticsNode): TextStyle? { | ||
val textLayoutResults = mutableListOf<TextLayoutResult>() | ||
semanticsNode.config.getOrNull(SemanticsActions.GetTextLayoutResult)?.action?.invoke(textLayoutResults) | ||
return textLayoutResults.firstOrNull()?.layoutInput?.style | ||
} | ||
|
||
private fun resolveText(semanticsConfiguration: SemanticsConfiguration): String? { | ||
for ((key, value) in semanticsConfiguration) { | ||
if (key.name == KEY_CONFIG_TEXT) { | ||
return resolveAnnotatedString(value) | ||
} | ||
} | ||
return null | ||
} | ||
|
||
private fun resolveAnnotatedString(value: Any?): String { | ||
return if (value is AnnotatedString) { | ||
if (value.paragraphStyles.isEmpty() && | ||
value.spanStyles.isEmpty() && | ||
value.getStringAnnotations(0, value.text.length).isEmpty() | ||
) { | ||
value.text | ||
} else { | ||
// Save space if we there is text only in the object | ||
value.toString() | ||
} | ||
} else if (value is Collection<*>) { | ||
val sb = StringBuilder() | ||
value.forEach { | ||
resolveAnnotatedString(it).let { | ||
sb.append(it) | ||
} | ||
} | ||
sb.toString() | ||
} else { | ||
value.toString() | ||
} | ||
} | ||
|
||
companion object { | ||
private const val KEY_CONFIG_TEXT = "Text" | ||
private const val DEFAULT_FONT_FAMILY = "Roboto, sans-serif" | ||
private const val DEFAULT_TEXT_COLOR = "#000000FF" | ||
private const val DEFAULT_FONT_SIZE = 12L | ||
private val defaultTextStyle = MobileSegment.TextStyle( | ||
size = DEFAULT_FONT_SIZE, | ||
color = DEFAULT_TEXT_COLOR, | ||
family = DEFAULT_FONT_FAMILY | ||
) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.