From 3ee3aee447ccd085b84da90aad93cf14383691a4 Mon Sep 17 00:00:00 2001 From: markushi Date: Fri, 11 Jul 2025 10:35:23 +0200 Subject: [PATCH 1/4] feat(session-replay): Add package wildcard masking support - Add addMaskPackage() and addUnmaskPackage() methods to SentryReplayOptions - Implement PatternUtils for suffix wildcard pattern matching (e.g., 'com.thirdparty.*') - Update ViewHierarchyNode masking logic to support package patterns - Add comprehensive unit tests for pattern matching functionality - Update API files to reflect new public methods - Include package masking info in RRWebOptionsEvent Resolves #4393 --- .../replay/viewhierarchy/ViewHierarchyNode.kt | 12 ++ .../viewhierarchy/MaskingOptionsTest.kt | 142 ++++++++++++++++++ sentry/api/sentry.api | 4 + .../java/io/sentry/SentryReplayOptions.java | 42 ++++++ .../io/sentry/rrweb/RRWebOptionsEvent.java | 2 + .../java/io/sentry/util/PatternUtils.java | 59 ++++++++ .../java/io/sentry/util/PatternUtilsTest.kt | 87 +++++++++++ 7 files changed, 348 insertions(+) create mode 100644 sentry/src/main/java/io/sentry/util/PatternUtils.java create mode 100644 sentry/src/test/java/io/sentry/util/PatternUtilsTest.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 55ac74b59da..46c61ab0ab3 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 87543555243..16c460beb11 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 @@ -14,11 +16,14 @@ import android.widget.LinearLayout.LayoutParams import android.widget.RadioButton import android.widget.TextView import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.core.app.ApplicationProvider 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 import io.sentry.android.replay.sentryReplayUnmask +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.GenericViewHierarchyNode import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode import kotlin.test.BeforeTest @@ -26,6 +31,7 @@ import kotlin.test.Test import kotlin.test.assertFalse import kotlin.test.assertTrue import org.junit.runner.RunWith +import org.robolectric.Robolectric import org.robolectric.Robolectric.buildActivity import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config @@ -225,6 +231,142 @@ 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 171cd7eaa88..fa853fefc1e 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -3665,10 +3665,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; @@ -3677,6 +3680,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 3cddf4705ab..cb3e5e267d2 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 f9a96074c1c..29c5e4ac2f9 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 00000000000..df6764d01cc --- /dev/null +++ b/sentry/src/main/java/io/sentry/util/PatternUtils.java @@ -0,0 +1,59 @@ +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; + } +} \ No newline at end of file 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 00000000000..33eee6d99e8 --- /dev/null +++ b/sentry/src/test/java/io/sentry/util/PatternUtilsTest.kt @@ -0,0 +1,87 @@ +package io.sentry.util + +import org.junit.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +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)) + } +} \ No newline at end of file From 7fe87c6f1a4b372315a5eb220ddaab2878a23afd Mon Sep 17 00:00:00 2001 From: markushi Date: Fri, 11 Jul 2025 10:37:56 +0200 Subject: [PATCH 2/4] docs: Add CLAUDE.md development guide - Add overview of sentry-java project for Claude AI assistant - Include tech stack information and key commands - Document contributing guidelines and best practices - Provide useful resources for development --- CLAUDE.md | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000000..2b1473c321f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,41 @@ +# sentry-java Development Guide for Claude + +## Overview + +sentry-java is the Java and Android SDK for Sentry. This repository contains the source code and examples for SDK usage. + +## Tech Stack + +- **Language**: Java and Kotlin +- **Build Framework**: Gradle + +## Key Commands + +```bash +# Format Code and regenerate .api file +./gradlew spotlessApply apiDump + +# Run tests and lint +./gradlew check +``` + +## Contributing Guidelines + +1. Before implementing a new feature, checkout main, pull the latest changes and branch-off +```bash +git checkout main +git pull origin main +git checkout -b markushi/[fix/feat]/[feature-name] +``` +2. Follow existing code style and language +3. Do not modify the API files (e.g. sentry.api) manually, instead run `./gradlew apiDump` to regenerate them +4. Write comprehensive tests +5. Use Kotlin only for test code and Android modules which already use Kotlin, otherwise use Java +6. New features should be opt-in by default, extend `SentryOptions` with getters and setters to enable/disable a new feature +7. Consider backwards compatibility + +## Useful Resources + +- Main Documentation: https://docs.sentry.io/ +- Internal Contributing Guide: https://docs.sentry.io/internal/contributing/ +- Git Commit messages https://develop.sentry.dev/engineering-practices/commit-messages/ From 285c0d493b603c0cb2704daf8d9bc71bc74a4d11 Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Mon, 14 Jul 2025 05:57:23 +0000 Subject: [PATCH 3/4] Format code --- .../replay/viewhierarchy/ViewHierarchyNode.kt | 2 +- .../viewhierarchy/MaskingOptionsTest.kt | 131 +++++++------- .../java/io/sentry/SentryReplayOptions.java | 10 +- .../java/io/sentry/util/PatternUtils.java | 12 +- .../java/io/sentry/util/PatternUtilsTest.kt | 166 +++++++++--------- 5 files changed, 169 insertions(+), 152 deletions(-) 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 46c61ab0ab3..6fe9d0b4559 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 @@ -317,7 +317,7 @@ internal sealed class ViewHierarchyNode( if (PatternUtils.matchesAnyPattern(className, options.sessionReplay.maskPackagePatterns)) { return true } - + // Check package-based unmasking patterns if (PatternUtils.matchesAnyPattern(className, options.sessionReplay.unmaskPackagePatterns)) { return false 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 16c460beb11..f4a38a86852 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 @@ -15,15 +15,14 @@ import android.widget.LinearLayout import android.widget.LinearLayout.LayoutParams import android.widget.RadioButton import android.widget.TextView -import androidx.test.ext.junit.runners.AndroidJUnit4 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 import io.sentry.android.replay.sentryReplayUnmask -import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.GenericViewHierarchyNode import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode import kotlin.test.BeforeTest @@ -31,7 +30,6 @@ import kotlin.test.Test import kotlin.test.assertFalse import kotlin.test.assertTrue import org.junit.runner.RunWith -import org.robolectric.Robolectric import org.robolectric.Robolectric.buildActivity import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config @@ -234,30 +232,33 @@ class MaskingOptionsTest { @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) - } - + 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 options = SentryOptions().apply { sessionReplay.addMaskPackage("android.widget.*") } val textNode = ViewHierarchyNode.fromView(textView, null, 0, options) val imageNode = ViewHierarchyNode.fromView(imageView, null, 0, options) @@ -271,7 +272,7 @@ class MaskingOptionsTest { 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() @@ -294,24 +295,27 @@ class MaskingOptionsTest { @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 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) @@ -324,26 +328,35 @@ class MaskingOptionsTest { @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) - } - + 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() diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index cb3e5e267d2..9a3b923d787 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -93,8 +93,8 @@ public enum SentryReplayQuality { /** * 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.". + * 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. @@ -104,9 +104,9 @@ public enum SentryReplayQuality { 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.". + * 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. diff --git a/sentry/src/main/java/io/sentry/util/PatternUtils.java b/sentry/src/main/java/io/sentry/util/PatternUtils.java index df6764d01cc..5ea6ed23a5b 100644 --- a/sentry/src/main/java/io/sentry/util/PatternUtils.java +++ b/sentry/src/main/java/io/sentry/util/PatternUtils.java @@ -3,9 +3,7 @@ import java.util.Set; import org.jetbrains.annotations.NotNull; -/** - * Utility class for pattern matching operations, primarily used for Session Replay masking. - */ +/** Utility class for pattern matching operations, primarily used for Session Replay masking. */ public final class PatternUtils { private PatternUtils() {} @@ -23,18 +21,18 @@ public static boolean matchesPattern(final @NotNull String input, final @NotNull 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); } @@ -56,4 +54,4 @@ public static boolean matchesAnyPattern( } return false; } -} \ No newline at end of file +} diff --git a/sentry/src/test/java/io/sentry/util/PatternUtilsTest.kt b/sentry/src/test/java/io/sentry/util/PatternUtilsTest.kt index 33eee6d99e8..19c3da6f59f 100644 --- a/sentry/src/test/java/io/sentry/util/PatternUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/util/PatternUtilsTest.kt @@ -1,87 +1,93 @@ package io.sentry.util -import org.junit.Test 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)) - } -} \ No newline at end of file + @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)) + } +} From 9dd6fec9ee3b87c7663c976f5c06dbb0497916ab Mon Sep 17 00:00:00 2001 From: markushi Date: Wed, 30 Jul 2025 09:41:07 +0200 Subject: [PATCH 4/4] Cleanup --- CLAUDE.md | 41 ----------------------------------------- 1 file changed, 41 deletions(-) delete mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 2b1473c321f..00000000000 --- a/CLAUDE.md +++ /dev/null @@ -1,41 +0,0 @@ -# sentry-java Development Guide for Claude - -## Overview - -sentry-java is the Java and Android SDK for Sentry. This repository contains the source code and examples for SDK usage. - -## Tech Stack - -- **Language**: Java and Kotlin -- **Build Framework**: Gradle - -## Key Commands - -```bash -# Format Code and regenerate .api file -./gradlew spotlessApply apiDump - -# Run tests and lint -./gradlew check -``` - -## Contributing Guidelines - -1. Before implementing a new feature, checkout main, pull the latest changes and branch-off -```bash -git checkout main -git pull origin main -git checkout -b markushi/[fix/feat]/[feature-name] -``` -2. Follow existing code style and language -3. Do not modify the API files (e.g. sentry.api) manually, instead run `./gradlew apiDump` to regenerate them -4. Write comprehensive tests -5. Use Kotlin only for test code and Android modules which already use Kotlin, otherwise use Java -6. New features should be opt-in by default, extend `SentryOptions` with getters and setters to enable/disable a new feature -7. Consider backwards compatibility - -## Useful Resources - -- Main Documentation: https://docs.sentry.io/ -- Internal Contributing Guide: https://docs.sentry.io/internal/contributing/ -- Git Commit messages https://develop.sentry.dev/engineering-practices/commit-messages/