Skip to content

Commit

Permalink
RUM-7341: Add Compose FGM override API
Browse files Browse the repository at this point in the history
  • Loading branch information
ambushwork committed Nov 27, 2024
1 parent 4590058 commit 29ade0e
Show file tree
Hide file tree
Showing 14 changed files with 482 additions and 11 deletions.
4 changes: 4 additions & 0 deletions features/dd-sdk-android-session-replay-compose/api/apiSurface
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,7 @@ class com.datadog.android.sessionreplay.compose.ComposeExtensionSupport : com.da
override fun getCustomDrawableMapper(): List<com.datadog.android.sessionreplay.utils.DrawableToColorMapper>
override fun name(): String
annotation com.datadog.android.sessionreplay.compose.ExperimentalSessionReplayApi
fun androidx.compose.ui.Modifier.sessionReplayHide(Boolean): androidx.compose.ui.Modifier
fun androidx.compose.ui.Modifier.sessionReplayImagePrivacy(com.datadog.android.sessionreplay.ImagePrivacy): androidx.compose.ui.Modifier
fun androidx.compose.ui.Modifier.sessionReplayTextAndInputPrivacy(com.datadog.android.sessionreplay.TextAndInputPrivacy): androidx.compose.ui.Modifier
fun androidx.compose.ui.Modifier.sessionReplayTouchPrivacy(com.datadog.android.sessionreplay.TouchPrivacy): androidx.compose.ui.Modifier
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,10 @@ public final class com/datadog/android/sessionreplay/compose/ComposeExtensionSup
public abstract interface annotation class com/datadog/android/sessionreplay/compose/ExperimentalSessionReplayApi : java/lang/annotation/Annotation {
}

public final class com/datadog/android/sessionreplay/compose/internal/ModifierExtKt {
public static final fun sessionReplayHide (Landroidx/compose/ui/Modifier;Z)Landroidx/compose/ui/Modifier;
public static final fun sessionReplayImagePrivacy (Landroidx/compose/ui/Modifier;Lcom/datadog/android/sessionreplay/ImagePrivacy;)Landroidx/compose/ui/Modifier;
public static final fun sessionReplayTextAndInputPrivacy (Landroidx/compose/ui/Modifier;Lcom/datadog/android/sessionreplay/TextAndInputPrivacy;)Landroidx/compose/ui/Modifier;
public static final fun sessionReplayTouchPrivacy (Landroidx/compose/ui/Modifier;Lcom/datadog/android/sessionreplay/TouchPrivacy;)Landroidx/compose/ui/Modifier;
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/*
* 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

import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.SemanticsPropertyKey
import androidx.compose.ui.semantics.SemanticsPropertyReceiver
import androidx.compose.ui.semantics.semantics
import com.datadog.android.sessionreplay.ImagePrivacy
import com.datadog.android.sessionreplay.TextAndInputPrivacy
import com.datadog.android.sessionreplay.TouchPrivacy

/**
* Allows setting a view to be "hidden" in the hierarchy in Session Replay.
*
* @param hide pass `true` to hide the composable, or `false` to remove the override
*/
fun Modifier.sessionReplayHide(hide: Boolean): Modifier {
return this.semantics {
this.hide = hide
}
}

/**
* Allows overriding the image privacy for a view in Session Replay.
*
* @param imagePrivacy the new privacy level to use for the composable.
*/
fun Modifier.sessionReplayImagePrivacy(imagePrivacy: ImagePrivacy): Modifier {
return this.semantics {
this.imagePrivacy = imagePrivacy
}
}

/**
* Allows overriding the text and input privacy for a view in Session Replay.
*
* @param textAndInputPrivacy the new privacy level to use for the composable.
*/
fun Modifier.sessionReplayTextAndInputPrivacy(textAndInputPrivacy: TextAndInputPrivacy): Modifier {
return this.semantics {
this.textInputPrivacy = textAndInputPrivacy
}
}

/**
* Allows overriding the touch privacy for a view in Session Replay.
*
* @param touchPrivacy the new privacy level to use for the composable
* or null to remove the override.
*/
fun Modifier.sessionReplayTouchPrivacy(touchPrivacy: TouchPrivacy): Modifier {
return this.semantics {
this.touchPrivacy = touchPrivacy
}
}

private val SessionReplayHidePropertyKey: SemanticsPropertyKey<Boolean> = SemanticsPropertyKey(
name = "_dd_session_replay_hide"
)

internal val ImagePrivacySemanticsPropertyKey: SemanticsPropertyKey<ImagePrivacy> =
SemanticsPropertyKey(
name = "_dd_session_replay_image_privacy"
)

internal val TextInputSemanticsPropertyKey: SemanticsPropertyKey<TextAndInputPrivacy> =
SemanticsPropertyKey(
name = "_dd_session_replay_text_input_privacy"
)

internal val TouchSemanticsPropertyKey: SemanticsPropertyKey<TouchPrivacy> = SemanticsPropertyKey(
name = "_dd_session_replay_touch_privacy"
)

private var SemanticsPropertyReceiver.hide by SessionReplayHidePropertyKey
private var SemanticsPropertyReceiver.imagePrivacy by ImagePrivacySemanticsPropertyKey
private var SemanticsPropertyReceiver.textInputPrivacy by TextInputSemanticsPropertyKey
private var SemanticsPropertyReceiver.touchPrivacy by TouchSemanticsPropertyKey
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,16 @@ internal class ContainerSemanticsNodeMapper(
val backgroundColor = semanticsUtils.resolveBackgroundColor(semanticsNode)?.let {
convertColor(it)
}
val textAndInputPrivacy = semanticsUtils.getTextAndInputPrivacyOverride(semanticsNode)
?: parentContext.textAndInputPrivacy
val imagePrivacy = semanticsUtils.getImagePrivacyOverride(semanticsNode)
?: parentContext.imagePrivacy
return SemanticsWireframe(
wireframes = wireframes,
uiContext = parentContext.copy(
parentContentColor = backgroundColor ?: parentContext.parentContentColor
parentContentColor = backgroundColor ?: parentContext.parentContentColor,
imagePrivacy = imagePrivacy,
textAndInputPrivacy = textAndInputPrivacy
)
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,13 @@ import com.datadog.android.sessionreplay.compose.internal.reflection.ComposeRefl
import com.datadog.android.sessionreplay.compose.internal.reflection.ComposeReflection.PainterFieldOfAsyncImagePainter
import com.datadog.android.sessionreplay.compose.internal.reflection.ComposeReflection.PainterFieldOfContentPainter
import com.datadog.android.sessionreplay.compose.internal.reflection.getSafe
import com.datadog.android.sessionreplay.compose.internal.utils.SemanticsUtils
import com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback
import com.datadog.android.sessionreplay.utils.ColorStringFormatter

internal class ImageSemanticsNodeMapper(
colorStringFormatter: ColorStringFormatter
colorStringFormatter: ColorStringFormatter,
private val semanticsUtils: SemanticsUtils
) : AbstractSemanticsNodeMapper(colorStringFormatter) {

override fun map(
Expand All @@ -37,14 +39,16 @@ internal class ImageSemanticsNodeMapper(
val bounds = resolveBounds(semanticsNode)
val bitmapInfo = resolveSemanticsPainter(semanticsNode)
val containerFrames = resolveModifierWireframes(semanticsNode).toMutableList()
val imagePrivacy =
semanticsUtils.getImagePrivacyOverride(semanticsNode) ?: parentContext.imagePrivacy
val imageWireframe = if (bitmapInfo != null) {
parentContext.imageWireframeHelper.createImageWireframeByBitmap(
id = semanticsNode.id.toLong(),
globalBounds = bounds,
bitmap = bitmapInfo.bitmap,
density = parentContext.density,
isContextualImage = bitmapInfo.isContextualImage,
imagePrivacy = parentContext.imagePrivacy,
imagePrivacy = imagePrivacy,
asyncJobStatusCallback = asyncJobStatusCallback,
clipping = null,
shapeStyle = null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ internal class RootSemanticsNodeMapper(
Role.RadioButton to RadioButtonSemanticsNodeMapper(colorStringFormatter, semanticsUtils),
Role.Tab to TabSemanticsNodeMapper(colorStringFormatter, semanticsUtils),
Role.Button to ButtonSemanticsNodeMapper(colorStringFormatter, semanticsUtils),
Role.Image to ImageSemanticsNodeMapper(colorStringFormatter)
Role.Image to ImageSemanticsNodeMapper(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
Expand Up @@ -10,6 +10,7 @@ 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.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
Expand All @@ -29,11 +30,14 @@ internal class TextFieldSemanticsNodeMapper(
asyncJobStatusCallback: AsyncJobStatusCallback
): SemanticsWireframe {
var index = 0
val privacy = semanticsUtils.getTextAndInputPrivacyOverride(semanticsNode)
?: parentContext.textAndInputPrivacy
val shapeWireframe = resolveTextFieldShapeWireframe(parentContext, semanticsNode, index++)
val editTextWireframe = resolveEditTextWireframe(parentContext, semanticsNode, index)
val editTextWireframe =
resolveEditTextWireframe(parentContext, semanticsNode, privacy, index)
return SemanticsWireframe(
wireframes = listOfNotNull(shapeWireframe, editTextWireframe),
uiContext = null
uiContext = parentContext.copy(textAndInputPrivacy = privacy)
)
}

Expand Down Expand Up @@ -68,11 +72,12 @@ internal class TextFieldSemanticsNodeMapper(
private fun resolveEditTextWireframe(
parentContext: UiContext,
semanticsNode: SemanticsNode,
textAndInputPrivacy: TextAndInputPrivacy,
index: Int
): MobileSegment.Wireframe? {
val globalBounds = semanticsUtils.resolveInnerBounds(semanticsNode)
val editText = resolveEditText(semanticsNode.config)?.let {
transformCapturedText(it, parentContext.textAndInputPrivacy, true)
transformCapturedText(it, textAndInputPrivacy, true)
}
val textLayoutInfo = semanticsUtils.resolveTextLayoutInfo(semanticsNode)
val textStyle = textLayoutInfo?.let {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
package com.datadog.android.sessionreplay.compose.internal.mappers.semantics

import androidx.compose.ui.semantics.SemanticsNode
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
Expand All @@ -25,20 +26,23 @@ internal open class TextSemanticsNodeMapper(
parentContext: UiContext,
asyncJobStatusCallback: AsyncJobStatusCallback
): SemanticsWireframe {
val textWireframe = resolveTextWireFrame(parentContext, semanticsNode)
val textAndInputPrivacy = semanticsUtils.getTextAndInputPrivacyOverride(semanticsNode)
?: parentContext.textAndInputPrivacy
val textWireframe = resolveTextWireFrame(parentContext, semanticsNode, textAndInputPrivacy)
return SemanticsWireframe(
wireframes = listOfNotNull(textWireframe),
parentContext
parentContext.copy(textAndInputPrivacy = textAndInputPrivacy)
)
}

protected fun resolveTextWireFrame(
parentContext: UiContext,
semanticsNode: SemanticsNode
semanticsNode: SemanticsNode,
textAndInputPrivacy: TextAndInputPrivacy
): MobileSegment.Wireframe.TextWireframe? {
val textLayoutInfo = semanticsUtils.resolveTextLayoutInfo(semanticsNode)
val capturedText = textLayoutInfo?.text?.let {
transformCapturedText(it, parentContext.textAndInputPrivacy)
transformCapturedText(it, textAndInputPrivacy)
}
val bounds = resolveBounds(semanticsNode)
return capturedText?.let { text ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ import androidx.compose.ui.semantics.getOrNull
import androidx.compose.ui.text.TextLayoutInput
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.unit.Density
import com.datadog.android.sessionreplay.ImagePrivacy
import com.datadog.android.sessionreplay.TextAndInputPrivacy
import com.datadog.android.sessionreplay.TouchPrivacy
import com.datadog.android.sessionreplay.compose.internal.ImagePrivacySemanticsPropertyKey
import com.datadog.android.sessionreplay.compose.internal.TextInputSemanticsPropertyKey
import com.datadog.android.sessionreplay.compose.internal.TouchSemanticsPropertyKey
import com.datadog.android.sessionreplay.compose.internal.mappers.semantics.TextLayoutInfo
import com.datadog.android.sessionreplay.utils.GlobalBounds

Expand Down Expand Up @@ -209,4 +215,16 @@ internal class SemanticsUtils(private val reflectionUtils: ReflectionUtils = Ref
fontFamily = layoutInput.style.fontFamily
)
}

internal fun getImagePrivacyOverride(semanticsNode: SemanticsNode): ImagePrivacy? {
return semanticsNode.config.getOrNull(ImagePrivacySemanticsPropertyKey)
}

internal fun getTextAndInputPrivacyOverride(semanticsNode: SemanticsNode): TextAndInputPrivacy? {
return semanticsNode.config.getOrNull(TextInputSemanticsPropertyKey)
}

internal fun getTouchPrivacyOverride(semanticsNode: SemanticsNode): TouchPrivacy? {
return semanticsNode.config.getOrNull(TouchSemanticsPropertyKey)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
package com.datadog.android.sessionreplay.compose.internal.mappers.semantics

import androidx.compose.ui.semantics.SemanticsNode
import com.datadog.android.sessionreplay.ImagePrivacy
import com.datadog.android.sessionreplay.TextAndInputPrivacy
import com.datadog.android.sessionreplay.compose.internal.data.UiContext
import com.datadog.android.sessionreplay.compose.internal.utils.BackgroundInfo
import com.datadog.android.sessionreplay.compose.test.elmyr.SessionReplayComposeForgeConfigurator
Expand Down Expand Up @@ -108,6 +110,27 @@ internal class ContainerSemanticsNodeMapperTest : AbstractSemanticsNodeMapperTes
)
}

@Test
fun `M pass down the override privacy W privacy is override`(forge: Forge) {
// Given
val fakeImagePrivacy = forge.aValueFrom(ImagePrivacy::class.java)
val fakeTextInputPrivacy = forge.aValueFrom(TextAndInputPrivacy::class.java)
val mockSemanticsNode = mockSemanticsNode()
whenever(mockSemanticsUtils.getImagePrivacyOverride(mockSemanticsNode)) doReturn fakeImagePrivacy
whenever(mockSemanticsUtils.getTextAndInputPrivacyOverride(mockSemanticsNode)) doReturn fakeTextInputPrivacy

// When
val result = testedContainerSemanticsNodeMapper.map(
mockSemanticsNode,
fakeUiContext,
mockAsyncJobStatusCallback
)

// Then
assertThat(result.uiContext?.imagePrivacy).isEqualTo(fakeImagePrivacy)
assertThat(result.uiContext?.textAndInputPrivacy).isEqualTo(fakeTextInputPrivacy)
}

private fun mockSemanticsNode(): SemanticsNode {
return mockSemanticsNodeWithBound {}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,47 @@ internal class TextFieldSemanticsNodeMapperTest : AbstractSemanticsNodeMapperTes
assertThat(actual.wireframes).contains(expectedShapeWireframe, expectedTextWireframe)
}

@Test
fun `M pass down the override privacy W privacy is override`(forge: Forge) {
// Given
val mockSemanticsNode = mockSemanticsNodeWithBound {}
val fakeTextInputPrivacy = forge.aValueFrom(TextAndInputPrivacy::class.java)
val innerBounds = rectToBounds(fakeBounds, fakeDensity)
whenever(mockSemanticsNode.config) doReturn mockSemanticsConfiguration
whenever(mockSemanticsConfiguration.getOrNull(SemanticsProperties.EditableText)) doReturn AnnotatedString(
fakeEditText
)
whenever(mockSemanticsUtils.resolveTextLayoutInfo(mockSemanticsNode)) doReturn fakeTextLayoutInfo
whenever(mockSemanticsUtils.resolveInnerBounds(mockSemanticsNode)) doReturn innerBounds
whenever(mockSemanticsUtils.resolveBackgroundColor(mockSemanticsNode)) doReturn fakeBackgroundColor
whenever(mockSemanticsUtils.resolveBackgroundShape(mockSemanticsNode)) doReturn mockShape
whenever(
mockSemanticsUtils.resolveCornerRadius(
eq(mockShape),
any(),
any()
)
) doReturn fakeCornerRadius
whenever(
mockSemanticsUtils.resolveCornerRadius(
mockShape,
fakeGlobalBounds,
mockDensity
)
) doReturn fakeCornerRadius
whenever(mockSemanticsUtils.getTextAndInputPrivacyOverride(mockSemanticsNode)) doReturn fakeTextInputPrivacy

// When
val result = testedTextFieldSemanticsNodeMapper.map(
mockSemanticsNode,
fakeUiContext,
mockAsyncJobStatusCallback
)

// Then
assertThat(result.uiContext?.textAndInputPrivacy).isEqualTo(fakeTextInputPrivacy)
}

private fun mockSemanticsNode(): SemanticsNode {
return mockSemanticsNodeWithBound {
whenever(mockSemanticsNode.layoutInfo).doReturn(mockLayoutInfo)
Expand Down
Loading

0 comments on commit 29ade0e

Please sign in to comment.