Skip to content

feat(screenshot): Add screenshot masking using view hierarchy#5077

Open
romtsn wants to merge 11 commits intomainfrom
rz/feat/screenshot-masking
Open

feat(screenshot): Add screenshot masking using view hierarchy#5077
romtsn wants to merge 11 commits intomainfrom
rz/feat/screenshot-masking

Conversation

@romtsn
Copy link
Member

@romtsn romtsn commented Feb 5, 2026

📜 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.

  • 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.

some example events:

  1. https://sentry-sdks.sentry.io/issues/7241812873/events/5652b2955b8d4e638be96a8bed0ddd2c/
  2. https://sentry-sdks.sentry.io/issues/7235688870/events/9412e7165a4e4be98d3b9d9ceaea70b6/

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

  • I added GH Issue ID & Linear ID
  • I added tests to verify the changes.
  • No new PII added or SDK only sends newly added PII if sendDefaultPII is enabled.
  • [] I updated the docs if needed.
  • [] I updated the wizard if needed.
  • Review from the native team if needed.
  • No breaking change or entry added to the changelog.
  • No breaking change for hybrid SDKs or communicated to hybrid SDKs.

🔮 Next steps

Docs and maybe wizard

@github-actions
Copy link
Contributor

github-actions bot commented Feb 5, 2026

Semver Impact of This PR

🟡 Minor (new features)

📋 Changelog Preview

This is how your changes will appear in the changelog.
Entries from this PR are highlighted with a left border (blockquote style).


New Features ✨

  • (distribution) Add installGroupsOverride parameter by runningcode in #5066
  • (replay) Track custom masking usage via integration by markushi in #5070
  • (screenshot) Add screenshot masking using view hierarchy by romtsn in #5077

Bug Fixes 🐛

  • (android) Merge tombstone and Native SDK event message. by supervacuus in #5095
  • (logs,metrics) Attach user attributes to logs and metrics regardless of sendDefaultPii by romtsn in #5099

Internal Changes 🔧

Deps

  • Update Native SDK to v0.12.7 by github-actions in #5098
  • Bump getsentry/craft from 2.20.1 to 2.21.2 by dependabot in #5083
  • Bump github/codeql-action from 4.32.0 to 4.32.2 by dependabot in #5082
  • Update Native SDK to v0.12.6 by github-actions in #5071

Other

  • Session Replay: Add PreviewView to default masked view classes by szijpeter in #5097
  • No longer log a warning if a logging integration cannot initialize Sentry due to missing DSN. by adinauer in #5075
  • Fix thread leak caused by eager creation of SentryExecutorService in SentryOptions by adinauer in #5093

🤖 This preview updates automatically when you update the PR.

@github-actions
Copy link
Contributor

github-actions bot commented Feb 5, 2026

Messages
📖 Do not forget to update Sentry-docs with your feature once the pull request gets approved.

Generated by 🚫 dangerJS against 55819a4

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>
@romtsn romtsn force-pushed the rz/feat/screenshot-masking branch from 8afa77c to 17beece Compare February 5, 2026 13:33
@github-actions
Copy link
Contributor

github-actions bot commented Feb 5, 2026

Performance metrics 🚀

  Plain With Sentry Diff
Startup time 396.65 ms 506.92 ms 110.27 ms
Size 1.58 MiB 2.29 MiB 720.61 KiB

Baseline results on branch: main

Startup times

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

Previous results on branch: rz/feat/screenshot-masking

Startup times

Revision Plain With Sentry Diff
f839405 337.96 ms 405.19 ms 67.23 ms
dd8c794 343.71 ms 447.48 ms 103.77 ms

App size

Revision Plain With Sentry Diff
f839405 1.58 MiB 2.29 MiB 720.70 KiB
dd8c794 1.58 MiB 2.19 MiB 620.03 KiB

romtsn and others added 3 commits February 5, 2026 16:11
…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;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Fix in Cursor Fix in Web

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@markushi wdyt? makes sense?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I would just document it.

Copy link
Member

@markushi markushi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should use .peekDecorView(); instead of .getDecorView()

}

// Apply masking if enabled and replay module is available
if (isMaskingEnabled()) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I would just document it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need any license attribution for this file?

romtsn and others added 2 commits February 16, 2026 15:57
…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>
Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Fix in Cursor Fix in Web

&& activity.getWindow().getDecorView() != null
&& activity.getWindow().getDecorView().getRootView() != null
? activity.getWindow().getDecorView().getRootView()
: null;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

romtsn and others added 2 commits February 16, 2026 17:14
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Comment on lines 41 to +44
private static final long DEBOUNCE_WAIT_TIME_MS = 2000;
private static final int DEBOUNCE_MAX_EXECUTIONS = 3;

private @Nullable MaskRenderer maskRenderer = null;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Redact Screenshots via View Hierarchy

2 participants