feat(screenshot): Add screenshot masking using view hierarchy#5077
feat(screenshot): Add screenshot masking using view hierarchy#5077
Conversation
Semver Impact of This PR🟡 Minor (new features) 📋 Changelog PreviewThis is how your changes will appear in the changelog. New Features ✨
Bug Fixes 🐛
Internal Changes 🔧Deps
Other
🤖 This preview updates automatically when you update the PR. |
|
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>
sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java
Show resolved
Hide resolved
8afa77c to
17beece
Compare
sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java
Show resolved
Hide resolved
sentry-android-core/src/main/java/io/sentry/android/core/SentryScreenshotOptions.java
Show resolved
Hide resolved
Performance metrics 🚀
|
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| 9ea89e8 | 308.06 ms | 358.16 ms | 50.10 ms |
| d15471f | 310.26 ms | 377.04 ms | 66.78 ms |
| d364ace | 382.77 ms | 443.21 ms | 60.44 ms |
| 806307f | 357.85 ms | 424.64 ms | 66.79 ms |
| 17a0955 | 372.53 ms | 446.70 ms | 74.17 ms |
| 6edfca2 | 316.43 ms | 398.90 ms | 82.46 ms |
| d15471f | 343.13 ms | 361.47 ms | 18.34 ms |
| 319f256 | 317.53 ms | 370.83 ms | 53.29 ms |
| 9fbb112 | 359.71 ms | 421.85 ms | 62.14 ms |
| 539ca63 | 313.51 ms | 355.43 ms | 41.92 ms |
App size
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| 9ea89e8 | 1.58 MiB | 2.28 MiB | 716.23 KiB |
| d15471f | 1.58 MiB | 2.13 MiB | 559.54 KiB |
| d364ace | 1.58 MiB | 2.11 MiB | 539.75 KiB |
| 806307f | 1.58 MiB | 2.10 MiB | 533.42 KiB |
| 17a0955 | 1.58 MiB | 2.10 MiB | 533.20 KiB |
| 6edfca2 | 1.58 MiB | 2.13 MiB | 559.07 KiB |
| d15471f | 1.58 MiB | 2.13 MiB | 559.54 KiB |
| 319f256 | 1.58 MiB | 2.19 MiB | 619.79 KiB |
| 9fbb112 | 1.58 MiB | 2.11 MiB | 539.18 KiB |
| 539ca63 | 1.58 MiB | 2.12 MiB | 551.41 KiB |
sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java
Show resolved
Hide resolved
…s 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>
…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>
…mory 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>
| if (createdCopy && !mutableBitmap.isRecycled()) { | ||
| mutableBitmap.recycle(); | ||
| } | ||
| return screenshot; |
There was a problem hiding this comment.
Unmasked screenshot sent when masking operation fails
Low Severity
When masking is explicitly enabled but fails (due to an exception during renderMasks or if bitmap.copy() returns null), the original unmasked screenshot is returned and attached to the error event. Users who configured masking expect sensitive content (text, images) to be protected, but this failure mode silently sends unmasked screenshots to Sentry. The safer behavior would be to skip attaching the screenshot entirely if masking was requested but couldn't be applied.
Additional Locations (1)
There was a problem hiding this comment.
The safer behavior would be to skip attaching the screenshot entirely if masking was requested but couldn't be applied.
Yes, I think we should play safe here and only attach screenshots if masking was successful
| public void setMaskAllImages(final boolean maskAllImages) { | ||
| super.setMaskAllImages(maskAllImages); | ||
| if (maskAllImages) { | ||
| addSensitiveViewClasses(); |
There was a problem hiding this comment.
I guess this is questionable if you enable masking images, we will also mask other media. But I didn't want to introduce new flag, so made it dependant on this one. We can document this, or introduce a new flag. Thoughts @markushi ?
I think adding those classes by default is probably a no-go for screenshots, since it's a single frame screenshot, not a sequence like in replay, where potentially a lot more could be leaked
There was a problem hiding this comment.
Yeah, I would just document it.
markushi
left a comment
There was a problem hiding this comment.
Looking good! Just one concern around threading I'd like to discuss before approving.
| // Apply masking if enabled and replay module is available | ||
| if (isMaskingEnabled()) { | ||
| final @Nullable View rootView = | ||
| activity.getWindow() != null |
There was a problem hiding this comment.
We should use .peekDecorView(); instead of .getDecorView()
| } | ||
|
|
||
| // Apply masking if enabled and replay module is available | ||
| if (isMaskingEnabled()) { |
There was a problem hiding this comment.
I don't this is guaranteed to be always executed on the main thread, right? We might need to enforce this, otherwise random crashes could occur while iterating the VH.
| if (createdCopy && !mutableBitmap.isRecycled()) { | ||
| mutableBitmap.recycle(); | ||
| } | ||
| return screenshot; |
There was a problem hiding this comment.
The safer behavior would be to skip attaching the screenshot entirely if masking was requested but couldn't be applied.
Yes, I think we should play safe here and only attach screenshots if masking was successful
| public void setMaskAllImages(final boolean maskAllImages) { | ||
| super.setMaskAllImages(maskAllImages); | ||
| if (maskAllImages) { | ||
| addSensitiveViewClasses(); |
There was a problem hiding this comment.
Yeah, I would just document it.
There was a problem hiding this comment.
do we need any license attribution for this file?
…sking # Conflicts: # CHANGELOG.md # sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml # sentry/api/sentry.api # sentry/src/main/java/io/sentry/SentryReplayOptions.java
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>
sentry-android-replay/src/main/java/io/sentry/android/replay/util/MaskRenderer.kt
Show resolved
Hide resolved
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 3 potential issues.
Bugbot Autofix is OFF. To automatically fix reported issues with Cloud Agents, enable Autofix in the Cursor dashboard.
| if (rootView != null) { | ||
| screenshot = applyMasking(screenshot, rootView); | ||
| } | ||
| } |
There was a problem hiding this comment.
View hierarchy traversal may run off main thread
High Severity
The process method is not guaranteed to run on the main thread, yet the masking code accesses activity.getWindow().getDecorView() and traverses the view hierarchy via ViewHierarchyNode.fromView and ViewsKt.traverse. Accessing the Android view hierarchy from a background thread can cause random crashes. The existing captureScreenshot handles thread switching internally, but the masking code added afterward has no such protection.
Additional Locations (1)
| && activity.getWindow().getDecorView() != null | ||
| && activity.getWindow().getDecorView().getRootView() != null | ||
| ? activity.getWindow().getDecorView().getRootView() | ||
| : null; |
There was a problem hiding this comment.
Uses getDecorView instead of peekDecorView
Medium Severity
The masking code calls activity.getWindow().getDecorView() which can force creation of the decor view if it doesn't exist yet. The rest of the codebase (including ScreenshotUtils.captureScreenshot and ViewHierarchyEventProcessor) consistently uses peekDecorView() to safely return null instead. This was explicitly flagged in the PR review.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| private static final long DEBOUNCE_WAIT_TIME_MS = 2000; | ||
| private static final int DEBOUNCE_MAX_EXECUTIONS = 3; | ||
|
|
||
| private @Nullable MaskRenderer maskRenderer = null; |
There was a problem hiding this comment.
Bug: The shared MaskRenderer instance in ScreenshotEventProcessor is not thread-safe. Concurrent event processing from multiple threads can lead to a race condition when accessing its mutable state.
Severity: MEDIUM
Suggested Fix
Ensure thread-safe access to the MaskRenderer instance. This can be achieved by either creating a new MaskRenderer instance for each process call, using a ThreadLocal to provide a separate instance per thread, or by introducing locks to synchronize access to the shared instance. Alternatively, use a single-threaded executor to serialize calls, similar to the pattern in PixelCopyStrategy.
Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.
Location:
sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java#L41-L44
Potential issue: The `ScreenshotEventProcessor` creates a single, shared instance of
`MaskRenderer`. However, `MaskRenderer` is not thread-safe due to its lazily-initialized
mutable state, including `singlePixelBitmap`, `singlePixelBitmapCanvas`, and
`maskingPaint`. Since `SentryClient` can process events from multiple threads
concurrently, simultaneous calls to `ScreenshotEventProcessor.process()` can lead to a
race condition. This can result in incorrect color sampling, visual artifacts in masked
screenshots, or potential crashes due to concurrent canvas operations. Unlike
`PixelCopyStrategy`, which serializes access via a single-threaded executor,
`ScreenshotEventProcessor` calls `renderMasks()` directly on the calling thread,
exposing this concurrency issue.


📜 Description
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.
Masking requires the
sentry-android-replaymodule to be present at runtime. Without it, screenshots are captured without masking.some example events:
Also verified that pixelCopy strategy still works fine after the change, replay here:
https://sentry-sdks.sentry.io/explore/replays/cb4a531e5851484cb547c93e31a9c9f3/
💡 Motivation and Context
Closes #3286
💚 How did you test it?
Manually + automated
📝 Checklist
sendDefaultPIIis enabled.🔮 Next steps
Docs and maybe wizard