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-6219: Add Image and TextAndInput privacy overrides #2312

Merged
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
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.setSessionReplayImagePrivacy(ImagePrivacy?)
fun android.view.View.setSessionReplayTextAndInputPrivacy(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 @@ -24,6 +24,8 @@ public final class com/datadog/android/sessionreplay/MapperTypeWrapper {

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

public final class com/datadog/android/sessionreplay/SessionReplay {
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.setSessionReplayImagePrivacy(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.setSessionReplayTextAndInputPrivacy(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 setSessionReplayImagePrivacy() { with privacy }`(
forge: Forge
) {
// Given
val mockView = mock<View>()
val mockPrivacy = forge.aValueFrom(ImagePrivacy::class.java)

// When
mockView.setSessionReplayImagePrivacy(mockPrivacy)

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

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

// When
mockView.setSessionReplayImagePrivacy(null)

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

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

// When
mockView.setSessionReplayTextAndInputPrivacy(mockPrivacy)

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

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

// When
mockView.setSessionReplayTextAndInputPrivacy(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,13 +8,18 @@ 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.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
import com.datadog.android.sessionreplay.setSessionReplayImagePrivacy
import com.datadog.android.sessionreplay.setSessionReplayTextAndInputPrivacy
import com.datadog.android.sessionreplay.utils.ImageWireframeHelper
import fr.xgouchet.elmyr.Forge
import fr.xgouchet.elmyr.annotation.Forgery
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.setSessionReplayImagePrivacy(fakeImagePrivacy)
mockRoot.setSessionReplayTextAndInputPrivacy(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