Skip to content

Commit

Permalink
RUM-6189: Add Abstract and Text semantics mapper for compose SR
Browse files Browse the repository at this point in the history
  • Loading branch information
ambushwork committed Sep 27, 2024
1 parent 6de40db commit 1ec890c
Show file tree
Hide file tree
Showing 16 changed files with 1,329 additions and 4 deletions.
7 changes: 7 additions & 0 deletions detekt_custom.yml
Original file line number Diff line number Diff line change
Expand Up @@ -503,8 +503,11 @@ datadog:
- "androidx.compose.ui.graphics.Color(kotlin.Long)"
- "androidx.compose.ui.graphics.Color.toArgb()"
- "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.copy(kotlin.Float, kotlin.Float)"
- "androidx.compose.ui.text.AnnotatedString.getStringAnnotations(kotlin.Int, kotlin.Int)"
- "androidx.compose.ui.semantics.SemanticsConfiguration.getOrNull(androidx.compose.ui.semantics.SemanticsPropertyKey)"
- "androidx.core.view.GestureDetectorCompat.constructor(android.content.Context?, android.view.GestureDetector.OnGestureListener?)"
- "androidx.core.view.GestureDetectorCompat.onTouchEvent(android.view.MotionEvent?)"
- "androidx.fragment.app.FragmentManager.FragmentLifecycleCallbacks.onFragmentActivityCreated(androidx.fragment.app.FragmentManager, androidx.fragment.app.Fragment, android.os.Bundle?)"
Expand Down Expand Up @@ -815,7 +818,9 @@ datadog:
- "kotlin.collections.arrayListOf()"
- "kotlin.collections.ArrayList(kotlin.collections.MutableCollection?)"
- "kotlin.collections.Collection.flatten()"
- "kotlin.collections.Collection.forEach(kotlin.Function1)"
- "kotlin.collections.Collection.isNotEmpty()"
- "kotlin.collections.listOf()"
- "kotlin.collections.Collection.sumOf(kotlin.Function1)"
- "kotlin.collections.Collection.withIndex()"
- "kotlin.collections.HashMap()"
Expand Down Expand Up @@ -959,6 +964,7 @@ datadog:
- "kotlin.collections.MutableList.toSet()"
- "kotlin.collections.MutableList.toTypedArray()"
- "kotlin.collections.MutableList.withIndex()"
- "kotlin.collections.MutableList.firstOrNull()"
- "kotlin.collections.MutableMap.clear()"
- "kotlin.collections.MutableMap.containsKey(android.view.Window)"
- "kotlin.collections.MutableMap.containsKey(com.datadog.android.api.SdkCore)"
Expand Down Expand Up @@ -1125,6 +1131,7 @@ datadog:
- "kotlin.Number.toFloat()"
- "kotlin.Number.toLong()"
- "kotlin.Short.toUShort()"
- "kotlin.ULong.toLong()"
- "kotlin.UShort.toShort()"
- "kotlin.math.abs(kotlin.Float)"
- "kotlin.math.max(kotlin.Double, kotlin.Double)"
Expand Down
471 changes: 471 additions & 0 deletions features/dd-sdk-android-rum/api/dd-sdk-android-rum.api

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ package com.datadog.android.sessionreplay.compose
import androidx.compose.ui.platform.ComposeView
import com.datadog.android.sessionreplay.ExtensionSupport
import com.datadog.android.sessionreplay.MapperTypeWrapper
import com.datadog.android.sessionreplay.compose.internal.mappers.ComposeWireframeMapper
import com.datadog.android.sessionreplay.compose.internal.mappers.semantics.SemanticsWireframeMapper
import com.datadog.android.sessionreplay.recorder.OptionSelectorDetector
import com.datadog.android.sessionreplay.utils.ColorStringFormatter
import com.datadog.android.sessionreplay.utils.DefaultColorStringFormatter
Expand All @@ -34,7 +34,7 @@ class ComposeExtensionSupport : ExtensionSupport {
return listOf(
MapperTypeWrapper(
ComposeView::class.java,
ComposeWireframeMapper(
SemanticsWireframeMapper(
viewIdentifierResolver,
colorStringFormatter,
viewBoundsResolver,
Expand Down
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
}
}
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?
}
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)
}
}
}
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
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,17 @@ internal object ComposeReflection {

val RecomposeScopeImplClass = getClassSafe("androidx.compose.runtime.RecomposeScopeImpl")
val RecomposeScopeImplBlockField = RecomposeScopeImplClass?.getDeclaredFieldSafe("block")

val AbstractComposeViewClass = getClassSafe("androidx.compose.ui.platform.AbstractComposeView")
val CompositionField = AbstractComposeViewClass?.getDeclaredFieldSafe("composition")

val OwnerField = WrappedCompositionClass?.getDeclaredFieldSafe("owner")

val AndroidComposeViewClass = getClassSafe("androidx.compose.ui.platform.AndroidComposeView")
val SemanticsOwner = AndroidComposeViewClass?.getDeclaredFieldSafe("semanticsOwner")

val TextStringSimpleElement = getClassSafe("androidx.compose.foundation.text.modifiers.TextStringSimpleElement")
val ColorProducerField = TextStringSimpleElement?.getDeclaredFieldSafe("color")
}

internal fun Field.accessible(): Field {
Expand Down
Loading

0 comments on commit 1ec890c

Please sign in to comment.