Skip to content

Commit ce09ad4

Browse files
krystofwoldrichgetsentry-botromtsn
authored
feat(replay): Add Mask/Unmask Containers for custom masking in hybrid SDKs (#3881)
* feat(replay): Add Mask/Unmask Containers for custom masking in hybrid SDKs * Format code * Address PR feedback * Fix changelog --------- Co-authored-by: Sentry Github Bot <[email protected]> Co-authored-by: Roman Zavarnitsyn <[email protected]>
1 parent 0438c6f commit ce09ad4

File tree

5 files changed

+283
-0
lines changed

5 files changed

+283
-0
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
- Android 15: Add support for 16KB page sizes ([#3620](https://github.com/getsentry/sentry-java/pull/3620))
88
- See https://developer.android.com/guide/practices/page-sizes for more details
99
- Session Replay: Add `beforeSendReplay` callback ([#3855](https://github.com/getsentry/sentry-java/pull/3855))
10+
- Session Replay: Add support for masking/unmasking view containers ([#3881](https://github.com/getsentry/sentry-java/pull/3881))
1011

1112
### Fixes
1213

sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package io.sentry.android.replay.viewhierarchy
33
import android.annotation.TargetApi
44
import android.graphics.Rect
55
import android.view.View
6+
import android.view.ViewParent
67
import android.widget.ImageView
78
import android.widget.TextView
89
import io.sentry.SentryOptions
@@ -261,13 +262,32 @@ sealed class ViewHierarchyNode(
261262
return true
262263
}
263264

265+
if (!this.isMaskContainer(options) &&
266+
this.parent != null &&
267+
this.parent.isUnmaskContainer(options)
268+
) {
269+
return false
270+
}
271+
264272
if (this.javaClass.isAssignableFrom(options.experimental.sessionReplay.unmaskViewClasses)) {
265273
return false
266274
}
267275

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

279+
private fun ViewParent.isUnmaskContainer(options: SentryOptions): Boolean {
280+
val unmaskContainer =
281+
options.experimental.sessionReplay.unmaskViewContainerClass ?: return false
282+
return this.javaClass.name == unmaskContainer
283+
}
284+
285+
private fun View.isMaskContainer(options: SentryOptions): Boolean {
286+
val maskContainer =
287+
options.experimental.sessionReplay.maskViewContainerClass ?: return false
288+
return this.javaClass.name == maskContainer
289+
}
290+
271291
fun fromView(view: View, parent: ViewHierarchyNode?, distance: Int, options: SentryOptions): ViewHierarchyNode {
272292
val (isVisible, visibleRect) = view.isVisibleToUser()
273293
val shouldMask = isVisible && view.shouldMask(options)
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
package io.sentry.android.replay.viewhierarchy
2+
3+
import android.app.Activity
4+
import android.content.Context
5+
import android.graphics.Canvas
6+
import android.graphics.Color
7+
import android.graphics.drawable.Drawable
8+
import android.os.Bundle
9+
import android.view.View
10+
import android.view.ViewGroup
11+
import android.widget.ImageView
12+
import android.widget.LinearLayout
13+
import android.widget.LinearLayout.LayoutParams
14+
import android.widget.TextView
15+
import androidx.test.ext.junit.runners.AndroidJUnit4
16+
import io.sentry.SentryOptions
17+
import io.sentry.android.replay.maskAllImages
18+
import io.sentry.android.replay.maskAllText
19+
import org.junit.runner.RunWith
20+
import org.robolectric.Robolectric.buildActivity
21+
import org.robolectric.annotation.Config
22+
import kotlin.test.BeforeTest
23+
import kotlin.test.Test
24+
import kotlin.test.assertFalse
25+
import kotlin.test.assertTrue
26+
27+
@RunWith(AndroidJUnit4::class)
28+
@Config(sdk = [30])
29+
class ContainerMaskingOptionsTest {
30+
31+
@BeforeTest
32+
fun setup() {
33+
System.setProperty("robolectric.areWindowsMarkedVisible", "true")
34+
}
35+
36+
@Test
37+
fun `when maskAllText is set TextView in Unmask container is unmasked`() {
38+
buildActivity(MaskingOptionsActivity::class.java).setup()
39+
40+
val options = SentryOptions().apply {
41+
experimental.sessionReplay.maskAllText = true
42+
experimental.sessionReplay.setUnmaskViewContainerClass(CustomUnmask::class.java.name)
43+
}
44+
45+
val textNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textViewInUnmask!!, null, 0, options)
46+
assertFalse(textNode.shouldMask)
47+
}
48+
49+
@Test
50+
fun `when maskAllImages is set ImageView in Unmask container is unmasked`() {
51+
buildActivity(MaskingOptionsActivity::class.java).setup()
52+
53+
val options = SentryOptions().apply {
54+
experimental.sessionReplay.maskAllImages = true
55+
experimental.sessionReplay.setUnmaskViewContainerClass(CustomUnmask::class.java.name)
56+
}
57+
58+
val imageNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.imageViewInUnmask!!, null, 0, options)
59+
assertFalse(imageNode.shouldMask)
60+
}
61+
62+
@Test
63+
fun `MaskContainer is always masked`() {
64+
buildActivity(MaskingOptionsActivity::class.java).setup()
65+
66+
val options = SentryOptions().apply {
67+
experimental.sessionReplay.setMaskViewContainerClass(CustomMask::class.java.name)
68+
}
69+
70+
val maskContainer = ViewHierarchyNode.fromView(MaskingOptionsActivity.maskWithChildren!!, null, 0, options)
71+
72+
assertTrue(maskContainer.shouldMask)
73+
}
74+
75+
@Test
76+
fun `when Views are in UnmaskContainer only direct children are unmasked`() {
77+
buildActivity(MaskingOptionsActivity::class.java).setup()
78+
79+
val options = SentryOptions().apply {
80+
experimental.sessionReplay.addMaskViewClass(CustomView::class.java.name)
81+
experimental.sessionReplay.setUnmaskViewContainerClass(CustomUnmask::class.java.name)
82+
}
83+
84+
val maskContainer = ViewHierarchyNode.fromView(MaskingOptionsActivity.unmaskWithChildren!!, null, 0, options)
85+
val firstChild = ViewHierarchyNode.fromView(MaskingOptionsActivity.customViewInUnmask!!, maskContainer, 0, options)
86+
val secondLevelChild = ViewHierarchyNode.fromView(MaskingOptionsActivity.secondLayerChildInUnmask!!, firstChild, 0, options)
87+
88+
assertFalse(maskContainer.shouldMask)
89+
assertFalse(firstChild.shouldMask)
90+
assertTrue(secondLevelChild.shouldMask)
91+
}
92+
93+
@Test
94+
fun `when MaskContainer is direct child of UnmaskContainer all children od Mask are masked`() {
95+
buildActivity(MaskingOptionsActivity::class.java).setup()
96+
97+
val options = SentryOptions().apply {
98+
experimental.sessionReplay.setMaskViewContainerClass(CustomMask::class.java.name)
99+
experimental.sessionReplay.setUnmaskViewContainerClass(CustomUnmask::class.java.name)
100+
}
101+
102+
val unmaskNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.unmaskWithMaskChild!!, null, 0, options)
103+
val maskNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.maskAsDirectChildOfUnmask!!, unmaskNode, 0, options)
104+
105+
assertFalse(unmaskNode.shouldMask)
106+
assertTrue(maskNode.shouldMask)
107+
}
108+
109+
private class CustomView(context: Context) : View(context) {
110+
override fun onDraw(canvas: Canvas) {
111+
super.onDraw(canvas)
112+
canvas.drawColor(Color.BLACK)
113+
}
114+
}
115+
116+
private open class CustomGroup(context: Context) : LinearLayout(context) {
117+
init {
118+
setBackgroundColor(android.R.color.white)
119+
orientation = VERTICAL
120+
layoutParams = LayoutParams(100, 100)
121+
}
122+
123+
override fun onDraw(canvas: Canvas) {
124+
super.onDraw(canvas)
125+
canvas.drawColor(Color.BLACK)
126+
}
127+
}
128+
129+
private class CustomMask(context: Context) : CustomGroup(context)
130+
private class CustomUnmask(context: Context) : CustomGroup(context)
131+
132+
private class MaskingOptionsActivity : Activity() {
133+
134+
companion object {
135+
var unmaskWithTextView: ViewGroup? = null
136+
var textViewInUnmask: TextView? = null
137+
138+
var unmaskWithImageView: ViewGroup? = null
139+
var imageViewInUnmask: ImageView? = null
140+
141+
var unmaskWithChildren: ViewGroup? = null
142+
var customViewInUnmask: ViewGroup? = null
143+
var secondLayerChildInUnmask: View? = null
144+
145+
var maskWithChildren: ViewGroup? = null
146+
147+
var unmaskWithMaskChild: ViewGroup? = null
148+
var maskAsDirectChildOfUnmask: ViewGroup? = null
149+
}
150+
151+
override fun onCreate(savedInstanceState: Bundle?) {
152+
super.onCreate(savedInstanceState)
153+
val linearLayout = LinearLayout(this).apply {
154+
setBackgroundColor(android.R.color.white)
155+
orientation = LinearLayout.VERTICAL
156+
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
157+
}
158+
159+
val context = this
160+
161+
linearLayout.addView(
162+
CustomUnmask(context).apply {
163+
unmaskWithTextView = this
164+
this.addView(
165+
TextView(context).apply {
166+
textViewInUnmask = this
167+
text = "Hello, World!"
168+
layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
169+
}
170+
)
171+
}
172+
)
173+
174+
linearLayout.addView(
175+
CustomUnmask(context).apply {
176+
unmaskWithImageView = this
177+
this.addView(
178+
ImageView(context).apply {
179+
imageViewInUnmask = this
180+
val image = this::class.java.classLoader.getResource("Tongariro.jpg")!!
181+
setImageDrawable(Drawable.createFromPath(image.path))
182+
layoutParams = LayoutParams(50, 50).apply {
183+
setMargins(0, 16, 0, 0)
184+
}
185+
}
186+
)
187+
}
188+
)
189+
190+
linearLayout.addView(
191+
CustomUnmask(context).apply {
192+
unmaskWithChildren = this
193+
this.addView(
194+
CustomGroup(context).apply {
195+
customViewInUnmask = this
196+
this.addView(
197+
CustomView(context).apply {
198+
secondLayerChildInUnmask = this
199+
}
200+
)
201+
}
202+
)
203+
}
204+
)
205+
206+
linearLayout.addView(
207+
CustomMask(context).apply {
208+
maskWithChildren = this
209+
this.addView(
210+
CustomGroup(context).apply {
211+
this.addView(CustomView(context))
212+
}
213+
)
214+
}
215+
)
216+
217+
linearLayout.addView(
218+
CustomUnmask(context).apply {
219+
unmaskWithMaskChild = this
220+
this.addView(
221+
CustomMask(context).apply {
222+
maskAsDirectChildOfUnmask = this
223+
}
224+
)
225+
}
226+
)
227+
228+
setContentView(linearLayout)
229+
}
230+
}
231+
}

sentry/api/sentry.api

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2731,19 +2731,23 @@ public final class io/sentry/SentryReplayOptions {
27312731
public fun getErrorReplayDuration ()J
27322732
public fun getFrameRate ()I
27332733
public fun getMaskViewClasses ()Ljava/util/Set;
2734+
public fun getMaskViewContainerClass ()Ljava/lang/String;
27342735
public fun getOnErrorSampleRate ()Ljava/lang/Double;
27352736
public fun getQuality ()Lio/sentry/SentryReplayOptions$SentryReplayQuality;
27362737
public fun getSessionDuration ()J
27372738
public fun getSessionSampleRate ()Ljava/lang/Double;
27382739
public fun getSessionSegmentDuration ()J
27392740
public fun getUnmaskViewClasses ()Ljava/util/Set;
2741+
public fun getUnmaskViewContainerClass ()Ljava/lang/String;
27402742
public fun isSessionReplayEnabled ()Z
27412743
public fun isSessionReplayForErrorsEnabled ()Z
27422744
public fun setMaskAllImages (Z)V
27432745
public fun setMaskAllText (Z)V
2746+
public fun setMaskViewContainerClass (Ljava/lang/String;)V
27442747
public fun setOnErrorSampleRate (Ljava/lang/Double;)V
27452748
public fun setQuality (Lio/sentry/SentryReplayOptions$SentryReplayQuality;)V
27462749
public fun setSessionSampleRate (Ljava/lang/Double;)V
2750+
public fun setUnmaskViewContainerClass (Ljava/lang/String;)V
27472751
}
27482752

27492753
public final class io/sentry/SentryReplayOptions$SentryReplayQuality : java/lang/Enum {

sentry/src/main/java/io/sentry/SentryReplayOptions.java

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,12 @@ public enum SentryReplayQuality {
8181
*/
8282
private Set<String> unmaskViewClasses = new CopyOnWriteArraySet<>();
8383

84+
/** The class name of the view container that masks all of its children. */
85+
private @Nullable String maskViewContainerClass = null;
86+
87+
/** The class name of the view container that unmasks its direct children. */
88+
private @Nullable String unmaskViewContainerClass = null;
89+
8490
/**
8591
* Defines the quality of the session replay. The higher the quality, the more accurate the replay
8692
* will be, but also more data to transfer and more CPU load, defaults to MEDIUM.
@@ -239,4 +245,25 @@ public long getSessionSegmentDuration() {
239245
public long getSessionDuration() {
240246
return sessionDuration;
241247
}
248+
249+
@ApiStatus.Internal
250+
public void setMaskViewContainerClass(@NotNull String containerClass) {
251+
addMaskViewClass(containerClass);
252+
maskViewContainerClass = containerClass;
253+
}
254+
255+
@ApiStatus.Internal
256+
public void setUnmaskViewContainerClass(@NotNull String containerClass) {
257+
unmaskViewContainerClass = containerClass;
258+
}
259+
260+
@ApiStatus.Internal
261+
public @Nullable String getMaskViewContainerClass() {
262+
return maskViewContainerClass;
263+
}
264+
265+
@ApiStatus.Internal
266+
public @Nullable String getUnmaskViewContainerClass() {
267+
return unmaskViewContainerClass;
268+
}
242269
}

0 commit comments

Comments
 (0)