Skip to content

Commit 0d66c0b

Browse files
romtsnclaude
andauthored
feat(screenshot): Add screenshot masking using view hierarchy (#5077)
* 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> * Changelog * dontwarn about classes we check via reflection at runtime * pr id * fix(screenshot): Only warn about missing replay module when masking is configured The isMaskingEnabled() method was logging a warning before checking if masking was actually configured. This caused users who never set up screenshot masking to see spurious warnings on every event. Co-Authored-By: Claude <noreply@anthropic.com> * fix(screenshot): Remove sensitive view classes when setMaskAllImages(false) is called setMaskAllImages(true) was adding WebView, VideoView, and ExoPlayer classes to maskViewClasses, but setMaskAllImages(false) only removed ImageView. This caused asymmetric toggle behavior where disabling image masking didn't restore the original state. Co-Authored-By: Claude <noreply@anthropic.com> * fix(screenshot): Recycle bitmap copy on masking failure to prevent memory leak When an exception occurred in applyMasking after creating a mutable copy of the bitmap, the catch block returned the original screenshot without recycling the copy. This caused bitmap memory to accumulate until GC runs, potentially causing OOM issues on frequent errors. Co-Authored-By: Claude <noreply@anthropic.com> * fix: Resolve merge conflicts with main and integrate trackCustomMasking Move trackCustomMasking() to SentryMaskingOptions as an abstract method so it can be called polymorphically from replay view hierarchy code. SentryReplayOptions provides the real implementation, while SentryScreenshotOptions provides a no-op. Also adds CAMERAX_PREVIEW_VIEW_CLASS_NAME to SentryMaskingOptions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Clean up slop * fix(test): Implement abstract trackCustomMasking in test stub Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(screenshot): Use peekDecorView instead of getDecorView peekDecorView returns null if the decor view hasn't been created yet, avoiding forced creation. This is consistent with the rest of the codebase (ScreenshotUtils, ViewHierarchyEventProcessor). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(screenshot): Per-call MaskRenderer, main-thread VH capture, and don't leak unmasked screenshots - Use per-call MaskRenderer via try-with-resources instead of shared instance - Remove Closeable from ScreenshotEventProcessor (nothing to clean up) - Capture view hierarchy on main thread via runOnUiThread + CountDownLatch - Apply masking on the calling thread (only VH traversal needs main thread) - Return null on masking failure to avoid sending unmasked screenshots - Fix setMaskViewContainerClass to not trigger trackCustomMasking Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix tests and remove slop * clean up * fix(masking): Remove from opposite set when adding mask/unmask view class addMaskViewClass now removes from unmaskViewClasses and vice versa, preventing stale entries from silently blocking masking when setMaskAllText(false)/setMaskAllImages(false) is called with defaults. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * delegate to super in SentryReplayOptions * Do not capture screenshot when copy bitmap fails * fix(screenshot): Recycle bitmaps on all early-return paths to prevent memory leaks Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(screenshot): Log missing replay module warning once in constructor instead of per event Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Move PR id info to AGENTS.md * refactor: Rename getScreenshotOptions() to getScreenshot() to match getSessionReplay() pattern Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: Move screenshot masking changelog entry to Unreleased with code snippets Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(screenshot): Avoid crash from uncaught exception in view hierarchy traversal and unnecessary bitmap alloc in MaskRenderer.close() Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: Fix MaskRendererTest after lazy bitmap init guard and simplify test setup Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(screenshot): Wrap runOnUiThread in try-catch to handle destroyed activity race condition Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Bail out early if replay module is not available but masking is enabled for screenshots * fix(test): Expect no screenshot when masking configured without replay module Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 382d6c1 commit 0d66c0b

File tree

37 files changed

+1668
-295
lines changed

37 files changed

+1668
-295
lines changed

AGENTS.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,21 @@ The repository is organized into multiple modules:
144144
4. New features must be **opt-in by default** - extend `SentryOptions` or similar Option classes with getters/setters
145145
5. Consider backwards compatibility
146146

147+
### Getting PR Information
148+
149+
Use `gh pr view` to get PR details from the current branch. This is needed when adding changelog entries, which require the PR number.
150+
151+
```bash
152+
# Get PR number for current branch
153+
gh pr view --json number -q '.number'
154+
155+
# Get PR number for a specific branch
156+
gh pr view <branch-name> --json number -q '.number'
157+
158+
# Get PR URL
159+
gh pr view --json url -q '.url'
160+
```
161+
147162
## Useful Resources
148163

149164
- Main SDK documentation: https://develop.sentry.dev/sdk/overview/

CHANGELOG.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,28 @@
22

33
## Unreleased
44

5+
### Features
6+
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 using the same view hierarchy approach as Session Replay
9+
- Requires the `sentry-android-replay` module to be present at runtime for masking to work
10+
- Enable via code:
11+
```kotlin
12+
SentryAndroid.init(context) { options ->
13+
options.isAttachScreenshot = true
14+
options.screenshot.setMaskAllText(true)
15+
options.screenshot.setMaskAllImages(true)
16+
// Or mask specific view classes
17+
options.screenshot.addMaskViewClass("com.example.MyCustomView")
18+
}
19+
```
20+
- Or via `AndroidManifest.xml`:
21+
```xml
22+
<meta-data android:name="io.sentry.attach-screenshot" android:value="true" />
23+
<meta-data android:name="io.sentry.screenshot.mask-all-text" android:value="true" />
24+
<meta-data android:name="io.sentry.screenshot.mask-all-images" android:value="true" />
25+
```
26+
527
### Fixes
628

729
- Fix crash when unregistering `SystemEventsBroadcastReceiver` with try-catch block. ([#5106](https://github.com/getsentry/sentry-java/pull/5106))

CLAUDE.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,4 @@
33
## STOP — Required Reading (Do This First)
44

55
Before doing ANYTHING else (including answering questions), you MUST use the Read tool to load [AGENTS.md](AGENTS.md) and follow ALL of its instructions, including reading the required `.cursor/rules/*.mdc` files it references.
6-
76
Do NOT skip this step. Do NOT proceed without reading these files first.

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: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -326,7 +326,7 @@ public final class io/sentry/android/core/NetworkBreadcrumbsIntegration : io/sen
326326
}
327327

328328
public final class io/sentry/android/core/ScreenshotEventProcessor : io/sentry/EventProcessor {
329-
public fun <init> (Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/android/core/BuildInfoProvider;)V
329+
public fun <init> (Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/android/core/BuildInfoProvider;Z)V
330330
public fun getOrder ()Ljava/lang/Long;
331331
public fun process (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/SentryEvent;
332332
public fun process (Lio/sentry/protocol/SentryTransaction;Lio/sentry/Hint;)Lio/sentry/protocol/SentryTransaction;
@@ -354,6 +354,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr
354354
public fun getFrameMetricsCollector ()Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector;
355355
public fun getNativeSdkName ()Ljava/lang/String;
356356
public fun getNdkHandlerStrategy ()I
357+
public fun getScreenshot ()Lio/sentry/android/core/SentryScreenshotOptions;
357358
public fun getStartupCrashDurationThresholdMillis ()J
358359
public fun isAnrEnabled ()Z
359360
public fun isAnrReportInDebug ()Z
@@ -450,6 +451,12 @@ public final class io/sentry/android/core/SentryPerformanceProvider {
450451
public fun shutdown ()V
451452
}
452453

454+
public final class io/sentry/android/core/SentryScreenshotOptions : io/sentry/SentryMaskingOptions {
455+
public fun <init> ()V
456+
public fun setMaskAllImages (Z)V
457+
public fun trackCustomMasking ()V
458+
}
459+
453460
public class io/sentry/android/core/SentryUserFeedbackButton : android/widget/Button {
454461
public fun <init> (Landroid/content/Context;)V
455462
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
@@ -170,6 +170,10 @@ final class ManifestMetadataReader {
170170

171171
static final String SPOTLIGHT_CONNECTION_URL = "io.sentry.spotlight.url";
172172

173+
static final String SCREENSHOT_MASK_ALL_TEXT = "io.sentry.screenshot.mask-all-text";
174+
175+
static final String SCREENSHOT_MASK_ALL_IMAGES = "io.sentry.screenshot.mask-all-images";
176+
173177
/** ManifestMetadataReader ctor */
174178
private ManifestMetadataReader() {}
175179

@@ -659,6 +663,14 @@ static void applyMetadata(
659663
if (spotlightUrl != null) {
660664
options.setSpotlightConnectionUrl(spotlightUrl);
661665
}
666+
667+
// Screenshot masking options (default to false for backwards compatibility)
668+
options
669+
.getScreenshot()
670+
.setMaskAllText(readBool(metadata, logger, SCREENSHOT_MASK_ALL_TEXT, false));
671+
options
672+
.getScreenshot()
673+
.setMaskAllImages(readBool(metadata, logger, SCREENSHOT_MASK_ALL_IMAGES, false));
662674
}
663675
options
664676
.getLogger()

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

Lines changed: 143 additions & 3 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,16 @@
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.util.concurrent.CountDownLatch;
25+
import java.util.concurrent.TimeUnit;
26+
import java.util.concurrent.atomic.AtomicBoolean;
27+
import java.util.concurrent.atomic.AtomicReference;
2028
import org.jetbrains.annotations.ApiStatus;
2129
import org.jetbrains.annotations.NotNull;
2230
import org.jetbrains.annotations.Nullable;
@@ -34,10 +42,15 @@ public final class ScreenshotEventProcessor implements EventProcessor {
3442
private final @NotNull Debouncer debouncer;
3543
private static final long DEBOUNCE_WAIT_TIME_MS = 2000;
3644
private static final int DEBOUNCE_MAX_EXECUTIONS = 3;
45+
private static final long MASKING_TIMEOUT_MS = 2000;
46+
47+
private final boolean isReplayAvailable;
48+
private final AtomicBoolean isReplayModuleAbsenceLogged = new AtomicBoolean(false);
3749

3850
public ScreenshotEventProcessor(
3951
final @NotNull SentryAndroidOptions options,
40-
final @NotNull BuildInfoProvider buildInfoProvider) {
52+
final @NotNull BuildInfoProvider buildInfoProvider,
53+
final boolean isReplayAvailable) {
4154
this.options = Objects.requireNonNull(options, "SentryAndroidOptions is required");
4255
this.buildInfoProvider =
4356
Objects.requireNonNull(buildInfoProvider, "BuildInfoProvider is required");
@@ -47,11 +60,17 @@ public ScreenshotEventProcessor(
4760
DEBOUNCE_WAIT_TIME_MS,
4861
DEBOUNCE_MAX_EXECUTIONS);
4962

63+
this.isReplayAvailable = isReplayAvailable;
64+
5065
if (options.isAttachScreenshot()) {
5166
addIntegrationToSdkVersion("Screenshot");
5267
}
5368
}
5469

70+
private boolean isMaskingEnabled() {
71+
return !options.getScreenshot().getMaskViewClasses().isEmpty() && isReplayAvailable;
72+
}
73+
5574
@Override
5675
public @NotNull SentryTransaction process(
5776
@NotNull SentryTransaction transaction, @NotNull Hint hint) {
@@ -71,6 +90,15 @@ public ScreenshotEventProcessor(
7190

7291
return event;
7392
}
93+
if (!isReplayAvailable && !options.getScreenshot().getMaskViewClasses().isEmpty()) {
94+
if (!isReplayModuleAbsenceLogged.getAndSet(true)) {
95+
options
96+
.getLogger()
97+
.log(SentryLevel.WARNING, "Screenshot masking requires sentry-android-replay module");
98+
}
99+
return event;
100+
}
101+
74102
final @Nullable Activity activity = CurrentActivityHolder.getInstance().getActivity();
75103
if (activity == null || HintUtils.isFromHybridSdk(hint)) {
76104
return event;
@@ -89,23 +117,135 @@ public ScreenshotEventProcessor(
89117
return event;
90118
}
91119

92-
final Bitmap screenshot =
120+
Bitmap screenshot =
93121
captureScreenshot(
94122
activity, options.getThreadChecker(), options.getLogger(), buildInfoProvider);
95123
if (screenshot == null) {
96124
return event;
97125
}
98126

127+
// Apply masking if enabled and replay module is available
128+
if (isMaskingEnabled()) {
129+
final @Nullable ViewHierarchyNode rootNode = captureViewHierarchy(activity);
130+
if (rootNode == null) {
131+
screenshot.recycle();
132+
return event;
133+
}
134+
final @Nullable Bitmap masked = applyMasking(screenshot, rootNode);
135+
if (masked == null) {
136+
// applyMasking already recycles its bitmaps on failure
137+
return event;
138+
}
139+
screenshot = masked;
140+
}
141+
142+
final Bitmap finalScreenshot = screenshot;
99143
hint.setScreenshot(
100144
Attachment.fromByteProvider(
101-
() -> ScreenshotUtils.compressBitmapToPng(screenshot, options.getLogger()),
145+
() -> ScreenshotUtils.compressBitmapToPng(finalScreenshot, options.getLogger()),
102146
"screenshot.png",
103147
"image/png",
104148
false));
105149
hint.set(ANDROID_ACTIVITY, activity);
106150
return event;
107151
}
108152

153+
/**
154+
* Captures the view hierarchy on the main thread, since view traversal requires it. If already on
155+
* the main thread, captures directly; otherwise posts to the main thread and waits.
156+
*/
157+
private @Nullable ViewHierarchyNode captureViewHierarchy(final @NotNull Activity activity) {
158+
if (options.getThreadChecker().isMainThread()) {
159+
return buildViewHierarchy(activity);
160+
}
161+
162+
final AtomicReference<ViewHierarchyNode> result = new AtomicReference<>(null);
163+
final CountDownLatch latch = new CountDownLatch(1);
164+
165+
try {
166+
activity.runOnUiThread(
167+
() -> {
168+
try {
169+
result.set(buildViewHierarchy(activity));
170+
} finally {
171+
latch.countDown();
172+
}
173+
});
174+
175+
if (!latch.await(MASKING_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
176+
options
177+
.getLogger()
178+
.log(
179+
SentryLevel.WARNING, "Timed out waiting for view hierarchy capture on main thread");
180+
return null;
181+
}
182+
} catch (Throwable e) {
183+
options.getLogger().log(SentryLevel.ERROR, "Failed to capture view hierarchy", e);
184+
return null;
185+
}
186+
187+
return result.get();
188+
}
189+
190+
private @Nullable ViewHierarchyNode buildViewHierarchy(final @NotNull Activity activity) {
191+
try {
192+
final @Nullable View rootView =
193+
activity.getWindow() != null
194+
&& activity.getWindow().peekDecorView() != null
195+
&& activity.getWindow().peekDecorView().getRootView() != null
196+
? activity.getWindow().peekDecorView().getRootView()
197+
: null;
198+
if (rootView == null) {
199+
return null;
200+
}
201+
202+
final ViewHierarchyNode rootNode =
203+
ViewHierarchyNode.Companion.fromView(rootView, null, 0, options.getScreenshot());
204+
ViewsKt.traverse(rootView, rootNode, options.getScreenshot(), options.getLogger());
205+
return rootNode;
206+
} catch (Throwable e) {
207+
options.getLogger().log(SentryLevel.ERROR, "Failed to build view hierarchy", e);
208+
return null;
209+
}
210+
}
211+
212+
private @Nullable Bitmap applyMasking(
213+
final @NotNull Bitmap screenshot, final @NotNull ViewHierarchyNode rootNode) {
214+
Bitmap mutableBitmap = screenshot;
215+
boolean createdCopy = false;
216+
try (final MaskRenderer maskRenderer = new MaskRenderer()) {
217+
// Make bitmap mutable if needed
218+
if (!screenshot.isMutable()) {
219+
mutableBitmap = screenshot.copy(Bitmap.Config.ARGB_8888, true);
220+
if (mutableBitmap == null) {
221+
screenshot.recycle();
222+
return null;
223+
}
224+
createdCopy = true;
225+
}
226+
227+
maskRenderer.renderMasks(mutableBitmap, rootNode, null);
228+
229+
// Recycle original if we created a copy
230+
if (createdCopy && !screenshot.isRecycled()) {
231+
screenshot.recycle();
232+
}
233+
234+
return mutableBitmap;
235+
} catch (Throwable e) {
236+
options.getLogger().log(SentryLevel.ERROR, "Failed to mask screenshot", e);
237+
if (createdCopy) {
238+
if (!mutableBitmap.isRecycled()) {
239+
mutableBitmap.recycle();
240+
}
241+
}
242+
if (!screenshot.isRecycled()) {
243+
screenshot.recycle();
244+
}
245+
return null;
246+
}
247+
}
248+
109249
@Override
110250
public @Nullable Long getOrder() {
111251
return 10000L;

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 screenshot = new SentryScreenshotOptions();
254+
246255
public SentryAndroidOptions() {
247256
setSentryClientName(BuildConfig.SENTRY_ANDROID_SDK_NAME + "/" + BuildConfig.VERSION_NAME);
248257
setSdkVersion(createSdkVersion());
@@ -677,6 +686,15 @@ public void setEnableSystemEventBreadcrumbsExtras(
677686
this.enableSystemEventBreadcrumbsExtras = enableSystemEventBreadcrumbsExtras;
678687
}
679688

689+
/**
690+
* Returns the screenshot masking options.
691+
*
692+
* @return the screenshot masking options
693+
*/
694+
public @NotNull SentryScreenshotOptions getScreenshot() {
695+
return screenshot;
696+
}
697+
680698
static class AndroidUserFeedbackIDialogHandler implements SentryFeedbackOptions.IDialogHandler {
681699
@Override
682700
public void showDialog(

0 commit comments

Comments
 (0)