Skip to content

Commit dd8c794

Browse files
authored
Merge ad8d6df into dc4cc7a
2 parents dc4cc7a + ad8d6df commit dd8c794

File tree

36 files changed

+1544
-311
lines changed

36 files changed

+1544
-311
lines changed

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,23 @@
44

55
### Features
66

7+
- Add screenshot masking support using view hierarchy ([#5077](https://github.com/getsentry/sentry-java/pull/5077))
8+
- Masks sensitive content (text, images) in error screenshots before sending to Sentry
9+
- Reuses Session Replay's masking logic; **requires `sentry-android-replay` module at runtime**
10+
- To enable masking programmatically:
11+
```kotlin
12+
SentryAndroid.init(context) { options ->
13+
options.isAttachScreenshot = true
14+
options.screenshotOptions.setMaskAllText(true)
15+
options.screenshotOptions.setMaskAllImages(true)
16+
}
17+
```
18+
- Or via AndroidManifest.xml:
19+
```xml
20+
<meta-data android:name="io.sentry.attach-screenshot" android:value="true" />
21+
<meta-data android:name="io.sentry.screenshot.mask-all-text" android:value="true" />
22+
<meta-data android:name="io.sentry.screenshot.mask-all-images" android:value="true" />
23+
```
724
- Add `installGroupsOverride` parameter and `installGroups` property to Build Distribution SDK ([#5062](https://github.com/getsentry/sentry-java/pull/5062))
825
- Update Android targetSdk to API 36 (Android 16) ([#5016](https://github.com/getsentry/sentry-java/pull/5016))
926
- Add AndroidManifest support for Spotlight configuration via `io.sentry.spotlight.enable` and `io.sentry.spotlight.url` ([#5064](https://github.com/getsentry/sentry-java/pull/5064))

CLAUDE.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,13 +127,33 @@ The repository is organized into multiple modules:
127127
- System tests validate end-to-end functionality with sample applications
128128
- Coverage reports are generated for both JaCoCo (Java/Android) and Kover (KMP modules)
129129

130+
### Dependency Management
131+
- All dependencies must be declared in `gradle/libs.versions.toml` (Gradle version catalog)
132+
- Reference dependencies in build files using the `libs.` accessor (e.g., `libs.dropbox.differ`)
133+
- Never hardcode version strings directly in `build.gradle.kts` files
134+
130135
### Contributing Guidelines
131136
1. Follow existing code style and language
132137
2. Do not modify API files (e.g. sentry.api) manually - run `./gradlew apiDump` to regenerate them
133138
3. Write comprehensive tests
134139
4. New features must be **opt-in by default** - extend `SentryOptions` or similar Option classes with getters/setters
135140
5. Consider backwards compatibility
136141

142+
## Getting PR Information
143+
144+
Use `gh pr view` to get PR details from the current branch. This is needed when adding changelog entries, which require the PR number.
145+
146+
```bash
147+
# Get PR number for current branch
148+
gh pr view --json number -q '.number'
149+
150+
# Get PR number for a specific branch
151+
gh pr view <branch-name> --json number -q '.number'
152+
153+
# Get PR URL
154+
gh pr view --json url -q '.url'
155+
```
156+
137157
## Domain-Specific Knowledge Areas
138158

139159
For complex SDK functionality, refer to the detailed cursor rules in `.cursor/rules/`:

gradle/libs.versions.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,3 +236,4 @@ msgpack = { module = "org.msgpack:msgpack-core", version = "0.9.8" }
236236
okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" }
237237
okio = { module = "com.squareup.okio:okio", version = "1.13.0" }
238238
roboelectric = { module = "org.robolectric:robolectric", version = "4.14" }
239+
dropbox-differ = { module = "com.dropbox.differ:differ-jvm", version = "0.3.0" }

sentry-android-core/api/sentry-android-core.api

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -312,8 +312,9 @@ public final class io/sentry/android/core/NetworkBreadcrumbsIntegration : io/sen
312312
public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V
313313
}
314314

315-
public final class io/sentry/android/core/ScreenshotEventProcessor : io/sentry/EventProcessor {
316-
public fun <init> (Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/android/core/BuildInfoProvider;)V
315+
public final class io/sentry/android/core/ScreenshotEventProcessor : io/sentry/EventProcessor, java/io/Closeable {
316+
public fun <init> (Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/android/core/BuildInfoProvider;Z)V
317+
public fun close ()V
317318
public fun getOrder ()Ljava/lang/Long;
318319
public fun process (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/SentryEvent;
319320
public fun process (Lio/sentry/protocol/SentryTransaction;Lio/sentry/Hint;)Lio/sentry/protocol/SentryTransaction;
@@ -341,6 +342,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr
341342
public fun getFrameMetricsCollector ()Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector;
342343
public fun getNativeSdkName ()Ljava/lang/String;
343344
public fun getNdkHandlerStrategy ()I
345+
public fun getScreenshotOptions ()Lio/sentry/android/core/SentryScreenshotOptions;
344346
public fun getStartupCrashDurationThresholdMillis ()J
345347
public fun isAnrEnabled ()Z
346348
public fun isAnrReportInDebug ()Z
@@ -437,6 +439,11 @@ public final class io/sentry/android/core/SentryPerformanceProvider {
437439
public fun shutdown ()V
438440
}
439441

442+
public final class io/sentry/android/core/SentryScreenshotOptions : io/sentry/SentryMaskingOptions {
443+
public fun <init> ()V
444+
public fun setMaskAllImages (Z)V
445+
}
446+
440447
public class io/sentry/android/core/SentryUserFeedbackButton : android/widget/Button {
441448
public fun <init> (Landroid/content/Context;)V
442449
public fun <init> (Landroid/content/Context;Landroid/util/AttributeSet;)V

sentry-android-core/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ dependencies {
108108
testImplementation(projects.sentryAndroidReplay)
109109
testImplementation(projects.sentryCompose)
110110
testImplementation(projects.sentryAndroidNdk)
111+
testImplementation(libs.dropbox.differ)
111112
testRuntimeOnly(libs.androidx.compose.ui)
112113
testRuntimeOnly(libs.androidx.fragment.ktx)
113114
testRuntimeOnly(libs.timber)

sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,8 @@ static void initializeIntegrationsAndProcessors(
188188
options.addEventProcessor(
189189
new DefaultAndroidEventProcessor(context, buildInfoProvider, options));
190190
options.addEventProcessor(new PerformanceAndroidEventProcessor(options, activityFramesTracker));
191-
options.addEventProcessor(new ScreenshotEventProcessor(options, buildInfoProvider));
191+
options.addEventProcessor(
192+
new ScreenshotEventProcessor(options, buildInfoProvider, isReplayAvailable));
192193
options.addEventProcessor(new ViewHierarchyEventProcessor(options));
193194
options.addEventProcessor(
194195
new ApplicationExitInfoEventProcessor(context, options, buildInfoProvider));

sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,10 @@ final class ManifestMetadataReader {
168168

169169
static final String SPOTLIGHT_CONNECTION_URL = "io.sentry.spotlight.url";
170170

171+
static final String SCREENSHOT_MASK_ALL_TEXT = "io.sentry.screenshot.mask-all-text";
172+
173+
static final String SCREENSHOT_MASK_ALL_IMAGES = "io.sentry.screenshot.mask-all-images";
174+
171175
/** ManifestMetadataReader ctor */
172176
private ManifestMetadataReader() {}
173177

@@ -655,6 +659,14 @@ static void applyMetadata(
655659
if (spotlightUrl != null) {
656660
options.setSpotlightConnectionUrl(spotlightUrl);
657661
}
662+
663+
// Screenshot masking options (default to false for backwards compatibility)
664+
options
665+
.getScreenshotOptions()
666+
.setMaskAllText(readBool(metadata, logger, SCREENSHOT_MASK_ALL_TEXT, false));
667+
options
668+
.getScreenshotOptions()
669+
.setMaskAllImages(readBool(metadata, logger, SCREENSHOT_MASK_ALL_IMAGES, false));
658670
}
659671
options
660672
.getLogger()

sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import android.app.Activity;
88
import android.graphics.Bitmap;
9+
import android.view.View;
910
import io.sentry.Attachment;
1011
import io.sentry.EventProcessor;
1112
import io.sentry.Hint;
@@ -14,9 +15,14 @@
1415
import io.sentry.android.core.internal.util.AndroidCurrentDateProvider;
1516
import io.sentry.android.core.internal.util.Debouncer;
1617
import io.sentry.android.core.internal.util.ScreenshotUtils;
18+
import io.sentry.android.replay.util.MaskRenderer;
19+
import io.sentry.android.replay.util.ViewsKt;
20+
import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode;
1721
import io.sentry.protocol.SentryTransaction;
1822
import io.sentry.util.HintUtils;
1923
import io.sentry.util.Objects;
24+
import java.io.Closeable;
25+
import java.io.IOException;
2026
import org.jetbrains.annotations.ApiStatus;
2127
import org.jetbrains.annotations.NotNull;
2228
import org.jetbrains.annotations.Nullable;
@@ -26,7 +32,7 @@
2632
* captured.
2733
*/
2834
@ApiStatus.Internal
29-
public final class ScreenshotEventProcessor implements EventProcessor {
35+
public final class ScreenshotEventProcessor implements EventProcessor, Closeable {
3036

3137
private final @NotNull SentryAndroidOptions options;
3238
private final @NotNull BuildInfoProvider buildInfoProvider;
@@ -35,9 +41,12 @@ public final class ScreenshotEventProcessor implements EventProcessor {
3541
private static final long DEBOUNCE_WAIT_TIME_MS = 2000;
3642
private static final int DEBOUNCE_MAX_EXECUTIONS = 3;
3743

44+
private @Nullable MaskRenderer maskRenderer = null;
45+
3846
public ScreenshotEventProcessor(
3947
final @NotNull SentryAndroidOptions options,
40-
final @NotNull BuildInfoProvider buildInfoProvider) {
48+
final @NotNull BuildInfoProvider buildInfoProvider,
49+
final boolean isReplayAvailable) {
4150
this.options = Objects.requireNonNull(options, "SentryAndroidOptions is required");
4251
this.buildInfoProvider =
4352
Objects.requireNonNull(buildInfoProvider, "BuildInfoProvider is required");
@@ -47,11 +56,25 @@ public ScreenshotEventProcessor(
4756
DEBOUNCE_WAIT_TIME_MS,
4857
DEBOUNCE_MAX_EXECUTIONS);
4958

59+
if (isReplayAvailable) {
60+
maskRenderer = new MaskRenderer();
61+
}
62+
5063
if (options.isAttachScreenshot()) {
5164
addIntegrationToSdkVersion("Screenshot");
5265
}
5366
}
5467

68+
private boolean isMaskingEnabled() {
69+
if (maskRenderer == null) {
70+
options
71+
.getLogger()
72+
.log(SentryLevel.WARNING, "Screenshot masking requires sentry-android-replay module");
73+
return false;
74+
}
75+
return !options.getScreenshotOptions().getMaskViewClasses().isEmpty();
76+
}
77+
5578
@Override
5679
public @NotNull SentryTransaction process(
5780
@NotNull SentryTransaction transaction, @NotNull Hint hint) {
@@ -89,25 +112,81 @@ public ScreenshotEventProcessor(
89112
return event;
90113
}
91114

92-
final Bitmap screenshot =
115+
Bitmap screenshot =
93116
captureScreenshot(
94117
activity, options.getThreadChecker(), options.getLogger(), buildInfoProvider);
95118
if (screenshot == null) {
96119
return event;
97120
}
98121

122+
// Apply masking if enabled and replay module is available
123+
if (isMaskingEnabled()) {
124+
final @Nullable View rootView =
125+
activity.getWindow() != null
126+
&& activity.getWindow().getDecorView() != null
127+
&& activity.getWindow().getDecorView().getRootView() != null
128+
? activity.getWindow().getDecorView().getRootView()
129+
: null;
130+
if (rootView != null) {
131+
screenshot = applyMasking(screenshot, rootView);
132+
}
133+
}
134+
135+
final Bitmap finalScreenshot = screenshot;
99136
hint.setScreenshot(
100137
Attachment.fromByteProvider(
101-
() -> ScreenshotUtils.compressBitmapToPng(screenshot, options.getLogger()),
138+
() -> ScreenshotUtils.compressBitmapToPng(finalScreenshot, options.getLogger()),
102139
"screenshot.png",
103140
"image/png",
104141
false));
105142
hint.set(ANDROID_ACTIVITY, activity);
106143
return event;
107144
}
108145

146+
private @NotNull Bitmap applyMasking(
147+
final @NotNull Bitmap screenshot, final @NotNull View rootView) {
148+
try {
149+
// Make bitmap mutable if needed
150+
Bitmap mutableBitmap = screenshot;
151+
if (!screenshot.isMutable()) {
152+
mutableBitmap = screenshot.copy(Bitmap.Config.ARGB_8888, true);
153+
if (mutableBitmap == null) {
154+
return screenshot;
155+
}
156+
}
157+
158+
// we can access it here, since it's "internal" only for Kotlin
159+
160+
// Build view hierarchy and apply masks
161+
final ViewHierarchyNode rootNode =
162+
ViewHierarchyNode.Companion.fromView(rootView, null, 0, options.getScreenshotOptions());
163+
ViewsKt.traverse(rootView, rootNode, options.getScreenshotOptions(), options.getLogger());
164+
165+
if (maskRenderer != null) {
166+
maskRenderer.renderMasks(mutableBitmap, rootNode, null);
167+
}
168+
169+
// Recycle original if we created a copy
170+
if (mutableBitmap != screenshot && !screenshot.isRecycled()) {
171+
screenshot.recycle();
172+
}
173+
174+
return mutableBitmap;
175+
} catch (Throwable e) {
176+
options.getLogger().log(SentryLevel.ERROR, "Failed to mask screenshot", e);
177+
return screenshot;
178+
}
179+
}
180+
109181
@Override
110182
public @Nullable Long getOrder() {
111183
return 10000L;
112184
}
185+
186+
@Override
187+
public void close() throws IOException {
188+
if (maskRenderer != null) {
189+
maskRenderer.close();
190+
}
191+
}
113192
}

sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,15 @@ public interface BeforeCaptureCallback {
243243

244244
private boolean enableTombstone = false;
245245

246+
/**
247+
* Screenshot masking options. Configure which views should be masked when capturing screenshots
248+
* on error events.
249+
*
250+
* <p>Note: Screenshot masking requires the {@code sentry-android-replay} module to be present at
251+
* runtime. If the replay module is not available, screenshots will be captured without masking.
252+
*/
253+
private final @NotNull SentryScreenshotOptions screenshotOptions = new SentryScreenshotOptions();
254+
246255
public SentryAndroidOptions() {
247256
setSentryClientName(BuildConfig.SENTRY_ANDROID_SDK_NAME + "/" + BuildConfig.VERSION_NAME);
248257
setSdkVersion(createSdkVersion());
@@ -681,6 +690,15 @@ public void setEnableSystemEventBreadcrumbsExtras(
681690
this.enableSystemEventBreadcrumbsExtras = enableSystemEventBreadcrumbsExtras;
682691
}
683692

693+
/**
694+
* Returns the screenshot masking options.
695+
*
696+
* @return the screenshot masking options
697+
*/
698+
public @NotNull SentryScreenshotOptions getScreenshotOptions() {
699+
return screenshotOptions;
700+
}
701+
684702
static class AndroidUserFeedbackIDialogHandler implements SentryFeedbackOptions.IDialogHandler {
685703
@Override
686704
public void showDialog(
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package io.sentry.android.core;
2+
3+
import io.sentry.SentryMaskingOptions;
4+
5+
/**
6+
* Screenshot masking options for error screenshots. Extends the base {@link SentryMaskingOptions}
7+
* with screenshot-specific defaults.
8+
*
9+
* <p>By default, masking is disabled for screenshots. Enable masking by calling {@link
10+
* #setMaskAllText(boolean)} and/or {@link #setMaskAllImages(boolean)}.
11+
*
12+
* <p>Note: Screenshot masking requires the {@code sentry-android-replay} module to be present at
13+
* runtime. If the replay module is not available, screenshots will be captured without masking.
14+
*/
15+
public final class SentryScreenshotOptions extends SentryMaskingOptions {
16+
17+
public SentryScreenshotOptions() {
18+
// Default to NO masking until next major version.
19+
// maskViewClasses starts empty, so nothing is masked by default.
20+
}
21+
22+
/**
23+
* {@inheritDoc}
24+
*
25+
* <p>When enabling image masking for screenshots, this also adds masking for WebView, VideoView,
26+
* and media player views (ExoPlayer, Media3) since they may contain sensitive content.
27+
*/
28+
@Override
29+
public void setMaskAllImages(final boolean maskAllImages) {
30+
super.setMaskAllImages(maskAllImages);
31+
if (maskAllImages) {
32+
addSensitiveViewClasses();
33+
}
34+
}
35+
36+
private void addSensitiveViewClasses() {
37+
addMaskViewClass(WEB_VIEW_CLASS_NAME);
38+
addMaskViewClass(VIDEO_VIEW_CLASS_NAME);
39+
addMaskViewClass(ANDROIDX_MEDIA_VIEW_CLASS_NAME);
40+
addMaskViewClass(EXOPLAYER_CLASS_NAME);
41+
addMaskViewClass(EXOPLAYER_STYLED_CLASS_NAME);
42+
}
43+
}

0 commit comments

Comments
 (0)