Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 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
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Features

- Add screenshot masking support using view hierarchy ([#5077](https://github.com/getsentry/sentry-java/pull/5077))
- Add `installGroupsOverride` parameter to Build Distribution SDK for programmatic filtering, with support for configuration via properties file using `io.sentry.distribution.install-groups-override` ([#5066](https://github.com/getsentry/sentry-java/pull/5066))

### Fixes
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" }
9 changes: 8 additions & 1 deletion sentry-android-core/api/sentry-android-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,7 @@ public final class io/sentry/android/core/NetworkBreadcrumbsIntegration : io/sen
}

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 fun <init> (Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/android/core/BuildInfoProvider;Z)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 @@ -354,6 +354,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 @@ -450,6 +451,12 @@ 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 fun trackCustomMasking ()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 @@ -170,6 +170,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 @@ -659,6 +663,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));
}
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,15 @@
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.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
Expand All @@ -34,10 +41,14 @@ public final class ScreenshotEventProcessor implements EventProcessor {
private final @NotNull Debouncer debouncer;
private static final long DEBOUNCE_WAIT_TIME_MS = 2000;
private static final int DEBOUNCE_MAX_EXECUTIONS = 3;
private static final long MASKING_TIMEOUT_MS = 2000;

private final boolean isReplayAvailable;

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 +58,22 @@ public ScreenshotEventProcessor(
DEBOUNCE_WAIT_TIME_MS,
DEBOUNCE_MAX_EXECUTIONS);

this.isReplayAvailable = isReplayAvailable;

if (options.isAttachScreenshot()) {
addIntegrationToSdkVersion("Screenshot");
if (!isReplayAvailable && !options.getScreenshotOptions().getMaskViewClasses().isEmpty()) {
options
.getLogger()
.log(SentryLevel.WARNING, "Screenshot masking requires sentry-android-replay module");
}
}
}

private boolean isMaskingEnabled() {
return !options.getScreenshotOptions().getMaskViewClasses().isEmpty() && isReplayAvailable;
}

@Override
public @NotNull SentryTransaction process(
@NotNull SentryTransaction transaction, @NotNull Hint hint) {
Expand Down Expand Up @@ -89,23 +111,130 @@ 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()) {
final @Nullable ViewHierarchyNode rootNode = captureViewHierarchy(activity);
if (rootNode == null) {
screenshot.recycle();
return event;
}
final @Nullable Bitmap masked = applyMasking(screenshot, rootNode);
if (masked == null) {
// applyMasking already recycles its bitmaps on failure
return event;
}
screenshot = masked;
}

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;
}

/**
* Captures the view hierarchy on the main thread, since view traversal requires it. If already on
* the main thread, captures directly; otherwise posts to the main thread and waits.
*/
private @Nullable ViewHierarchyNode captureViewHierarchy(final @NotNull Activity activity) {
if (options.getThreadChecker().isMainThread()) {
return buildViewHierarchy(activity);
}

final AtomicReference<ViewHierarchyNode> result = new AtomicReference<>(null);
final CountDownLatch latch = new CountDownLatch(1);

activity.runOnUiThread(
() -> {
try {
result.set(buildViewHierarchy(activity));
} finally {
latch.countDown();
}
});

try {
if (!latch.await(MASKING_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
options
.getLogger()
.log(
SentryLevel.WARNING, "Timed out waiting for view hierarchy capture on main thread");
return null;
}
} catch (Throwable e) {
options.getLogger().log(SentryLevel.ERROR, "Failed to capture view hierarchy", e);
return null;
}

return result.get();
}

private @Nullable ViewHierarchyNode buildViewHierarchy(final @NotNull Activity activity) {
final @Nullable View rootView =
activity.getWindow() != null
&& activity.getWindow().peekDecorView() != null
&& activity.getWindow().peekDecorView().getRootView() != null
? activity.getWindow().peekDecorView().getRootView()
: null;
if (rootView == null) {
return null;
}

final ViewHierarchyNode rootNode =
ViewHierarchyNode.Companion.fromView(rootView, null, 0, options.getScreenshotOptions());
ViewsKt.traverse(rootView, rootNode, options.getScreenshotOptions(), options.getLogger());
return rootNode;
}

private @Nullable Bitmap applyMasking(
final @NotNull Bitmap screenshot, final @NotNull ViewHierarchyNode rootNode) {
Bitmap mutableBitmap = screenshot;
boolean createdCopy = false;
try (final MaskRenderer maskRenderer = new MaskRenderer()) {
// Make bitmap mutable if needed
if (!screenshot.isMutable()) {
mutableBitmap = screenshot.copy(Bitmap.Config.ARGB_8888, true);
if (mutableBitmap == null) {
screenshot.recycle();
return null;
}
createdCopy = true;
}

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);
if (createdCopy) {
if (!mutableBitmap.isRecycled()) {
mutableBitmap.recycle();
}
}
if (!screenshot.isRecycled()) {
screenshot.recycle();
}
return null;
}
}

@Override
public @Nullable Long getOrder() {
return 10000L;
Expand Down
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 @@ -677,6 +686,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
Loading
Loading