From 2dc6e5af71b545b4744b65d39bbe17529acefea3 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Tue, 12 Nov 2024 15:27:33 +0100 Subject: [PATCH 1/4] feat(replay): Add Mask/Unmask Containers for custom masking in hybrid SDKs --- .../replay/viewhierarchy/ViewHierarchyNode.kt | 24 ++ .../ContainerMaskingOptionsTest.kt | 231 ++++++++++++++++++ sentry/api/sentry.api | 4 + .../java/io/sentry/SentryReplayOptions.java | 31 +++ 4 files changed, 290 insertions(+) create mode 100644 sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ContainerMaskingOptionsTest.kt diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt index 03cb37ad3e..6b601b1d4b 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt @@ -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 @@ -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): Boolean { var cls: Class<*>? = this while (cls != null) { @@ -261,6 +266,13 @@ 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 } @@ -268,6 +280,18 @@ sealed class ViewHierarchyNode( 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) diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ContainerMaskingOptionsTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ContainerMaskingOptionsTest.kt new file mode 100644 index 0000000000..ff9a125d95 --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ContainerMaskingOptionsTest.kt @@ -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) + } + } +} diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 8e266bcdba..69d05b8c40 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -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 { diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index 0c99085726..8e39b97435 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -81,6 +81,16 @@ public enum SentryReplayQuality { */ private Set 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. @@ -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; + } } From 8398ec20475ebb5cc02193a4723d8a2b408c1891 Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Tue, 12 Nov 2024 14:33:59 +0000 Subject: [PATCH 2/4] Format code --- sentry/src/main/java/io/sentry/SentryReplayOptions.java | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index 8e39b97435..fd492213ac 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -81,14 +81,10 @@ public enum SentryReplayQuality { */ private Set unmaskViewClasses = new CopyOnWriteArraySet<>(); - /** - * The class name of the view container that masks all of its children. - */ + /** 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. - */ + /** The class name of the view container that unmasks its direct children. */ private @Nullable String unmaskViewContainerClass = null; /** From 8d18edf02957fd84448559e952dc9dd3975887d8 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 18 Nov 2024 10:09:28 +0100 Subject: [PATCH 3/4] Address PR feedback --- CHANGELOG.md | 1 + .../android/replay/viewhierarchy/ViewHierarchyNode.kt | 10 +++------- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7a41e65dc..a2a7cafd56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Add meta option to set the maximum amount of breadcrumbs to be logged. ([#3836](https://github.com/getsentry/sentry-java/pull/3836)) - Use a separate `Random` instance per thread to improve SDK performance ([#3835](https://github.com/getsentry/sentry-java/pull/3835)) +- Session Replay: Add support for masking/unmasking view containers ([#3881](https://github.com/getsentry/sentry-java/pull/3881)) ### Fixes diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt index 6b601b1d4b..03bda7cfc6 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt @@ -237,10 +237,6 @@ 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): Boolean { var cls: Class<*>? = this while (cls != null) { @@ -283,13 +279,13 @@ sealed class ViewHierarchyNode( private fun ViewParent.isUnmaskContainer(options: SentryOptions): Boolean { val unmaskContainer = options.experimental.sessionReplay.unmaskViewContainerClass ?: return false - return this.javaClass.isAssignableFrom(unmaskContainer) + return this.javaClass.name == unmaskContainer } private fun View.isMaskContainer(options: SentryOptions): Boolean { - val unmaskContainer = + val maskContainer = options.experimental.sessionReplay.maskViewContainerClass ?: return false - return this.javaClass.isAssignableFrom(unmaskContainer) + return this.javaClass.name == maskContainer } fun fromView(view: View, parent: ViewHierarchyNode?, distance: Int, options: SentryOptions): ViewHierarchyNode { From 84596dc43c7cb741aa1e9778694a68d103f4e97a Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 18 Nov 2024 10:11:36 +0100 Subject: [PATCH 4/4] Fix changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee59409325..5261116397 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -28,7 +29,6 @@ - Add meta option to set the maximum amount of breadcrumbs to be logged. ([#3836](https://github.com/getsentry/sentry-java/pull/3836)) - Use a separate `Random` instance per thread to improve SDK performance ([#3835](https://github.com/getsentry/sentry-java/pull/3835)) -- Session Replay: Add support for masking/unmasking view containers ([#3881](https://github.com/getsentry/sentry-java/pull/3881)) ### Fixes