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 55ac74b59d..6fe9d0b455 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 @@ -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( @@ -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) } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/MaskingOptionsTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/MaskingOptionsTest.kt index 8754355524..f4a38a8685 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/MaskingOptionsTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/MaskingOptionsTest.kt @@ -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 @@ -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 @@ -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() + 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() + 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() + 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) { diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 99d323cf0a..73cf399a84 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -3680,10 +3680,13 @@ public final class io/sentry/SentryReplayOptions { public static final field WEB_VIEW_CLASS_NAME Ljava/lang/String; public fun (Ljava/lang/Double;Ljava/lang/Double;Lio/sentry/protocol/SdkVersion;)V public fun (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; @@ -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 diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index 3cddf4705a..9a3b923d78 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -91,6 +91,30 @@ public enum SentryReplayQuality { */ private Set 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.". + * + *

If you're using an obfuscation tool, make sure to add the respective proguard rules to keep + * the package names. + * + *

Default is empty. + */ + private Set 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.". + * + *

If you're using an obfuscation tool, make sure to add the respective proguard rules to keep + * the package names. + * + *

Default is empty. + */ + private Set unmaskPackagePatterns = new CopyOnWriteArraySet<>(); + /** The class name of the view container that masks all of its children. */ private @Nullable String maskViewContainerClass = null; @@ -252,6 +276,24 @@ public void addUnmaskViewClass(final @NotNull String className) { this.unmaskViewClasses.add(className); } + @NotNull + public Set getMaskPackagePatterns() { + return this.maskPackagePatterns; + } + + public void addMaskPackage(final @NotNull String packagePattern) { + this.maskPackagePatterns.add(packagePattern); + } + + @NotNull + public Set getUnmaskPackagePatterns() { + return this.unmaskPackagePatterns; + } + + public void addUnmaskPackage(final @NotNull String packagePattern) { + this.unmaskPackagePatterns.add(packagePattern); + } + @ApiStatus.Internal public @NotNull SentryReplayQuality getQuality() { return quality; diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebOptionsEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebOptionsEvent.java index f9a96074c1..29c5e4ac2f 100644 --- a/sentry/src/main/java/io/sentry/rrweb/RRWebOptionsEvent.java +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebOptionsEvent.java @@ -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 diff --git a/sentry/src/main/java/io/sentry/util/PatternUtils.java b/sentry/src/main/java/io/sentry/util/PatternUtils.java new file mode 100644 index 0000000000..5ea6ed23a5 --- /dev/null +++ b/sentry/src/main/java/io/sentry/util/PatternUtils.java @@ -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 patterns) { + for (final String pattern : patterns) { + if (matchesPattern(input, pattern)) { + return true; + } + } + return false; + } +} diff --git a/sentry/src/test/java/io/sentry/util/PatternUtilsTest.kt b/sentry/src/test/java/io/sentry/util/PatternUtilsTest.kt new file mode 100644 index 0000000000..19c3da6f59 --- /dev/null +++ b/sentry/src/test/java/io/sentry/util/PatternUtilsTest.kt @@ -0,0 +1,93 @@ +package io.sentry.util + +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.junit.Test + +class PatternUtilsTest { + + @Test + fun `matchesPattern returns true for exact match`() { + assertTrue(PatternUtils.matchesPattern("com.example.app", "com.example.app")) + } + + @Test + fun `matchesPattern returns false for non-matching strings`() { + assertFalse(PatternUtils.matchesPattern("com.example.app", "com.other.app")) + } + + @Test + fun `matchesPattern returns true for suffix wildcard matching prefix`() { + assertTrue(PatternUtils.matchesPattern("com.example.app", "com.example.*")) + assertTrue(PatternUtils.matchesPattern("com.example.app.MainActivity", "com.example.*")) + assertTrue(PatternUtils.matchesPattern("com.example.SomeClass", "com.example.*")) + } + + @Test + fun `matchesPattern returns false for suffix wildcard not matching prefix`() { + assertFalse(PatternUtils.matchesPattern("com.other.app", "com.example.*")) + assertFalse(PatternUtils.matchesPattern("org.example.app", "com.example.*")) + // Exact package name should not match suffix wildcard + assertFalse(PatternUtils.matchesPattern("com.example", "com.example.*")) + } + + @Test + fun `matchesPattern returns true for empty prefix with suffix wildcard`() { + assertTrue(PatternUtils.matchesPattern("anything", "*")) + assertTrue(PatternUtils.matchesPattern("", "*")) + assertTrue(PatternUtils.matchesPattern("com.example.app", "*")) + } + + @Test + fun `matchesPattern returns false for wildcards in middle or beginning`() { + // Wildcards in the middle are not supported + assertFalse(PatternUtils.matchesPattern("com.example.pdf.viewer", "*pdf*")) + assertFalse(PatternUtils.matchesPattern("com.example.pdf.viewer", "com.*.pdf")) + assertFalse(PatternUtils.matchesPattern("com.example.pdf.viewer", "com.*pdf*")) + + // Wildcards at the beginning (not at the end) are not supported + assertFalse(PatternUtils.matchesPattern("com.example.app", "*example")) + assertFalse(PatternUtils.matchesPattern("com.example.app", "*example.app")) + } + + @Test + fun `matchesPattern handles complex package names with suffix wildcards`() { + assertTrue( + PatternUtils.matchesPattern("com.thirdparty.pdf.renderer.PdfView", "com.thirdparty.*") + ) + assertTrue( + PatternUtils.matchesPattern("com.thirdparty.trusted.TrustedView", "com.thirdparty.*") + ) + assertTrue( + PatternUtils.matchesPattern("com.thirdparty.pdf.ExactPdfView", "com.thirdparty.pdf.*") + ) + + assertFalse(PatternUtils.matchesPattern("com.thirdparty.pdf.renderer.PdfView", "com.other.*")) + assertFalse(PatternUtils.matchesPattern("org.thirdparty.SomeView", "com.thirdparty.*")) + } + + @Test + fun `matchesAnyPattern returns true when input matches any pattern`() { + val patterns = setOf("com.example.*", "com.thirdparty.*", "org.test.ExactClass") + + assertTrue(PatternUtils.matchesAnyPattern("com.example.app", patterns)) + assertTrue(PatternUtils.matchesAnyPattern("com.thirdparty.pdf.PdfView", patterns)) + assertTrue(PatternUtils.matchesAnyPattern("org.test.ExactClass", patterns)) + } + + @Test + fun `matchesAnyPattern returns false when input matches no patterns`() { + val patterns = setOf("com.example.*", "com.thirdparty.*", "org.test.ExactClass") + + assertFalse(PatternUtils.matchesAnyPattern("com.other.app", patterns)) + assertFalse(PatternUtils.matchesAnyPattern("org.test.OtherClass", patterns)) + assertFalse(PatternUtils.matchesAnyPattern("net.example.app", patterns)) + } + + @Test + fun `matchesAnyPattern returns false for empty patterns`() { + val patterns = emptySet() + + assertFalse(PatternUtils.matchesAnyPattern("com.example.app", patterns)) + } +}