Skip to content

Commit

Permalink
Merge 2dc6e5a into cb0ecf1
Browse files Browse the repository at this point in the history
  • Loading branch information
krystofwoldrich authored Nov 12, 2024
2 parents cb0ecf1 + 2dc6e5a commit 4db467c
Show file tree
Hide file tree
Showing 4 changed files with 290 additions and 0 deletions.
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 @@ -236,6 +237,10 @@ sealed class ViewHierarchyNode(
private const val SENTRY_UNMASK_TAG = "sentry-unmask"
private const val SENTRY_MASK_TAG = "sentry-mask"

private fun Class<*>.isAssignableFrom(className: String): Boolean {
return this.name.equals(className)
}

private fun Class<*>.isAssignableFrom(set: Set<String>): Boolean {
var cls: Class<*>? = this
while (cls != null) {
Expand All @@ -261,13 +266,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.isAssignableFrom(unmaskContainer)
}

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

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 @@ -2725,19 +2725,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
31 changes: 31 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,16 @@ 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 +249,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;
}
}

0 comments on commit 4db467c

Please sign in to comment.