Skip to content

Commit

Permalink
RUM-6219: Add image and text_and_input overrides
Browse files Browse the repository at this point in the history
  • Loading branch information
jonathanmos committed Oct 13, 2024
1 parent 715c7b1 commit 9a60bdf
Show file tree
Hide file tree
Showing 8 changed files with 251 additions and 5 deletions.
2 changes: 2 additions & 0 deletions features/dd-sdk-android-session-replay/api/apiSurface
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ data class com.datadog.android.sessionreplay.MapperTypeWrapper<T: android.view.V
fun supportsView(android.view.View): Boolean
fun getUnsafeMapper(): com.datadog.android.sessionreplay.recorder.mapper.WireframeMapper<android.view.View>
fun android.view.View.setSessionReplayHidden(Boolean)
fun android.view.View.datadogSessionReplayImagePrivacy(ImagePrivacy?)
fun android.view.View.datadogSessionReplayTextAndInputPrivacy(TextAndInputPrivacy?)
object com.datadog.android.sessionreplay.SessionReplay
fun enable(SessionReplayConfiguration, com.datadog.android.api.SdkCore = Datadog.getInstance())
fun startRecording(com.datadog.android.api.SdkCore = Datadog.getInstance())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ public final class com/datadog/android/sessionreplay/MapperTypeWrapper {
}

public final class com/datadog/android/sessionreplay/PrivacyOverrideExtensionsKt {
public static final fun datadogSessionReplayImagePrivacy (Landroid/view/View;Lcom/datadog/android/sessionreplay/ImagePrivacy;)V
public static final fun datadogSessionReplayTextAndInputPrivacy (Landroid/view/View;Lcom/datadog/android/sessionreplay/TextAndInputPrivacy;)V
public static final fun setSessionReplayHidden (Landroid/view/View;Z)V
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,31 @@ fun View.setSessionReplayHidden(hide: Boolean) {
this.setTag(R.id.datadog_hidden, null)
}
}

/**
* Allows overriding the image privacy for a view in Session Replay.
*
* @param privacy the new privacy level to use for the view
* or null to remove the override.
*/
fun View.datadogSessionReplayImagePrivacy(privacy: ImagePrivacy?) {
if (privacy == null) {
this.setTag(R.id.datadog_image_privacy, null)
} else {
this.setTag(R.id.datadog_image_privacy, privacy.toString())
}
}

/**
* Allows overriding the text and input privacy for a view in Session Replay.
*
* @param privacy the new privacy level to use for the view
* or null to remove the override.
*/
fun View.datadogSessionReplayTextAndInputPrivacy(privacy: TextAndInputPrivacy?) {
if (privacy == null) {
this.setTag(R.id.datadog_text_and_input_privacy, null)
} else {
this.setTag(R.id.datadog_text_and_input_privacy, privacy.toString())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,8 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder {
),
ComposedOptionSelectorDetector(
customOptionSelectorDetectors + DefaultOptionSelectorDetector()
)
),
internalLogger = internalLogger
),
recordedDataQueueHandler = recordedDataQueueHandler,
sdkCore = sdkCore,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ package com.datadog.android.sessionreplay.internal.recorder
import android.view.View
import android.view.ViewGroup
import androidx.annotation.UiThread
import com.datadog.android.api.InternalLogger
import com.datadog.android.sessionreplay.ImagePrivacy
import com.datadog.android.sessionreplay.R
import com.datadog.android.sessionreplay.TextAndInputPrivacy
import com.datadog.android.sessionreplay.internal.async.RecordedDataQueueRefs
import com.datadog.android.sessionreplay.model.MobileSegment
Expand All @@ -22,7 +24,8 @@ import java.util.LinkedList
internal class SnapshotProducer(
private val imageWireframeHelper: ImageWireframeHelper,
private val treeViewTraversal: TreeViewTraversal,
private val optionSelectorDetector: OptionSelectorDetector
private val optionSelectorDetector: OptionSelectorDetector,
private val internalLogger: InternalLogger
) {

@UiThread
Expand Down Expand Up @@ -55,7 +58,8 @@ internal class SnapshotProducer(
recordedDataQueueRefs: RecordedDataQueueRefs
): Node? {
return withinSRBenchmarkSpan(view::class.java.simpleName, view is ViewGroup) {
val traversedTreeView = treeViewTraversal.traverse(view, mappingContext, recordedDataQueueRefs)
val localMappingContext = resolvePrivacyOverrides(view, mappingContext)
val traversedTreeView = treeViewTraversal.traverse(view, localMappingContext, recordedDataQueueRefs)
val nextTraversalStrategy = traversedTreeView.nextActionStrategy
val resolvedWireframes = traversedTreeView.mappedWireframes
if (nextTraversalStrategy == TraversalStrategy.STOP_AND_DROP_NODE) {
Expand All @@ -70,7 +74,7 @@ internal class SnapshotProducer(
view.childCount > 0 &&
nextTraversalStrategy == TraversalStrategy.TRAVERSE_ALL_CHILDREN
) {
val childMappingContext = resolveChildMappingContext(view, mappingContext)
val childMappingContext = resolveChildMappingContext(view, localMappingContext)
val parentsCopy = LinkedList(parents).apply { addAll(resolvedWireframes) }
for (i in 0 until view.childCount) {
val viewChild = view.getChildAt(i) ?: continue
Expand All @@ -97,4 +101,50 @@ internal class SnapshotProducer(
parentMappingContext
}
}

private fun resolvePrivacyOverrides(view: View, mappingContext: MappingContext): MappingContext {
val imagePrivacy =
try {
val privacy = view.getTag(R.id.datadog_image_privacy) as? String
if (privacy == null) {
mappingContext.imagePrivacy
} else {
ImagePrivacy.valueOf(privacy)
}
} catch (e: IllegalArgumentException) {
logInvalidPrivacyLevelError(e)
mappingContext.imagePrivacy
}

val textAndInputPrivacy =
try {
val privacy = view.getTag(R.id.datadog_text_and_input_privacy) as? String
if (privacy == null) {
mappingContext.textAndInputPrivacy
} else {
TextAndInputPrivacy.valueOf(privacy)
}
} catch (e: IllegalArgumentException) {
logInvalidPrivacyLevelError(e)
mappingContext.textAndInputPrivacy
}

return mappingContext.copy(
imagePrivacy = imagePrivacy,
textAndInputPrivacy = textAndInputPrivacy
)
}

private fun logInvalidPrivacyLevelError(e: Exception) {
internalLogger.log(
InternalLogger.Level.ERROR,
listOf(InternalLogger.Target.USER, InternalLogger.Target.TELEMETRY),
{ INVALID_PRIVACY_LEVEL_ERROR },
e
)
}

internal companion object {
internal const val INVALID_PRIVACY_LEVEL_ERROR = "Invalid privacy level"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,6 @@

<resources>
<item name="datadog_hidden" type="id"/>
<item name="datadog_image_privacy" type="id"/>
<item name="datadog_text_and_input_privacy" type="id"/>
</resources>
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ package com.datadog.android.sessionreplay

import android.view.View
import com.datadog.android.sessionreplay.forge.ForgeConfigurator
import fr.xgouchet.elmyr.Forge
import fr.xgouchet.elmyr.junit5.ForgeConfiguration
import fr.xgouchet.elmyr.junit5.ForgeExtension
import org.junit.jupiter.api.Test
Expand Down Expand Up @@ -52,4 +53,58 @@ internal class PrivacyOverrideExtensionsTest {
// Then
verify(mockView).setTag(eq(R.id.datadog_hidden), isNull())
}

@Test
fun `M set tag W datadogSessionReplayImagePrivacy() { with privacy }`(
forge: Forge
) {
// Given
val mockView = mock<View>()
val mockPrivacy = forge.aValueFrom(ImagePrivacy::class.java)

// When
mockView.datadogSessionReplayImagePrivacy(mockPrivacy)

// Then
verify(mockView).setTag(eq(R.id.datadog_image_privacy), eq(mockPrivacy.toString()))
}

@Test
fun `M set tag to null W datadogSessionReplayImagePrivacy() { privacy is null }`() {
// Given
val mockView = mock<View>()

// When
mockView.datadogSessionReplayImagePrivacy(null)

// Then
verify(mockView).setTag(eq(R.id.datadog_image_privacy), isNull())
}

@Test
fun `M set tag W datadogSessionReplayTextAndInputPrivacy() { with privacy }`(
forge: Forge
) {
// Given
val mockView = mock<View>()
val mockPrivacy = forge.aValueFrom(TextAndInputPrivacy::class.java)

// When
mockView.datadogSessionReplayTextAndInputPrivacy(mockPrivacy)

// Then
verify(mockView).setTag(eq(R.id.datadog_text_and_input_privacy), eq(mockPrivacy.toString()))
}

@Test
fun `M set tag to null W datadogSessionReplayTextAndInputPrivacy() { privacy is null }`() {
// Given
val mockView = mock<View>()

// When
mockView.datadogSessionReplayTextAndInputPrivacy(null)

// Then
verify(mockView).setTag(eq(R.id.datadog_text_and_input_privacy), isNull())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,15 @@ package com.datadog.android.sessionreplay.internal.recorder

import android.view.View
import android.view.ViewGroup
import com.datadog.android.api.InternalLogger
import com.datadog.android.sessionreplay.ImagePrivacy
import com.datadog.android.sessionreplay.R
import com.datadog.android.sessionreplay.TextAndInputPrivacy
import com.datadog.android.sessionreplay.datadogSessionReplayImagePrivacy
import com.datadog.android.sessionreplay.datadogSessionReplayTextAndInputPrivacy
import com.datadog.android.sessionreplay.forge.ForgeConfigurator
import com.datadog.android.sessionreplay.internal.async.RecordedDataQueueRefs
import com.datadog.android.sessionreplay.internal.recorder.SnapshotProducer.Companion.INVALID_PRIVACY_LEVEL_ERROR
import com.datadog.android.sessionreplay.model.MobileSegment
import com.datadog.android.sessionreplay.recorder.MappingContext
import com.datadog.android.sessionreplay.recorder.SystemInformation
Expand Down Expand Up @@ -60,6 +65,9 @@ internal class SnapshotProducerTest {
@Mock
lateinit var mockImageWireframeHelper: ImageWireframeHelper

@Mock
lateinit var mockInternalLogger: InternalLogger

@Forgery
lateinit var fakeSystemInformation: SystemInformation

Expand All @@ -77,7 +85,8 @@ internal class SnapshotProducerTest {
testedSnapshotProducer = SnapshotProducer(
mockImageWireframeHelper,
mockTreeViewTraversal,
mockOptionSelectorDetector
mockOptionSelectorDetector,
mockInternalLogger
)
}

Expand Down Expand Up @@ -366,6 +375,103 @@ internal class SnapshotProducerTest {
assertThat(snapshot).isEqualTo(expectedSnapshot)
}

@Test
fun `M apply override privacy to parent and children W produce()`(
forge: Forge
) {
// Given
val mockChildren: List<View> = forge.aList { mock() }
val mockRoot: ViewGroup = mock { root ->
whenever(root.childCount).thenReturn(mockChildren.size)
whenever(root.getChildAt(any())).thenAnswer { mockChildren[it.getArgument(0)] }
}
val fakeImagePrivacy = forge.aValueFrom(ImagePrivacy::class.java)
val fakeTextAndInputPrivacy = forge.aValueFrom(TextAndInputPrivacy::class.java)
mockRoot.datadogSessionReplayImagePrivacy(fakeImagePrivacy)
mockRoot.datadogSessionReplayTextAndInputPrivacy(fakeTextAndInputPrivacy)
val fakeTraversedTreeView = TreeViewTraversal.TraversedTreeView(
fakeViewWireframes,
TraversalStrategy.TRAVERSE_ALL_CHILDREN
)
whenever(mockTreeViewTraversal.traverse(any(), any(), any()))
.thenReturn(fakeTraversedTreeView)
.thenReturn(
fakeTraversedTreeView.copy(
nextActionStrategy =
TraversalStrategy.STOP_AND_DROP_NODE
)
)

// When
testedSnapshotProducer.produce(
mockRoot,
fakeSystemInformation,
fakeTextAndInputPrivacy,
fakeImagePrivacy,
mockRecordedDataQueueRefs
)

// Then
val argumentCaptor = argumentCaptor<MappingContext>()
verify(mockTreeViewTraversal, times(1 + mockChildren.size))
.traverse(any(), argumentCaptor.capture(), any())
argumentCaptor.allValues.forEach {
assertThat(it.imagePrivacy).isEqualTo(fakeImagePrivacy)
assertThat(it.textAndInputPrivacy).isEqualTo(fakeTextAndInputPrivacy)
}
}

@Test
fun `M log invalid privacy level W produce() { invalid override tag value }`(
forge: Forge
) {
// Given
val mockChildren: List<View> = forge.aList { mock() }
val mockRoot: ViewGroup = mock { root ->
whenever(root.childCount).thenReturn(mockChildren.size)
whenever(root.getChildAt(any())).thenAnswer { mockChildren[it.getArgument(0)] }
whenever(root.getTag(R.id.datadog_image_privacy)).thenReturn("arglblargl")
whenever(root.getTag(R.id.datadog_text_and_input_privacy)).thenReturn("arglblargl")
}
val fakeImagePrivacy = forge.aValueFrom(ImagePrivacy::class.java)
val fakeTextAndInputPrivacy = forge.aValueFrom(TextAndInputPrivacy::class.java)

val fakeTraversedTreeView = TreeViewTraversal.TraversedTreeView(
fakeViewWireframes,
TraversalStrategy.TRAVERSE_ALL_CHILDREN
)
whenever(mockTreeViewTraversal.traverse(any(), any(), any()))
.thenReturn(fakeTraversedTreeView)
.thenReturn(
fakeTraversedTreeView.copy(
nextActionStrategy =
TraversalStrategy.STOP_AND_DROP_NODE
)
)

// When
testedSnapshotProducer.produce(
mockRoot,
fakeSystemInformation,
fakeTextAndInputPrivacy,
fakeImagePrivacy,
mockRecordedDataQueueRefs
)

// Then
argumentCaptor<() -> String> {
verify(mockInternalLogger, times(2)).log(
eq(InternalLogger.Level.ERROR),
eq(listOf(InternalLogger.Target.USER, InternalLogger.Target.TELEMETRY)),
capture(),
any(),
eq(false),
eq(null)
)
assertThat(lastValue.invoke()).isEqualTo(INVALID_PRIVACY_LEVEL_ERROR)
}
}

// region Internals

private fun View.toNode(
Expand Down

0 comments on commit 9a60bdf

Please sign in to comment.