Skip to content

Commit 8afa77c

Browse files
romtsnclaude
andcommitted
feat(screenshot): Add screenshot masking using view hierarchy
Adds masking support to error screenshots by reusing the Session Replay masking logic. This allows sensitive content (text, images) to be masked before attaching screenshots to error events. - Add SentryMaskingOptions base class for shared masking configuration - Add SentryScreenshotOptions for screenshot-specific masking settings - Create MaskRenderer utility for shared mask rendering (used by both replay and screenshots) - Add manifest metadata support for screenshot masking options - Add snapshot tests with Dropbox Differ library for visual regression - Update CLAUDE.md with dependency management guidelines Masking requires the sentry-android-replay module to be present at runtime. Without it, screenshots are captured without masking. Refs: #3286 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 0eaac1e commit 8afa77c

34 files changed

+1508
-311
lines changed

CLAUDE.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,11 @@ 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

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)