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

feat(replay): Add Mask/Unmask Containers for custom masking in hybrid SDKs #3881

Merged
merged 6 commits into from
Nov 18, 2024
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
- Android 15: Add support for 16KB page sizes ([#3620](https://github.com/getsentry/sentry-java/pull/3620))
- See https://developer.android.com/guide/practices/page-sizes for more details
- Session Replay: Add `beforeSendReplay` callback ([#3855](https://github.com/getsentry/sentry-java/pull/3855))
- Session Replay: Add support for masking/unmasking view containers ([#3881](https://github.com/getsentry/sentry-java/pull/3881))

### Fixes

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package io.sentry.android.replay.viewhierarchy
import android.annotation.TargetApi
import android.graphics.Rect
import android.view.View
import android.view.ViewParent
import android.widget.ImageView
import android.widget.TextView
import io.sentry.SentryOptions
Expand Down Expand Up @@ -261,13 +262,32 @@ sealed class ViewHierarchyNode(
return true
}

if (!this.isMaskContainer(options) &&
this.parent != null &&
this.parent.isUnmaskContainer(options)
) {
return false
}

if (this.javaClass.isAssignableFrom(options.experimental.sessionReplay.unmaskViewClasses)) {
return false
}

return this.javaClass.isAssignableFrom(options.experimental.sessionReplay.maskViewClasses)
}

private fun ViewParent.isUnmaskContainer(options: SentryOptions): Boolean {
val unmaskContainer =
options.experimental.sessionReplay.unmaskViewContainerClass ?: return false
return this.javaClass.name == unmaskContainer
}

private fun View.isMaskContainer(options: SentryOptions): Boolean {
val maskContainer =
options.experimental.sessionReplay.maskViewContainerClass ?: return false
return this.javaClass.name == maskContainer
}

fun fromView(view: View, parent: ViewHierarchyNode?, distance: Int, options: SentryOptions): ViewHierarchyNode {
val (isVisible, visibleRect) = view.isVisibleToUser()
val shouldMask = isVisible && view.shouldMask(options)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
package io.sentry.android.replay.viewhierarchy

import android.app.Activity
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.drawable.Drawable
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.LinearLayout.LayoutParams
import android.widget.TextView
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.sentry.SentryOptions
import io.sentry.android.replay.maskAllImages
import io.sentry.android.replay.maskAllText
import org.junit.runner.RunWith
import org.robolectric.Robolectric.buildActivity
import org.robolectric.annotation.Config
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue

@RunWith(AndroidJUnit4::class)
@Config(sdk = [30])
class ContainerMaskingOptionsTest {

@BeforeTest
fun setup() {
System.setProperty("robolectric.areWindowsMarkedVisible", "true")
}

@Test
fun `when maskAllText is set TextView in Unmask container is unmasked`() {
buildActivity(MaskingOptionsActivity::class.java).setup()

val options = SentryOptions().apply {
experimental.sessionReplay.maskAllText = true
experimental.sessionReplay.setUnmaskViewContainerClass(CustomUnmask::class.java.name)
}

val textNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textViewInUnmask!!, null, 0, options)
assertFalse(textNode.shouldMask)
}

@Test
fun `when maskAllImages is set ImageView in Unmask container is unmasked`() {
buildActivity(MaskingOptionsActivity::class.java).setup()

val options = SentryOptions().apply {
experimental.sessionReplay.maskAllImages = true
experimental.sessionReplay.setUnmaskViewContainerClass(CustomUnmask::class.java.name)
}

val imageNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.imageViewInUnmask!!, null, 0, options)
assertFalse(imageNode.shouldMask)
}

@Test
fun `MaskContainer is always masked`() {
buildActivity(MaskingOptionsActivity::class.java).setup()

val options = SentryOptions().apply {
experimental.sessionReplay.setMaskViewContainerClass(CustomMask::class.java.name)
}

val maskContainer = ViewHierarchyNode.fromView(MaskingOptionsActivity.maskWithChildren!!, null, 0, options)

assertTrue(maskContainer.shouldMask)
}

@Test
fun `when Views are in UnmaskContainer only direct children are unmasked`() {
buildActivity(MaskingOptionsActivity::class.java).setup()

val options = SentryOptions().apply {
experimental.sessionReplay.addMaskViewClass(CustomView::class.java.name)
experimental.sessionReplay.setUnmaskViewContainerClass(CustomUnmask::class.java.name)
}

val maskContainer = ViewHierarchyNode.fromView(MaskingOptionsActivity.unmaskWithChildren!!, null, 0, options)
val firstChild = ViewHierarchyNode.fromView(MaskingOptionsActivity.customViewInUnmask!!, maskContainer, 0, options)
val secondLevelChild = ViewHierarchyNode.fromView(MaskingOptionsActivity.secondLayerChildInUnmask!!, firstChild, 0, options)

assertFalse(maskContainer.shouldMask)
assertFalse(firstChild.shouldMask)
assertTrue(secondLevelChild.shouldMask)
}

@Test
fun `when MaskContainer is direct child of UnmaskContainer all children od Mask are masked`() {
buildActivity(MaskingOptionsActivity::class.java).setup()

val options = SentryOptions().apply {
experimental.sessionReplay.setMaskViewContainerClass(CustomMask::class.java.name)
experimental.sessionReplay.setUnmaskViewContainerClass(CustomUnmask::class.java.name)
}

val unmaskNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.unmaskWithMaskChild!!, null, 0, options)
val maskNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.maskAsDirectChildOfUnmask!!, unmaskNode, 0, options)

assertFalse(unmaskNode.shouldMask)
assertTrue(maskNode.shouldMask)
}

private class CustomView(context: Context) : View(context) {
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.drawColor(Color.BLACK)
}
}

private open class CustomGroup(context: Context) : LinearLayout(context) {
init {
setBackgroundColor(android.R.color.white)
orientation = VERTICAL
layoutParams = LayoutParams(100, 100)
}

override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.drawColor(Color.BLACK)
}
}

private class CustomMask(context: Context) : CustomGroup(context)
private class CustomUnmask(context: Context) : CustomGroup(context)

private class MaskingOptionsActivity : Activity() {

companion object {
var unmaskWithTextView: ViewGroup? = null
var textViewInUnmask: TextView? = null

var unmaskWithImageView: ViewGroup? = null
var imageViewInUnmask: ImageView? = null

var unmaskWithChildren: ViewGroup? = null
var customViewInUnmask: ViewGroup? = null
var secondLayerChildInUnmask: View? = null

var maskWithChildren: ViewGroup? = null

var unmaskWithMaskChild: ViewGroup? = null
var maskAsDirectChildOfUnmask: ViewGroup? = null
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val linearLayout = LinearLayout(this).apply {
setBackgroundColor(android.R.color.white)
orientation = LinearLayout.VERTICAL
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
}

val context = this

linearLayout.addView(
CustomUnmask(context).apply {
unmaskWithTextView = this
this.addView(
TextView(context).apply {
textViewInUnmask = this
text = "Hello, World!"
layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
}
)
}
)

linearLayout.addView(
CustomUnmask(context).apply {
unmaskWithImageView = this
this.addView(
ImageView(context).apply {
imageViewInUnmask = this
val image = this::class.java.classLoader.getResource("Tongariro.jpg")!!
setImageDrawable(Drawable.createFromPath(image.path))
layoutParams = LayoutParams(50, 50).apply {
setMargins(0, 16, 0, 0)
}
}
)
}
)

linearLayout.addView(
CustomUnmask(context).apply {
unmaskWithChildren = this
this.addView(
CustomGroup(context).apply {
customViewInUnmask = this
this.addView(
CustomView(context).apply {
secondLayerChildInUnmask = this
}
)
}
)
}
)

linearLayout.addView(
CustomMask(context).apply {
maskWithChildren = this
this.addView(
CustomGroup(context).apply {
this.addView(CustomView(context))
}
)
}
)

linearLayout.addView(
CustomUnmask(context).apply {
unmaskWithMaskChild = this
this.addView(
CustomMask(context).apply {
maskAsDirectChildOfUnmask = this
}
)
}
)

setContentView(linearLayout)
}
}
}
4 changes: 4 additions & 0 deletions sentry/api/sentry.api
Original file line number Diff line number Diff line change
Expand Up @@ -2731,19 +2731,23 @@ public final class io/sentry/SentryReplayOptions {
public fun getErrorReplayDuration ()J
public fun getFrameRate ()I
public fun getMaskViewClasses ()Ljava/util/Set;
public fun getMaskViewContainerClass ()Ljava/lang/String;
public fun getOnErrorSampleRate ()Ljava/lang/Double;
public fun getQuality ()Lio/sentry/SentryReplayOptions$SentryReplayQuality;
public fun getSessionDuration ()J
public fun getSessionSampleRate ()Ljava/lang/Double;
public fun getSessionSegmentDuration ()J
public fun getUnmaskViewClasses ()Ljava/util/Set;
public fun getUnmaskViewContainerClass ()Ljava/lang/String;
public fun isSessionReplayEnabled ()Z
public fun isSessionReplayForErrorsEnabled ()Z
public fun setMaskAllImages (Z)V
public fun setMaskAllText (Z)V
public fun setMaskViewContainerClass (Ljava/lang/String;)V
public fun setOnErrorSampleRate (Ljava/lang/Double;)V
public fun setQuality (Lio/sentry/SentryReplayOptions$SentryReplayQuality;)V
public fun setSessionSampleRate (Ljava/lang/Double;)V
public fun setUnmaskViewContainerClass (Ljava/lang/String;)V
}

public final class io/sentry/SentryReplayOptions$SentryReplayQuality : java/lang/Enum {
Expand Down
27 changes: 27 additions & 0 deletions sentry/src/main/java/io/sentry/SentryReplayOptions.java
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,12 @@ public enum SentryReplayQuality {
*/
private Set<String> unmaskViewClasses = new CopyOnWriteArraySet<>();

/** The class name of the view container that masks all of its children. */
private @Nullable String maskViewContainerClass = null;

/** The class name of the view container that unmasks its direct children. */
private @Nullable String unmaskViewContainerClass = null;

/**
* Defines the quality of the session replay. The higher the quality, the more accurate the replay
* will be, but also more data to transfer and more CPU load, defaults to MEDIUM.
Expand Down Expand Up @@ -239,4 +245,25 @@ public long getSessionSegmentDuration() {
public long getSessionDuration() {
return sessionDuration;
}

@ApiStatus.Internal
public void setMaskViewContainerClass(@NotNull String containerClass) {
addMaskViewClass(containerClass);
maskViewContainerClass = containerClass;
}

@ApiStatus.Internal
public void setUnmaskViewContainerClass(@NotNull String containerClass) {
unmaskViewContainerClass = containerClass;
}

@ApiStatus.Internal
public @Nullable String getMaskViewContainerClass() {
return maskViewContainerClass;
}

@ApiStatus.Internal
public @Nullable String getUnmaskViewContainerClass() {
return unmaskViewContainerClass;
}
}
Loading