Skip to content

Add option to mask widgets by package name #4553

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

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import io.sentry.android.replay.util.isMaskable
import io.sentry.android.replay.util.isVisibleToUser
import io.sentry.android.replay.util.toOpaque
import io.sentry.android.replay.util.totalPaddingTopSafe
import io.sentry.util.PatternUtils

@TargetApi(26)
internal sealed class ViewHierarchyNode(
Expand Down Expand Up @@ -311,6 +312,17 @@ internal sealed class ViewHierarchyNode(
return false
}

// Check package-based masking patterns
val className = this.javaClass.name
if (PatternUtils.matchesAnyPattern(className, options.sessionReplay.maskPackagePatterns)) {
return true
}

// Check package-based unmasking patterns
if (PatternUtils.matchesAnyPattern(className, options.sessionReplay.unmaskPackagePatterns)) {
return false
}

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ package io.sentry.android.replay.viewhierarchy

import android.app.Activity
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.os.Bundle
import android.os.Looper
Expand All @@ -13,8 +15,10 @@ import android.widget.LinearLayout
import android.widget.LinearLayout.LayoutParams
import android.widget.RadioButton
import android.widget.TextView
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.sentry.SentryOptions
import io.sentry.android.replay.R
import io.sentry.android.replay.maskAllImages
import io.sentry.android.replay.maskAllText
import io.sentry.android.replay.sentryReplayMask
Expand Down Expand Up @@ -225,6 +229,157 @@ class MaskingOptionsTest {
assertTrue(textNode.shouldMask)
assertTrue(imageNode.shouldMask)
}

@Test
fun `views are masked when class name matches mask package pattern`() {
val textView =
TextView(ApplicationProvider.getApplicationContext()).apply {
text = "Test text"
visibility = View.VISIBLE
measure(
View.MeasureSpec.makeMeasureSpec(100, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(50, View.MeasureSpec.EXACTLY),
)
layout(0, 0, 100, 50)
}
val imageView =
ImageView(ApplicationProvider.getApplicationContext()).apply {
visibility = View.VISIBLE
measure(
View.MeasureSpec.makeMeasureSpec(100, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(50, View.MeasureSpec.EXACTLY),
)
layout(0, 0, 100, 50)
}

// Create a bitmap drawable that should be considered maskable
val bitmap = Bitmap.createBitmap(50, 50, Bitmap.Config.ARGB_8888)
val context = ApplicationProvider.getApplicationContext<Context>()
val drawable = BitmapDrawable(context.resources, bitmap)
imageView.setImageDrawable(drawable)

val options = SentryOptions().apply { sessionReplay.addMaskPackage("android.widget.*") }

val textNode = ViewHierarchyNode.fromView(textView, null, 0, options)
val imageNode = ViewHierarchyNode.fromView(imageView, null, 0, options)

// Both views from android.widget.* should be masked
assertTrue(textNode.shouldMask)
assertTrue(imageNode.shouldMask)
}

@Test
fun `views are unmasked when class name matches unmask package pattern`() {
val textView = TextView(ApplicationProvider.getApplicationContext())
val imageView = ImageView(ApplicationProvider.getApplicationContext())

// Create a bitmap drawable that should be considered maskable
val bitmap = Bitmap.createBitmap(50, 50, Bitmap.Config.ARGB_8888)
val context = ApplicationProvider.getApplicationContext<Context>()
val drawable = BitmapDrawable(context.resources, bitmap)
imageView.setImageDrawable(drawable)

val options =
SentryOptions().apply {
sessionReplay.addMaskPackage("android.*")
sessionReplay.addUnmaskPackage("android.widget.*")
}

val textNode = ViewHierarchyNode.fromView(textView, null, 0, options)
val imageNode = ViewHierarchyNode.fromView(imageView, null, 0, options)

// Both views should be unmasked due to more specific unmask pattern
assertFalse(textNode.shouldMask)
assertFalse(imageNode.shouldMask)
}

@Test
fun `views are masked with specific package patterns`() {
val textView =
TextView(ApplicationProvider.getApplicationContext()).apply {
text = "Test text"
visibility = View.VISIBLE
measure(
View.MeasureSpec.makeMeasureSpec(100, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(50, View.MeasureSpec.EXACTLY),
)
layout(0, 0, 100, 50)
}
val linearLayout =
LinearLayout(ApplicationProvider.getApplicationContext()).apply {
visibility = View.VISIBLE
measure(
View.MeasureSpec.makeMeasureSpec(100, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(50, View.MeasureSpec.EXACTLY),
)
layout(0, 0, 100, 50)
}

val options = SentryOptions().apply { sessionReplay.addMaskPackage("android.widget.TextView") }

val textNode = ViewHierarchyNode.fromView(textView, null, 0, options)
val layoutNode = ViewHierarchyNode.fromView(linearLayout, null, 0, options)

// TextView should be masked by exact match
assertTrue(textNode.shouldMask)
// LinearLayout should not be masked
assertFalse(layoutNode.shouldMask)
}

@Test
fun `package patterns work with multiple patterns`() {
val textView =
TextView(ApplicationProvider.getApplicationContext()).apply {
text = "Test text"
visibility = View.VISIBLE
measure(
View.MeasureSpec.makeMeasureSpec(100, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(50, View.MeasureSpec.EXACTLY),
)
layout(0, 0, 100, 50)
}
val imageView =
ImageView(ApplicationProvider.getApplicationContext()).apply {
visibility = View.VISIBLE
measure(
View.MeasureSpec.makeMeasureSpec(100, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(50, View.MeasureSpec.EXACTLY),
)
layout(0, 0, 100, 50)
}
val linearLayout =
LinearLayout(ApplicationProvider.getApplicationContext()).apply {
visibility = View.VISIBLE
measure(
View.MeasureSpec.makeMeasureSpec(100, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(50, View.MeasureSpec.EXACTLY),
)
layout(0, 0, 100, 50)
}

// Create a bitmap drawable that should be considered maskable
val bitmap = Bitmap.createBitmap(50, 50, Bitmap.Config.ARGB_8888)
val context = ApplicationProvider.getApplicationContext<Context>()
val drawable = BitmapDrawable(context.resources, bitmap)
imageView.setImageDrawable(drawable)

val options =
SentryOptions().apply {
sessionReplay.addMaskPackage("android.widget.TextView")
sessionReplay.addMaskPackage("android.widget.ImageView")
sessionReplay.addUnmaskPackage("android.widget.LinearLayout")
}

val textNode = ViewHierarchyNode.fromView(textView, null, 0, options)
val imageNode = ViewHierarchyNode.fromView(imageView, null, 0, options)
val layoutNode = ViewHierarchyNode.fromView(linearLayout, null, 0, options)

// TextView and ImageView should be masked by exact matches
assertTrue(textNode.shouldMask)
assertTrue(imageNode.shouldMask)
// LinearLayout should not be masked (not in any mask patterns)
assertFalse(layoutNode.shouldMask)
}
}

private class CustomView(context: Context) : View(context) {
Expand Down
4 changes: 4 additions & 0 deletions sentry/api/sentry.api
Original file line number Diff line number Diff line change
Expand Up @@ -3680,10 +3680,13 @@ public final class io/sentry/SentryReplayOptions {
public static final field WEB_VIEW_CLASS_NAME Ljava/lang/String;
public fun <init> (Ljava/lang/Double;Ljava/lang/Double;Lio/sentry/protocol/SdkVersion;)V
public fun <init> (ZLio/sentry/protocol/SdkVersion;)V
public fun addMaskPackage (Ljava/lang/String;)V
public fun addMaskViewClass (Ljava/lang/String;)V
public fun addUnmaskPackage (Ljava/lang/String;)V
public fun addUnmaskViewClass (Ljava/lang/String;)V
public fun getErrorReplayDuration ()J
public fun getFrameRate ()I
public fun getMaskPackagePatterns ()Ljava/util/Set;
public fun getMaskViewClasses ()Ljava/util/Set;
public fun getMaskViewContainerClass ()Ljava/lang/String;
public fun getOnErrorSampleRate ()Ljava/lang/Double;
Expand All @@ -3692,6 +3695,7 @@ public final class io/sentry/SentryReplayOptions {
public fun getSessionDuration ()J
public fun getSessionSampleRate ()Ljava/lang/Double;
public fun getSessionSegmentDuration ()J
public fun getUnmaskPackagePatterns ()Ljava/util/Set;
public fun getUnmaskViewClasses ()Ljava/util/Set;
public fun getUnmaskViewContainerClass ()Ljava/lang/String;
public fun isDebug ()Z
Expand Down
42 changes: 42 additions & 0 deletions sentry/src/main/java/io/sentry/SentryReplayOptions.java
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,30 @@ public enum SentryReplayQuality {
*/
private Set<String> unmaskViewClasses = new CopyOnWriteArraySet<>();

/**
* Mask all views with the specified package name patterns. The package name pattern can include
* wildcards (*) to match multiple packages. For example, "com.thirdparty.*" will mask all views
* from packages starting with "com.thirdparty.".
*
* <p>If you're using an obfuscation tool, make sure to add the respective proguard rules to keep
* the package names.
*
* <p>Default is empty.
*/
private Set<String> maskPackagePatterns = new CopyOnWriteArraySet<>();

/**
* Ignore all views with the specified package name patterns from masking. The package name
* pattern can include wildcards (*) to match multiple packages. For example, "com.myapp.*" will
* unmask all views from packages starting with "com.myapp.".
*
* <p>If you're using an obfuscation tool, make sure to add the respective proguard rules to keep
* the package names.
*
* <p>Default is empty.
*/
private Set<String> unmaskPackagePatterns = new CopyOnWriteArraySet<>();

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

Expand Down Expand Up @@ -252,6 +276,24 @@ public void addUnmaskViewClass(final @NotNull String className) {
this.unmaskViewClasses.add(className);
}

@NotNull
public Set<String> getMaskPackagePatterns() {
return this.maskPackagePatterns;
}

public void addMaskPackage(final @NotNull String packagePattern) {
this.maskPackagePatterns.add(packagePattern);
}

@NotNull
public Set<String> getUnmaskPackagePatterns() {
return this.unmaskPackagePatterns;
}

public void addUnmaskPackage(final @NotNull String packagePattern) {
this.unmaskPackagePatterns.add(packagePattern);
}

@ApiStatus.Internal
public @NotNull SentryReplayQuality getQuality() {
return quality;
Expand Down
2 changes: 2 additions & 0 deletions sentry/src/main/java/io/sentry/rrweb/RRWebOptionsEvent.java
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ public RRWebOptionsEvent(final @NotNull SentryOptions options) {
optionsPayload.put("quality", replayOptions.getQuality().serializedName());
optionsPayload.put("maskedViewClasses", replayOptions.getMaskViewClasses());
optionsPayload.put("unmaskedViewClasses", replayOptions.getUnmaskViewClasses());
optionsPayload.put("maskedPackagePatterns", replayOptions.getMaskPackagePatterns());
optionsPayload.put("unmaskedPackagePatterns", replayOptions.getUnmaskPackagePatterns());
}

@NotNull
Expand Down
57 changes: 57 additions & 0 deletions sentry/src/main/java/io/sentry/util/PatternUtils.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package io.sentry.util;

import java.util.Set;
import org.jetbrains.annotations.NotNull;

/** Utility class for pattern matching operations, primarily used for Session Replay masking. */
public final class PatternUtils {

private PatternUtils() {}

/**
* Checks if a given string matches a pattern. The pattern can contain wildcards (*) only at the
* end to match any sequence of characters as a suffix.
*
* @param input the string to check
* @param pattern the pattern to match against (only suffix wildcards are supported)
* @return true if the input matches the pattern, false otherwise
*/
public static boolean matchesPattern(final @NotNull String input, final @NotNull String pattern) {
// If pattern doesn't contain wildcard, do exact match
if (!pattern.contains("*")) {
return input.equals(pattern);
}

// Only support suffix wildcards (pattern ending with *)
if (!pattern.endsWith("*")) {
return false;
}

// Check if pattern has wildcards in the middle or beginning (not supported)
final String prefix = pattern.substring(0, pattern.length() - 1);
if (prefix.contains("*")) {
return false;
}

// Check if input starts with the prefix
return input.startsWith(prefix);
}

/**
* Checks if a given string matches any of the provided patterns. Patterns can contain wildcards
* (*) only at the end to match any sequence of characters as a suffix.
*
* @param input the string to check
* @param patterns the set of patterns to match against
* @return true if the input matches any of the patterns, false otherwise
*/
public static boolean matchesAnyPattern(
final @NotNull String input, final @NotNull Set<String> patterns) {
for (final String pattern : patterns) {
if (matchesPattern(input, pattern)) {
return true;
}
}
return false;
}
}
Loading
Loading