Skip to content

Commit e10da97

Browse files
authored
Merge 7fe87c6 into 2bfacef
2 parents 2bfacef + 7fe87c6 commit e10da97

File tree

8 files changed

+389
-0
lines changed

8 files changed

+389
-0
lines changed

CLAUDE.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# sentry-java Development Guide for Claude
2+
3+
## Overview
4+
5+
sentry-java is the Java and Android SDK for Sentry. This repository contains the source code and examples for SDK usage.
6+
7+
## Tech Stack
8+
9+
- **Language**: Java and Kotlin
10+
- **Build Framework**: Gradle
11+
12+
## Key Commands
13+
14+
```bash
15+
# Format Code and regenerate .api file
16+
./gradlew spotlessApply apiDump
17+
18+
# Run tests and lint
19+
./gradlew check
20+
```
21+
22+
## Contributing Guidelines
23+
24+
1. Before implementing a new feature, checkout main, pull the latest changes and branch-off
25+
```bash
26+
git checkout main
27+
git pull origin main
28+
git checkout -b markushi/[fix/feat]/[feature-name]
29+
```
30+
2. Follow existing code style and language
31+
3. Do not modify the API files (e.g. sentry.api) manually, instead run `./gradlew apiDump` to regenerate them
32+
4. Write comprehensive tests
33+
5. Use Kotlin only for test code and Android modules which already use Kotlin, otherwise use Java
34+
6. New features should be opt-in by default, extend `SentryOptions` with getters and setters to enable/disable a new feature
35+
7. Consider backwards compatibility
36+
37+
## Useful Resources
38+
39+
- Main Documentation: https://docs.sentry.io/
40+
- Internal Contributing Guide: https://docs.sentry.io/internal/contributing/
41+
- Git Commit messages https://develop.sentry.dev/engineering-practices/commit-messages/

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import io.sentry.android.replay.util.isMaskable
1414
import io.sentry.android.replay.util.isVisibleToUser
1515
import io.sentry.android.replay.util.toOpaque
1616
import io.sentry.android.replay.util.totalPaddingTopSafe
17+
import io.sentry.util.PatternUtils
1718

1819
@TargetApi(26)
1920
internal sealed class ViewHierarchyNode(
@@ -311,6 +312,17 @@ internal sealed class ViewHierarchyNode(
311312
return false
312313
}
313314

315+
// Check package-based masking patterns
316+
val className = this.javaClass.name
317+
if (PatternUtils.matchesAnyPattern(className, options.sessionReplay.maskPackagePatterns)) {
318+
return true
319+
}
320+
321+
// Check package-based unmasking patterns
322+
if (PatternUtils.matchesAnyPattern(className, options.sessionReplay.unmaskPackagePatterns)) {
323+
return false
324+
}
325+
314326
return this.javaClass.isAssignableFrom(options.sessionReplay.maskViewClasses)
315327
}
316328

sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/MaskingOptionsTest.kt

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ package io.sentry.android.replay.viewhierarchy
22

33
import android.app.Activity
44
import android.content.Context
5+
import android.graphics.Bitmap
56
import android.graphics.Canvas
67
import android.graphics.Color
8+
import android.graphics.drawable.BitmapDrawable
79
import android.graphics.drawable.Drawable
810
import android.os.Bundle
911
import android.os.Looper
@@ -14,18 +16,22 @@ import android.widget.LinearLayout.LayoutParams
1416
import android.widget.RadioButton
1517
import android.widget.TextView
1618
import androidx.test.ext.junit.runners.AndroidJUnit4
19+
import androidx.test.core.app.ApplicationProvider
1720
import io.sentry.SentryOptions
21+
import io.sentry.android.replay.R
1822
import io.sentry.android.replay.maskAllImages
1923
import io.sentry.android.replay.maskAllText
2024
import io.sentry.android.replay.sentryReplayMask
2125
import io.sentry.android.replay.sentryReplayUnmask
26+
import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.GenericViewHierarchyNode
2227
import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode
2328
import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode
2429
import kotlin.test.BeforeTest
2530
import kotlin.test.Test
2631
import kotlin.test.assertFalse
2732
import kotlin.test.assertTrue
2833
import org.junit.runner.RunWith
34+
import org.robolectric.Robolectric
2935
import org.robolectric.Robolectric.buildActivity
3036
import org.robolectric.Shadows.shadowOf
3137
import org.robolectric.annotation.Config
@@ -225,6 +231,142 @@ class MaskingOptionsTest {
225231
assertTrue(textNode.shouldMask)
226232
assertTrue(imageNode.shouldMask)
227233
}
234+
235+
@Test
236+
fun `views are masked when class name matches mask package pattern`() {
237+
val textView = TextView(ApplicationProvider.getApplicationContext()).apply {
238+
text = "Test text"
239+
visibility = View.VISIBLE
240+
measure(View.MeasureSpec.makeMeasureSpec(100, View.MeasureSpec.EXACTLY),
241+
View.MeasureSpec.makeMeasureSpec(50, View.MeasureSpec.EXACTLY))
242+
layout(0, 0, 100, 50)
243+
}
244+
val imageView = ImageView(ApplicationProvider.getApplicationContext()).apply {
245+
visibility = View.VISIBLE
246+
measure(View.MeasureSpec.makeMeasureSpec(100, View.MeasureSpec.EXACTLY),
247+
View.MeasureSpec.makeMeasureSpec(50, View.MeasureSpec.EXACTLY))
248+
layout(0, 0, 100, 50)
249+
}
250+
251+
// Create a bitmap drawable that should be considered maskable
252+
val bitmap = Bitmap.createBitmap(50, 50, Bitmap.Config.ARGB_8888)
253+
val context = ApplicationProvider.getApplicationContext<Context>()
254+
val drawable = BitmapDrawable(context.resources, bitmap)
255+
imageView.setImageDrawable(drawable)
256+
257+
val options =
258+
SentryOptions().apply {
259+
sessionReplay.addMaskPackage("android.widget.*")
260+
}
261+
262+
val textNode = ViewHierarchyNode.fromView(textView, null, 0, options)
263+
val imageNode = ViewHierarchyNode.fromView(imageView, null, 0, options)
264+
265+
// Both views from android.widget.* should be masked
266+
assertTrue(textNode.shouldMask)
267+
assertTrue(imageNode.shouldMask)
268+
}
269+
270+
@Test
271+
fun `views are unmasked when class name matches unmask package pattern`() {
272+
val textView = TextView(ApplicationProvider.getApplicationContext())
273+
val imageView = ImageView(ApplicationProvider.getApplicationContext())
274+
275+
// Create a bitmap drawable that should be considered maskable
276+
val bitmap = Bitmap.createBitmap(50, 50, Bitmap.Config.ARGB_8888)
277+
val context = ApplicationProvider.getApplicationContext<Context>()
278+
val drawable = BitmapDrawable(context.resources, bitmap)
279+
imageView.setImageDrawable(drawable)
280+
281+
val options =
282+
SentryOptions().apply {
283+
sessionReplay.addMaskPackage("android.*")
284+
sessionReplay.addUnmaskPackage("android.widget.*")
285+
}
286+
287+
val textNode = ViewHierarchyNode.fromView(textView, null, 0, options)
288+
val imageNode = ViewHierarchyNode.fromView(imageView, null, 0, options)
289+
290+
// Both views should be unmasked due to more specific unmask pattern
291+
assertFalse(textNode.shouldMask)
292+
assertFalse(imageNode.shouldMask)
293+
}
294+
295+
@Test
296+
fun `views are masked with specific package patterns`() {
297+
val textView = TextView(ApplicationProvider.getApplicationContext()).apply {
298+
text = "Test text"
299+
visibility = View.VISIBLE
300+
measure(View.MeasureSpec.makeMeasureSpec(100, View.MeasureSpec.EXACTLY),
301+
View.MeasureSpec.makeMeasureSpec(50, View.MeasureSpec.EXACTLY))
302+
layout(0, 0, 100, 50)
303+
}
304+
val linearLayout = LinearLayout(ApplicationProvider.getApplicationContext()).apply {
305+
visibility = View.VISIBLE
306+
measure(View.MeasureSpec.makeMeasureSpec(100, View.MeasureSpec.EXACTLY),
307+
View.MeasureSpec.makeMeasureSpec(50, View.MeasureSpec.EXACTLY))
308+
layout(0, 0, 100, 50)
309+
}
310+
311+
val options =
312+
SentryOptions().apply {
313+
sessionReplay.addMaskPackage("android.widget.TextView")
314+
}
315+
316+
val textNode = ViewHierarchyNode.fromView(textView, null, 0, options)
317+
val layoutNode = ViewHierarchyNode.fromView(linearLayout, null, 0, options)
318+
319+
// TextView should be masked by exact match
320+
assertTrue(textNode.shouldMask)
321+
// LinearLayout should not be masked
322+
assertFalse(layoutNode.shouldMask)
323+
}
324+
325+
@Test
326+
fun `package patterns work with multiple patterns`() {
327+
val textView = TextView(ApplicationProvider.getApplicationContext()).apply {
328+
text = "Test text"
329+
visibility = View.VISIBLE
330+
measure(View.MeasureSpec.makeMeasureSpec(100, View.MeasureSpec.EXACTLY),
331+
View.MeasureSpec.makeMeasureSpec(50, View.MeasureSpec.EXACTLY))
332+
layout(0, 0, 100, 50)
333+
}
334+
val imageView = ImageView(ApplicationProvider.getApplicationContext()).apply {
335+
visibility = View.VISIBLE
336+
measure(View.MeasureSpec.makeMeasureSpec(100, View.MeasureSpec.EXACTLY),
337+
View.MeasureSpec.makeMeasureSpec(50, View.MeasureSpec.EXACTLY))
338+
layout(0, 0, 100, 50)
339+
}
340+
val linearLayout = LinearLayout(ApplicationProvider.getApplicationContext()).apply {
341+
visibility = View.VISIBLE
342+
measure(View.MeasureSpec.makeMeasureSpec(100, View.MeasureSpec.EXACTLY),
343+
View.MeasureSpec.makeMeasureSpec(50, View.MeasureSpec.EXACTLY))
344+
layout(0, 0, 100, 50)
345+
}
346+
347+
// Create a bitmap drawable that should be considered maskable
348+
val bitmap = Bitmap.createBitmap(50, 50, Bitmap.Config.ARGB_8888)
349+
val context = ApplicationProvider.getApplicationContext<Context>()
350+
val drawable = BitmapDrawable(context.resources, bitmap)
351+
imageView.setImageDrawable(drawable)
352+
353+
val options =
354+
SentryOptions().apply {
355+
sessionReplay.addMaskPackage("android.widget.TextView")
356+
sessionReplay.addMaskPackage("android.widget.ImageView")
357+
sessionReplay.addUnmaskPackage("android.widget.LinearLayout")
358+
}
359+
360+
val textNode = ViewHierarchyNode.fromView(textView, null, 0, options)
361+
val imageNode = ViewHierarchyNode.fromView(imageView, null, 0, options)
362+
val layoutNode = ViewHierarchyNode.fromView(linearLayout, null, 0, options)
363+
364+
// TextView and ImageView should be masked by exact matches
365+
assertTrue(textNode.shouldMask)
366+
assertTrue(imageNode.shouldMask)
367+
// LinearLayout should not be masked (not in any mask patterns)
368+
assertFalse(layoutNode.shouldMask)
369+
}
228370
}
229371

230372
private class CustomView(context: Context) : View(context) {

sentry/api/sentry.api

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3665,10 +3665,13 @@ public final class io/sentry/SentryReplayOptions {
36653665
public static final field WEB_VIEW_CLASS_NAME Ljava/lang/String;
36663666
public fun <init> (Ljava/lang/Double;Ljava/lang/Double;Lio/sentry/protocol/SdkVersion;)V
36673667
public fun <init> (ZLio/sentry/protocol/SdkVersion;)V
3668+
public fun addMaskPackage (Ljava/lang/String;)V
36683669
public fun addMaskViewClass (Ljava/lang/String;)V
3670+
public fun addUnmaskPackage (Ljava/lang/String;)V
36693671
public fun addUnmaskViewClass (Ljava/lang/String;)V
36703672
public fun getErrorReplayDuration ()J
36713673
public fun getFrameRate ()I
3674+
public fun getMaskPackagePatterns ()Ljava/util/Set;
36723675
public fun getMaskViewClasses ()Ljava/util/Set;
36733676
public fun getMaskViewContainerClass ()Ljava/lang/String;
36743677
public fun getOnErrorSampleRate ()Ljava/lang/Double;
@@ -3677,6 +3680,7 @@ public final class io/sentry/SentryReplayOptions {
36773680
public fun getSessionDuration ()J
36783681
public fun getSessionSampleRate ()Ljava/lang/Double;
36793682
public fun getSessionSegmentDuration ()J
3683+
public fun getUnmaskPackagePatterns ()Ljava/util/Set;
36803684
public fun getUnmaskViewClasses ()Ljava/util/Set;
36813685
public fun getUnmaskViewContainerClass ()Ljava/lang/String;
36823686
public fun isDebug ()Z

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

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,30 @@ public enum SentryReplayQuality {
9191
*/
9292
private Set<String> unmaskViewClasses = new CopyOnWriteArraySet<>();
9393

94+
/**
95+
* Mask all views with the specified package name patterns. The package name pattern can include
96+
* wildcards (*) to match multiple packages. For example, "com.thirdparty.*" will mask all
97+
* views from packages starting with "com.thirdparty.".
98+
*
99+
* <p>If you're using an obfuscation tool, make sure to add the respective proguard rules to keep
100+
* the package names.
101+
*
102+
* <p>Default is empty.
103+
*/
104+
private Set<String> maskPackagePatterns = new CopyOnWriteArraySet<>();
105+
106+
/**
107+
* Ignore all views with the specified package name patterns from masking. The package name pattern can include
108+
* wildcards (*) to match multiple packages. For example, "com.myapp.*" will unmask all
109+
* views from packages starting with "com.myapp.".
110+
*
111+
* <p>If you're using an obfuscation tool, make sure to add the respective proguard rules to keep
112+
* the package names.
113+
*
114+
* <p>Default is empty.
115+
*/
116+
private Set<String> unmaskPackagePatterns = new CopyOnWriteArraySet<>();
117+
94118
/** The class name of the view container that masks all of its children. */
95119
private @Nullable String maskViewContainerClass = null;
96120

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

279+
@NotNull
280+
public Set<String> getMaskPackagePatterns() {
281+
return this.maskPackagePatterns;
282+
}
283+
284+
public void addMaskPackage(final @NotNull String packagePattern) {
285+
this.maskPackagePatterns.add(packagePattern);
286+
}
287+
288+
@NotNull
289+
public Set<String> getUnmaskPackagePatterns() {
290+
return this.unmaskPackagePatterns;
291+
}
292+
293+
public void addUnmaskPackage(final @NotNull String packagePattern) {
294+
this.unmaskPackagePatterns.add(packagePattern);
295+
}
296+
255297
@ApiStatus.Internal
256298
public @NotNull SentryReplayQuality getQuality() {
257299
return quality;

sentry/src/main/java/io/sentry/rrweb/RRWebOptionsEvent.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ public RRWebOptionsEvent(final @NotNull SentryOptions options) {
5252
optionsPayload.put("quality", replayOptions.getQuality().serializedName());
5353
optionsPayload.put("maskedViewClasses", replayOptions.getMaskViewClasses());
5454
optionsPayload.put("unmaskedViewClasses", replayOptions.getUnmaskViewClasses());
55+
optionsPayload.put("maskedPackagePatterns", replayOptions.getMaskPackagePatterns());
56+
optionsPayload.put("unmaskedPackagePatterns", replayOptions.getUnmaskPackagePatterns());
5557
}
5658

5759
@NotNull
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package io.sentry.util;
2+
3+
import java.util.Set;
4+
import org.jetbrains.annotations.NotNull;
5+
6+
/**
7+
* Utility class for pattern matching operations, primarily used for Session Replay masking.
8+
*/
9+
public final class PatternUtils {
10+
11+
private PatternUtils() {}
12+
13+
/**
14+
* Checks if a given string matches a pattern. The pattern can contain wildcards (*) only at the
15+
* end to match any sequence of characters as a suffix.
16+
*
17+
* @param input the string to check
18+
* @param pattern the pattern to match against (only suffix wildcards are supported)
19+
* @return true if the input matches the pattern, false otherwise
20+
*/
21+
public static boolean matchesPattern(final @NotNull String input, final @NotNull String pattern) {
22+
// If pattern doesn't contain wildcard, do exact match
23+
if (!pattern.contains("*")) {
24+
return input.equals(pattern);
25+
}
26+
27+
// Only support suffix wildcards (pattern ending with *)
28+
if (!pattern.endsWith("*")) {
29+
return false;
30+
}
31+
32+
// Check if pattern has wildcards in the middle or beginning (not supported)
33+
final String prefix = pattern.substring(0, pattern.length() - 1);
34+
if (prefix.contains("*")) {
35+
return false;
36+
}
37+
38+
// Check if input starts with the prefix
39+
return input.startsWith(prefix);
40+
}
41+
42+
/**
43+
* Checks if a given string matches any of the provided patterns. Patterns can contain wildcards
44+
* (*) only at the end to match any sequence of characters as a suffix.
45+
*
46+
* @param input the string to check
47+
* @param patterns the set of patterns to match against
48+
* @return true if the input matches any of the patterns, false otherwise
49+
*/
50+
public static boolean matchesAnyPattern(
51+
final @NotNull String input, final @NotNull Set<String> patterns) {
52+
for (final String pattern : patterns) {
53+
if (matchesPattern(input, pattern)) {
54+
return true;
55+
}
56+
}
57+
return false;
58+
}
59+
}

0 commit comments

Comments
 (0)