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
Outdated
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 |
|---|---|---|---|
| 3998a95 | 415.94 ms | 478.54 ms | 62.60 ms |
| d15471f | 369.38 ms | 459.08 ms | 89.70 ms |
| 6405ec5 | 310.88 ms | 354.56 ms | 43.69 ms |
| a416a65 | 295.53 ms | 373.74 ms | 78.21 ms |
| 6edfca2 | 305.52 ms | 432.78 ms | 127.26 ms |
| d15471f | 303.49 ms | 439.08 ms | 135.59 ms |
| 319f256 | 315.96 ms | 372.96 ms | 57.00 ms |
| 694d587 | 379.62 ms | 400.80 ms | 21.18 ms |
| 9fbb112 | 404.51 ms | 475.65 ms | 71.14 ms |
| b03edbb | 352.20 ms | 423.69 ms | 71.49 ms |
App size
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| 3998a95 | 1.58 MiB | 2.10 MiB | 532.96 KiB |
| d15471f | 1.58 MiB | 2.13 MiB | 559.54 KiB |
| 6405ec5 | 1.58 MiB | 2.12 MiB | 552.23 KiB |
| a416a65 | 1.58 MiB | 2.12 MiB | 555.26 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 |
| 694d587 | 1.58 MiB | 2.19 MiB | 620.06 KiB |
| 9fbb112 | 1.58 MiB | 2.11 MiB | 539.18 KiB |
| b03edbb | 1.58 MiB | 2.13 MiB | 557.32 KiB |
Previous results on branch: rz/feat/screenshot-masking
Startup times
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| 4b6bd27 | 331.80 ms | 389.30 ms | 57.50 ms |
| f839405 | 337.96 ms | 405.19 ms | 67.23 ms |
| 5a5e340 | 396.65 ms | 506.92 ms | 110.27 ms |
| 30ecad1 | 377.35 ms | 410.86 ms | 33.50 ms |
| dd8c794 | 343.71 ms | 447.48 ms | 103.77 ms |
| c8b63af | 324.09 ms | 399.74 ms | 75.65 ms |
| 26069fa | 341.00 ms | 421.66 ms | 80.66 ms |
| a36ad8f | 306.38 ms | 370.08 ms | 63.70 ms |
| 91309a4 | 309.08 ms | 359.69 ms | 50.61 ms |
App size
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| 4b6bd27 | 1.58 MiB | 2.29 MiB | 720.62 KiB |
| f839405 | 1.58 MiB | 2.29 MiB | 720.70 KiB |
| 5a5e340 | 1.58 MiB | 2.29 MiB | 720.61 KiB |
| 30ecad1 | 1.58 MiB | 2.29 MiB | 720.80 KiB |
| dd8c794 | 1.58 MiB | 2.19 MiB | 620.03 KiB |
| c8b63af | 1.58 MiB | 2.29 MiB | 720.85 KiB |
| 26069fa | 1.58 MiB | 2.29 MiB | 720.85 KiB |
| a36ad8f | 1.58 MiB | 2.29 MiB | 720.81 KiB |
| 91309a4 | 1.58 MiB | 2.29 MiB | 720.85 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>
sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java
Outdated
Show resolved
Hide resolved
| 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.
sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java
Outdated
Show resolved
Hide resolved
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/ScreenshotEventProcessor.java
Outdated
Show resolved
Hide resolved
| 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?
There was a problem hiding this comment.
good question, but we already have it in sentry-android-replay/src/test/resources/ and sentry/src/test/resources/ so I'd say no. Also, looks like it'd been taken from this website https://pixabay.com/images/search/tongariro/ which claims the license is free for use (we don't distribute it)
…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
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/ScreenshotEventProcessor.java
Outdated
Show resolved
Hide resolved
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java
Outdated
Show resolved
Hide resolved
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>
sentry-android-core/src/test/java/io/sentry/android/core/ScreenshotEventProcessorTest.kt
Show resolved
Hide resolved
sentry-android-core/src/test/java/io/sentry/android/core/SentryScreenshotOptionsTest.kt
Outdated
Show resolved
Hide resolved
…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>
sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java
Show resolved
Hide resolved
sentry-android-core/src/test/java/io/sentry/android/core/SentryScreenshotOptionsTest.kt
Outdated
Show resolved
Hide resolved
sentry-android-core/src/test/java/io/sentry/android/core/SentryScreenshotOptionsTest.kt
Show resolved
Hide resolved
…lass 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>
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/ScreenshotEventProcessor.java
Show resolved
Hide resolved
sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java
Show resolved
Hide resolved
… memory leaks Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix is OFF. To automatically fix reported issues with Cloud Agents, enable Autofix in the Cursor dashboard.
sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java
Show resolved
Hide resolved
…r instead of per event Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
📜 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