Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
469086f
feat(screenshot): Add screenshot masking using view hierarchy
romtsn Feb 5, 2026
17beece
Changelog
romtsn Feb 5, 2026
49f4517
dontwarn about classes we check via reflection at runtime
romtsn Feb 5, 2026
ad8d6df
pr id
romtsn Feb 5, 2026
5242ed6
fix(screenshot): Only warn about missing replay module when masking i…
romtsn Feb 5, 2026
115107b
fix(screenshot): Remove sensitive view classes when setMaskAllImages(…
romtsn Feb 5, 2026
83da7df
fix(screenshot): Recycle bitmap copy on masking failure to prevent me…
romtsn Feb 5, 2026
b26b448
Merge remote-tracking branch 'origin/main' into rz/feat/screenshot-ma…
romtsn Feb 16, 2026
2b8e4aa
fix: Resolve merge conflicts with main and integrate trackCustomMasking
romtsn Feb 16, 2026
69e5d56
Clean up slop
romtsn Feb 16, 2026
55819a4
fix(test): Implement abstract trackCustomMasking in test stub
romtsn Feb 16, 2026
5084ed2
fix(screenshot): Use peekDecorView instead of getDecorView
romtsn Feb 16, 2026
b37b2eb
refactor(screenshot): Per-call MaskRenderer, main-thread VH capture, …
romtsn Feb 17, 2026
2ea0893
Fix tests and remove slop
romtsn Feb 17, 2026
700f5d7
clean up
romtsn Feb 17, 2026
fd452e9
fix(masking): Remove from opposite set when adding mask/unmask view c…
romtsn Feb 17, 2026
6f1933c
delegate to super in SentryReplayOptions
romtsn Feb 17, 2026
2e8662b
Do not capture screenshot when copy bitmap fails
romtsn Feb 17, 2026
b85eb89
fix(screenshot): Recycle bitmaps on all early-return paths to prevent…
romtsn Feb 17, 2026
b85f24e
fix(screenshot): Log missing replay module warning once in constructo…
romtsn Feb 17, 2026
6987f73
Merge branch 'main' into rz/feat/screenshot-masking
romtsn Feb 25, 2026
9db3b83
Move PR id info to AGENTS.md
romtsn Feb 25, 2026
ee833e6
refactor: Rename getScreenshotOptions() to getScreenshot() to match g…
romtsn Feb 25, 2026
d97cc91
docs: Move screenshot masking changelog entry to Unreleased with code…
romtsn Feb 25, 2026
942f3e6
fix(screenshot): Avoid crash from uncaught exception in view hierarch…
romtsn Feb 25, 2026
deedc16
fix: Fix MaskRendererTest after lazy bitmap init guard and simplify t…
romtsn Feb 25, 2026
849087c
fix(screenshot): Wrap runOnUiThread in try-catch to handle destroyed …
romtsn Feb 25, 2026
4465657
Bail out early if replay module is not available but masking is enabl…
romtsn Feb 26, 2026
b4a7034
fix(test): Expect no screenshot when masking configured without repla…
romtsn Feb 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,23 @@

### Features

- Add screenshot masking support using view hierarchy ([#5077](https://github.com/getsentry/sentry-java/pull/5077))
- Masks sensitive content (text, images) in error screenshots before sending to Sentry
- Reuses Session Replay's masking logic; **requires `sentry-android-replay` module at runtime**
- To enable masking programmatically:
```kotlin
SentryAndroid.init(context) { options ->
options.isAttachScreenshot = true
options.screenshotOptions.setMaskAllText(true)
options.screenshotOptions.setMaskAllImages(true)
}
```
- Or via AndroidManifest.xml:
```xml
<meta-data android:name="io.sentry.attach-screenshot" android:value="true" />
<meta-data android:name="io.sentry.screenshot.mask-all-text" android:value="true" />
<meta-data android:name="io.sentry.screenshot.mask-all-images" android:value="true" />
```
- Add `installGroupsOverride` parameter and `installGroups` property to Build Distribution SDK ([#5062](https://github.com/getsentry/sentry-java/pull/5062))
- Update Android targetSdk to API 36 (Android 16) ([#5016](https://github.com/getsentry/sentry-java/pull/5016))
- Add AndroidManifest support for Spotlight configuration via `io.sentry.spotlight.enable` and `io.sentry.spotlight.url` ([#5064](https://github.com/getsentry/sentry-java/pull/5064))
Expand Down
20 changes: 20 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,13 +127,33 @@ The repository is organized into multiple modules:
- System tests validate end-to-end functionality with sample applications
- Coverage reports are generated for both JaCoCo (Java/Android) and Kover (KMP modules)

### Dependency Management
- All dependencies must be declared in `gradle/libs.versions.toml` (Gradle version catalog)
- Reference dependencies in build files using the `libs.` accessor (e.g., `libs.dropbox.differ`)
- Never hardcode version strings directly in `build.gradle.kts` files

### Contributing Guidelines
1. Follow existing code style and language
2. Do not modify API files (e.g. sentry.api) manually - run `./gradlew apiDump` to regenerate them
3. Write comprehensive tests
4. New features must be **opt-in by default** - extend `SentryOptions` or similar Option classes with getters/setters
5. Consider backwards compatibility

## Getting PR Information

Use `gh pr view` to get PR details from the current branch. This is needed when adding changelog entries, which require the PR number.

```bash
# Get PR number for current branch
gh pr view --json number -q '.number'

# Get PR number for a specific branch
gh pr view <branch-name> --json number -q '.number'

# Get PR URL
gh pr view --json url -q '.url'
```

## Domain-Specific Knowledge Areas

For complex SDK functionality, refer to the detailed cursor rules in `.cursor/rules/`:
Expand Down
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -236,3 +236,4 @@ msgpack = { module = "org.msgpack:msgpack-core", version = "0.9.8" }
okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" }
okio = { module = "com.squareup.okio:okio", version = "1.13.0" }
roboelectric = { module = "org.robolectric:robolectric", version = "4.14" }
dropbox-differ = { module = "com.dropbox.differ:differ-jvm", version = "0.3.0" }
11 changes: 9 additions & 2 deletions sentry-android-core/api/sentry-android-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -312,8 +312,9 @@ public final class io/sentry/android/core/NetworkBreadcrumbsIntegration : io/sen
public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V
}

public final class io/sentry/android/core/ScreenshotEventProcessor : io/sentry/EventProcessor {
public fun <init> (Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/android/core/BuildInfoProvider;)V
public final class io/sentry/android/core/ScreenshotEventProcessor : io/sentry/EventProcessor, java/io/Closeable {
public fun <init> (Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/android/core/BuildInfoProvider;Z)V
public fun close ()V
public fun getOrder ()Ljava/lang/Long;
public fun process (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/SentryEvent;
public fun process (Lio/sentry/protocol/SentryTransaction;Lio/sentry/Hint;)Lio/sentry/protocol/SentryTransaction;
Expand Down Expand Up @@ -341,6 +342,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr
public fun getFrameMetricsCollector ()Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector;
public fun getNativeSdkName ()Ljava/lang/String;
public fun getNdkHandlerStrategy ()I
public fun getScreenshotOptions ()Lio/sentry/android/core/SentryScreenshotOptions;
public fun getStartupCrashDurationThresholdMillis ()J
public fun isAnrEnabled ()Z
public fun isAnrReportInDebug ()Z
Expand Down Expand Up @@ -437,6 +439,11 @@ public final class io/sentry/android/core/SentryPerformanceProvider {
public fun shutdown ()V
}

public final class io/sentry/android/core/SentryScreenshotOptions : io/sentry/SentryMaskingOptions {
public fun <init> ()V
public fun setMaskAllImages (Z)V
}

public class io/sentry/android/core/SentryUserFeedbackButton : android/widget/Button {
public fun <init> (Landroid/content/Context;)V
public fun <init> (Landroid/content/Context;Landroid/util/AttributeSet;)V
Expand Down
1 change: 1 addition & 0 deletions sentry-android-core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ dependencies {
testImplementation(projects.sentryAndroidReplay)
testImplementation(projects.sentryCompose)
testImplementation(projects.sentryAndroidNdk)
testImplementation(libs.dropbox.differ)
testRuntimeOnly(libs.androidx.compose.ui)
testRuntimeOnly(libs.androidx.fragment.ktx)
testRuntimeOnly(libs.timber)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,8 @@ static void initializeIntegrationsAndProcessors(
options.addEventProcessor(
new DefaultAndroidEventProcessor(context, buildInfoProvider, options));
options.addEventProcessor(new PerformanceAndroidEventProcessor(options, activityFramesTracker));
options.addEventProcessor(new ScreenshotEventProcessor(options, buildInfoProvider));
options.addEventProcessor(
new ScreenshotEventProcessor(options, buildInfoProvider, isReplayAvailable));
options.addEventProcessor(new ViewHierarchyEventProcessor(options));
options.addEventProcessor(
new ApplicationExitInfoEventProcessor(context, options, buildInfoProvider));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,10 @@ final class ManifestMetadataReader {

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

static final String SCREENSHOT_MASK_ALL_TEXT = "io.sentry.screenshot.mask-all-text";

static final String SCREENSHOT_MASK_ALL_IMAGES = "io.sentry.screenshot.mask-all-images";

/** ManifestMetadataReader ctor */
private ManifestMetadataReader() {}

Expand Down Expand Up @@ -655,6 +659,14 @@ static void applyMetadata(
if (spotlightUrl != null) {
options.setSpotlightConnectionUrl(spotlightUrl);
}

// Screenshot masking options (default to false for backwards compatibility)
options
.getScreenshotOptions()
.setMaskAllText(readBool(metadata, logger, SCREENSHOT_MASK_ALL_TEXT, false));
options
.getScreenshotOptions()
.setMaskAllImages(readBool(metadata, logger, SCREENSHOT_MASK_ALL_IMAGES, false));
Comment thread
cursor[bot] marked this conversation as resolved.
}
options
.getLogger()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import android.app.Activity;
import android.graphics.Bitmap;
import android.view.View;
import io.sentry.Attachment;
import io.sentry.EventProcessor;
import io.sentry.Hint;
Expand All @@ -14,9 +15,14 @@
import io.sentry.android.core.internal.util.AndroidCurrentDateProvider;
import io.sentry.android.core.internal.util.Debouncer;
import io.sentry.android.core.internal.util.ScreenshotUtils;
import io.sentry.android.replay.util.MaskRenderer;
import io.sentry.android.replay.util.ViewsKt;
import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode;
import io.sentry.protocol.SentryTransaction;
import io.sentry.util.HintUtils;
import io.sentry.util.Objects;
import java.io.Closeable;
import java.io.IOException;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
Expand All @@ -26,7 +32,7 @@
* captured.
*/
@ApiStatus.Internal
public final class ScreenshotEventProcessor implements EventProcessor {
public final class ScreenshotEventProcessor implements EventProcessor, Closeable {

private final @NotNull SentryAndroidOptions options;
private final @NotNull BuildInfoProvider buildInfoProvider;
Expand All @@ -35,9 +41,12 @@ public final class ScreenshotEventProcessor implements EventProcessor {
private static final long DEBOUNCE_WAIT_TIME_MS = 2000;
private static final int DEBOUNCE_MAX_EXECUTIONS = 3;

private @Nullable MaskRenderer maskRenderer = null;
Comment thread
sentry[bot] marked this conversation as resolved.
Outdated

public ScreenshotEventProcessor(
final @NotNull SentryAndroidOptions options,
final @NotNull BuildInfoProvider buildInfoProvider) {
final @NotNull BuildInfoProvider buildInfoProvider,
final boolean isReplayAvailable) {
this.options = Objects.requireNonNull(options, "SentryAndroidOptions is required");
this.buildInfoProvider =
Objects.requireNonNull(buildInfoProvider, "BuildInfoProvider is required");
Expand All @@ -47,11 +56,28 @@ public ScreenshotEventProcessor(
DEBOUNCE_WAIT_TIME_MS,
DEBOUNCE_MAX_EXECUTIONS);

if (isReplayAvailable) {
maskRenderer = new MaskRenderer();
}

if (options.isAttachScreenshot()) {
addIntegrationToSdkVersion("Screenshot");
}
}

private boolean isMaskingEnabled() {
if (options.getScreenshotOptions().getMaskViewClasses().isEmpty()) {
return false;
}
if (maskRenderer == null) {
options
.getLogger()
.log(SentryLevel.WARNING, "Screenshot masking requires sentry-android-replay module");
return false;
}
return true;
}
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.

@Override
public @NotNull SentryTransaction process(
@NotNull SentryTransaction transaction, @NotNull Hint hint) {
Expand Down Expand Up @@ -89,25 +115,87 @@ public ScreenshotEventProcessor(
return event;
}

final Bitmap screenshot =
Bitmap screenshot =
captureScreenshot(
activity, options.getThreadChecker(), options.getLogger(), buildInfoProvider);
if (screenshot == null) {
return event;
}

// Apply masking if enabled and replay module is available
if (isMaskingEnabled()) {
Comment thread
romtsn marked this conversation as resolved.
final @Nullable View rootView =
activity.getWindow() != null
Comment thread
romtsn marked this conversation as resolved.
Outdated
&& activity.getWindow().getDecorView() != null
&& activity.getWindow().getDecorView().getRootView() != null
? activity.getWindow().getDecorView().getRootView()
: null;
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
if (rootView != null) {
screenshot = applyMasking(screenshot, rootView);
}
Comment thread
cursor[bot] marked this conversation as resolved.
}
Comment thread
cursor[bot] marked this conversation as resolved.

final Bitmap finalScreenshot = screenshot;
hint.setScreenshot(
Attachment.fromByteProvider(
() -> ScreenshotUtils.compressBitmapToPng(screenshot, options.getLogger()),
() -> ScreenshotUtils.compressBitmapToPng(finalScreenshot, options.getLogger()),
"screenshot.png",
"image/png",
false));
hint.set(ANDROID_ACTIVITY, activity);
return event;
}

private @NotNull Bitmap applyMasking(
final @NotNull Bitmap screenshot, final @NotNull View rootView) {
Bitmap mutableBitmap = screenshot;
boolean createdCopy = false;
try {
// Make bitmap mutable if needed
if (!screenshot.isMutable()) {
mutableBitmap = screenshot.copy(Bitmap.Config.ARGB_8888, true);
if (mutableBitmap == null) {
return screenshot;
}
Comment thread
sentry[bot] marked this conversation as resolved.
createdCopy = true;
}

// we can access it here, since it's "internal" only for Kotlin

// Build view hierarchy and apply masks
final ViewHierarchyNode rootNode =
ViewHierarchyNode.Companion.fromView(rootView, null, 0, options.getScreenshotOptions());
ViewsKt.traverse(rootView, rootNode, options.getScreenshotOptions(), options.getLogger());

if (maskRenderer != null) {
maskRenderer.renderMasks(mutableBitmap, rootNode, null);
}

// Recycle original if we created a copy
if (createdCopy && !screenshot.isRecycled()) {
screenshot.recycle();
}

return mutableBitmap;
} catch (Throwable e) {
options.getLogger().log(SentryLevel.ERROR, "Failed to mask screenshot", e);
// Recycle the copy if we created one, to avoid memory leak
if (createdCopy && !mutableBitmap.isRecycled()) {
mutableBitmap.recycle();
}
return screenshot;
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
}
Comment thread
cursor[bot] marked this conversation as resolved.
}

@Override
public @Nullable Long getOrder() {
return 10000L;
}

@Override
public void close() throws IOException {
if (maskRenderer != null) {
maskRenderer.close();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,15 @@ public interface BeforeCaptureCallback {

private boolean enableTombstone = false;

/**
* Screenshot masking options. Configure which views should be masked when capturing screenshots
* on error events.
*
* <p>Note: Screenshot masking requires the {@code sentry-android-replay} module to be present at
* runtime. If the replay module is not available, screenshots will be captured without masking.
*/
private final @NotNull SentryScreenshotOptions screenshotOptions = new SentryScreenshotOptions();

public SentryAndroidOptions() {
setSentryClientName(BuildConfig.SENTRY_ANDROID_SDK_NAME + "/" + BuildConfig.VERSION_NAME);
setSdkVersion(createSdkVersion());
Expand Down Expand Up @@ -681,6 +690,15 @@ public void setEnableSystemEventBreadcrumbsExtras(
this.enableSystemEventBreadcrumbsExtras = enableSystemEventBreadcrumbsExtras;
}

/**
* Returns the screenshot masking options.
*
* @return the screenshot masking options
*/
public @NotNull SentryScreenshotOptions getScreenshotOptions() {
return screenshotOptions;
}

static class AndroidUserFeedbackIDialogHandler implements SentryFeedbackOptions.IDialogHandler {
@Override
public void showDialog(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package io.sentry.android.core;

import io.sentry.SentryMaskingOptions;

/**
* Screenshot masking options for error screenshots. Extends the base {@link SentryMaskingOptions}
* with screenshot-specific defaults.
*
* <p>By default, masking is disabled for screenshots. Enable masking by calling {@link
* #setMaskAllText(boolean)} and/or {@link #setMaskAllImages(boolean)}.
*
* <p>Note: Screenshot masking requires the {@code sentry-android-replay} module to be present at
* runtime. If the replay module is not available, screenshots will be captured without masking.
*/
public final class SentryScreenshotOptions extends SentryMaskingOptions {

public SentryScreenshotOptions() {
// Default to NO masking until next major version.
// maskViewClasses starts empty, so nothing is masked by default.
}

/**
* {@inheritDoc}
*
* <p>When enabling image masking for screenshots, this also adds masking for WebView, VideoView,
* and media player views (ExoPlayer, Media3) since they may contain sensitive content.
*/
@Override
public void setMaskAllImages(final boolean maskAllImages) {
super.setMaskAllImages(maskAllImages);
if (maskAllImages) {
addSensitiveViewClasses();
Comment thread
romtsn marked this conversation as resolved.
} else {
removeSensitiveViewClasses();
}
}
Comment thread
cursor[bot] marked this conversation as resolved.

private void addSensitiveViewClasses() {
addMaskViewClass(WEB_VIEW_CLASS_NAME);
addMaskViewClass(VIDEO_VIEW_CLASS_NAME);
addMaskViewClass(ANDROIDX_MEDIA_VIEW_CLASS_NAME);
addMaskViewClass(EXOPLAYER_CLASS_NAME);
addMaskViewClass(EXOPLAYER_STYLED_CLASS_NAME);
}

private void removeSensitiveViewClasses() {
getMaskViewClasses().remove(WEB_VIEW_CLASS_NAME);
getMaskViewClasses().remove(VIDEO_VIEW_CLASS_NAME);
getMaskViewClasses().remove(ANDROIDX_MEDIA_VIEW_CLASS_NAME);
getMaskViewClasses().remove(EXOPLAYER_CLASS_NAME);
getMaskViewClasses().remove(EXOPLAYER_STYLED_CLASS_NAME);
}
}
Loading
Loading