From 795e5c83fc47136f1d4a39f92a9beb4ab927f8b9 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Mon, 19 Oct 2026 10:33:20 +0200 Subject: [PATCH 1/5] feat(android): Add masked screenshot capture for Hybrid SDKs Hybrid SDKs capture screenshots for their own errors through ScreenshotUtils.takeScreenshot, which ignores the screenshot masking options. Add ScreenshotUtils.takeMaskedScreenshot, backed by a shared captureMaskedScreenshot that ScreenshotEventProcessor now uses as well, so native and hybrid screenshots follow the same masking rules. Refs getsentry/sentry-react-native#6900 Co-Authored-By: Claude Opus 5.5 --- .../api/sentry-android-core.api | 1 + .../core/ScreenshotEventProcessor.java | 133 +-------------- .../io/sentry/android/core/SentryAndroid.java | 4 +- .../android/core/SentryScreenshotOptions.java | 3 +- .../core/internal/util/ScreenshotMasking.java | 155 ++++++++++++++++++ .../core/internal/util/ScreenshotUtils.java | 62 +++++++ .../ScreenshotUtilsMaskedScreenshotTest.kt | 154 +++++++++++++++++ 7 files changed, 381 insertions(+), 131 deletions(-) create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotMasking.java create mode 100644 sentry-android-core/src/test/java/io/sentry/android/core/internal/util/ScreenshotUtilsMaskedScreenshotTest.kt diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index e8b95794ea0..a4872ffd24a 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -409,6 +409,7 @@ public final class io/sentry/android/core/ScreenshotEventProcessor : io/sentry/i } public final class io/sentry/android/core/SentryAndroid { + public static final field SENTRY_REPLAY_INTEGRATION_CLASS_NAME Ljava/lang/String; public static fun init (Landroid/content/Context;)V public static fun init (Landroid/content/Context;Lio/sentry/ILogger;)V public static fun init (Landroid/content/Context;Lio/sentry/ILogger;Lio/sentry/Sentry$OptionsConfiguration;)V diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java index 6283b47f95d..8b51c512c87 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java @@ -1,12 +1,10 @@ package io.sentry.android.core; import static io.sentry.TypeCheckHint.ANDROID_ACTIVITY; -import static io.sentry.android.core.internal.util.ScreenshotUtils.captureScreenshot; import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; import android.app.Activity; import android.graphics.Bitmap; -import android.view.View; import io.sentry.Attachment; import io.sentry.Hint; import io.sentry.SentryEvent; @@ -14,18 +12,12 @@ 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.internal.eventprocessor.SentryEventProcessor; import io.sentry.protocol.SentryTransaction; import io.sentry.util.ExceptionUtils; 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.AtomicBoolean; -import java.util.concurrent.atomic.AtomicReference; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -43,7 +35,6 @@ public final class ScreenshotEventProcessor implements SentryEventProcessor { 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; private final AtomicBoolean isReplayModuleAbsenceLogged = new AtomicBoolean(false); @@ -70,10 +61,6 @@ public ScreenshotEventProcessor( } } - private boolean isMaskingEnabled() { - return !options.getScreenshot().getMaskViewClasses().isEmpty() && isReplayAvailable; - } - @Override public @NotNull SentryTransaction process( @NotNull SentryTransaction transaction, @NotNull Hint hint) { @@ -131,32 +118,16 @@ private boolean isMaskingEnabled() { return event; } - Bitmap screenshot = - captureScreenshot( - activity, options.getThreadChecker(), options.getLogger(), buildInfoProvider); + final @Nullable Bitmap screenshot = + ScreenshotUtils.captureMaskedScreenshot( + activity, options, buildInfoProvider, isReplayAvailable); 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(finalScreenshot, options.getLogger()), + () -> ScreenshotUtils.compressBitmapToPng(screenshot, options.getLogger()), "screenshot.png", "image/png", false)); @@ -164,102 +135,6 @@ private boolean isMaskingEnabled() { 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 result = new AtomicReference<>(null); - final CountDownLatch latch = new CountDownLatch(1); - - try { - activity.runOnUiThread( - () -> { - try { - result.set(buildViewHierarchy(activity)); - } finally { - latch.countDown(); - } - }); - - 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) { - try { - 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.getScreenshot()); - ViewsKt.traverse(rootView, rootNode, options.getScreenshot(), options.getLogger(), null); - return rootNode; - } catch (Throwable e) { - options.getLogger().log(SentryLevel.ERROR, "Failed to build view hierarchy", e); - return null; - } - } - - 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.RGB_565, 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; diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java index 82916a248e6..38c634159ff 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java @@ -23,6 +23,7 @@ import java.util.ArrayList; import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -38,7 +39,8 @@ public final class SentryAndroid { static final String SENTRY_TIMBER_INTEGRATION_CLASS_NAME = "io.sentry.android.timber.SentryTimberIntegration"; - static final String SENTRY_REPLAY_INTEGRATION_CLASS_NAME = + @ApiStatus.Internal + public static final String SENTRY_REPLAY_INTEGRATION_CLASS_NAME = "io.sentry.android.replay.ReplayIntegration"; static final String SENTRY_DISTRIBUTION_INTEGRATION_CLASS_NAME = diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryScreenshotOptions.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryScreenshotOptions.java index 8680ed907d1..6e1cceb760b 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryScreenshotOptions.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryScreenshotOptions.java @@ -10,7 +10,8 @@ * #setMaskAllText(boolean)} and/or {@link #setMaskAllImages(boolean)}. * *

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. + * runtime. If masking is enabled but the replay module is not available, no screenshot is captured + * to avoid leaking sensitive data. */ public final class SentryScreenshotOptions extends SentryMaskingOptions { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotMasking.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotMasking.java new file mode 100644 index 00000000000..0457df4c2af --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotMasking.java @@ -0,0 +1,155 @@ +package io.sentry.android.core.internal.util; + +import android.app.Activity; +import android.graphics.Bitmap; +import android.view.View; +import io.sentry.ILogger; +import io.sentry.SentryLevel; +import io.sentry.android.core.SentryScreenshotOptions; +import io.sentry.android.replay.util.MaskRenderer; +import io.sentry.android.replay.util.ViewsKt; +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode; +import io.sentry.util.thread.IThreadChecker; +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; + +/** + * Applies screenshot masking using the Session Replay view hierarchy approach. Requires the + * `sentry-android-replay` module at runtime, callers must check for its availability first. + */ +@ApiStatus.Internal +public final class ScreenshotMasking { + + private static final long MASKING_TIMEOUT_MS = 2000; + + private ScreenshotMasking() {} + + /** + * Masks the given screenshot according to the screenshot options. + * + * @return the masked bitmap, or null if masking failed. The passed screenshot is recycled on + * failure, so a raw screenshot is never returned in place of a masked one. + */ + public static @Nullable Bitmap mask( + final @NotNull Activity activity, + final @NotNull Bitmap screenshot, + final @NotNull SentryScreenshotOptions screenshotOptions, + final @NotNull IThreadChecker threadChecker, + final @NotNull ILogger logger) { + final @Nullable ViewHierarchyNode rootNode = + captureViewHierarchy(activity, screenshotOptions, threadChecker, logger); + if (rootNode == null) { + screenshot.recycle(); + return null; + } + // applyMasking already recycles its bitmaps on failure + return applyMasking(screenshot, rootNode, logger); + } + + /** + * 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 static @Nullable ViewHierarchyNode captureViewHierarchy( + final @NotNull Activity activity, + final @NotNull SentryScreenshotOptions screenshotOptions, + final @NotNull IThreadChecker threadChecker, + final @NotNull ILogger logger) { + if (threadChecker.isMainThread()) { + return buildViewHierarchy(activity, screenshotOptions, logger); + } + + final AtomicReference result = new AtomicReference<>(null); + final CountDownLatch latch = new CountDownLatch(1); + + try { + activity.runOnUiThread( + () -> { + try { + result.set(buildViewHierarchy(activity, screenshotOptions, logger)); + } finally { + latch.countDown(); + } + }); + + if (!latch.await(MASKING_TIMEOUT_MS, TimeUnit.MILLISECONDS)) { + logger.log( + SentryLevel.WARNING, "Timed out waiting for view hierarchy capture on main thread"); + return null; + } + } catch (Throwable e) { + logger.log(SentryLevel.ERROR, "Failed to capture view hierarchy", e); + return null; + } + + return result.get(); + } + + private static @Nullable ViewHierarchyNode buildViewHierarchy( + final @NotNull Activity activity, + final @NotNull SentryScreenshotOptions screenshotOptions, + final @NotNull ILogger logger) { + try { + 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, screenshotOptions); + ViewsKt.traverse(rootView, rootNode, screenshotOptions, logger, null); + return rootNode; + } catch (Throwable e) { + logger.log(SentryLevel.ERROR, "Failed to build view hierarchy", e); + return null; + } + } + + private static @Nullable Bitmap applyMasking( + final @NotNull Bitmap screenshot, + final @NotNull ViewHierarchyNode rootNode, + final @NotNull ILogger logger) { + 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.RGB_565, 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) { + logger.log(SentryLevel.ERROR, "Failed to mask screenshot", e); + if (createdCopy) { + if (!mutableBitmap.isRecycled()) { + mutableBitmap.recycle(); + } + } + if (!screenshot.isRecycled()) { + screenshot.recycle(); + } + return null; + } + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotUtils.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotUtils.java index 092caa040db..9c5bc910357 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotUtils.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotUtils.java @@ -14,6 +14,9 @@ import io.sentry.ILogger; import io.sentry.SentryLevel; import io.sentry.android.core.BuildInfoProvider; +import io.sentry.android.core.SentryAndroid; +import io.sentry.android.core.SentryAndroidOptions; +import io.sentry.util.LoadClass; import io.sentry.util.thread.IThreadChecker; import java.io.ByteArrayOutputStream; import java.util.concurrent.CountDownLatch; @@ -51,6 +54,65 @@ public class ScreenshotUtils { return compressBitmapToPng(screenshot, logger); } + // Used by Hybrid SDKs + /** + * Takes a screenshot and masks it according to {@link SentryAndroidOptions#getScreenshot()}, the + * same way {@link io.sentry.android.core.ScreenshotEventProcessor} does for native errors. + * + * @see #captureMaskedScreenshot(Activity, SentryAndroidOptions, BuildInfoProvider, boolean) + * @noinspection unused + */ + public static @Nullable byte[] takeMaskedScreenshot( + final @NotNull Activity activity, + final @NotNull SentryAndroidOptions options, + final @NotNull BuildInfoProvider buildInfoProvider) { + final boolean isReplayAvailable = + new LoadClass() + .isClassAvailable( + SentryAndroid.SENTRY_REPLAY_INTEGRATION_CLASS_NAME, options.getLogger()); + return compressBitmapToPng( + captureMaskedScreenshot(activity, options, buildInfoProvider, isReplayAvailable), + options.getLogger()); + } + + /** + * Captures a screenshot and masks it according to {@link SentryAndroidOptions#getScreenshot()}. + * Shared by {@link io.sentry.android.core.ScreenshotEventProcessor} and Hybrid SDKs, so native + * and hybrid screenshots follow the same masking rules. + * + *

If no masking is configured, the screenshot is returned unmasked. If masking is configured + * but the `sentry-android-replay` module is not available, or masking fails, null is returned to + * avoid leaking sensitive data. + */ + public static @Nullable Bitmap captureMaskedScreenshot( + final @NotNull Activity activity, + final @NotNull SentryAndroidOptions options, + final @NotNull BuildInfoProvider buildInfoProvider, + final boolean isReplayAvailable) { + final boolean isMaskingConfigured = !options.getScreenshot().getMaskViewClasses().isEmpty(); + if (isMaskingConfigured && !isReplayAvailable) { + options + .getLogger() + .log(SentryLevel.WARNING, "Screenshot masking requires sentry-android-replay module"); + return null; + } + + final @Nullable Bitmap screenshot = + captureScreenshot( + activity, options.getThreadChecker(), options.getLogger(), buildInfoProvider); + if (screenshot == null || !isMaskingConfigured) { + return screenshot; + } + + // mask() recycles the screenshot on failure, so a raw screenshot is never returned + return ScreenshotMasking.mask( + activity, + screenshot, + options.getScreenshot(), + options.getThreadChecker(), + options.getLogger()); + } + public static @Nullable Bitmap captureScreenshot( final @NotNull Activity activity, final @NotNull ILogger logger, diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/ScreenshotUtilsMaskedScreenshotTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/ScreenshotUtilsMaskedScreenshotTest.kt new file mode 100644 index 00000000000..9bc6f9f85b0 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/ScreenshotUtilsMaskedScreenshotTest.kt @@ -0,0 +1,154 @@ +package io.sentry.android.core.internal.util + +import android.app.Activity +import android.content.Context +import android.graphics.Color +import android.os.Bundle +import android.widget.LinearLayout +import android.widget.LinearLayout.LayoutParams +import android.widget.TextView +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.android.core.BuildInfoProvider +import io.sentry.android.core.SentryAndroidOptions +import io.sentry.util.thread.IThreadChecker +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.Robolectric.buildActivity +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import org.robolectric.shadows.ShadowPixelCopy + +@RunWith(AndroidJUnit4::class) +@Config(shadows = [ShadowPixelCopy::class], sdk = [30]) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class ScreenshotUtilsMaskedScreenshotTest { + + private class Fixture { + lateinit var activity: Activity + val threadChecker = mock() + val options = + SentryAndroidOptions().apply { + dsn = "https://key@sentry.io/proj" + threadChecker = this@Fixture.threadChecker + } + val buildInfoProvider = BuildInfoProvider(options.logger) + + init { + whenever(threadChecker.isMainThread).thenReturn(true) + } + } + + private lateinit var fixture: Fixture + + private fun takeMaskedScreenshot(isReplayAvailable: Boolean): ByteArray? = + ScreenshotUtils.compressBitmapToPng( + ScreenshotUtils.captureMaskedScreenshot( + fixture.activity, + fixture.options, + fixture.buildInfoProvider, + isReplayAvailable, + ), + fixture.options.logger, + ) + + @BeforeTest + fun `set up`() { + System.setProperty("robolectric.areWindowsMarkedVisible", "true") + System.setProperty("robolectric.pixelCopyRenderMode", "hardware") + + fixture = Fixture() + fixture.activity = buildActivity(TextActivity::class.java, null).setup().get() + } + + @Test + fun `when masking is not configured, returns an unmasked screenshot`() { + val bytes = takeMaskedScreenshot(isReplayAvailable = false) + + assertNotNull(bytes) + } + + @Test + fun `when masking is configured, returns a masked screenshot`() { + val unmasked = takeMaskedScreenshot(isReplayAvailable = true) + fixture.options.screenshot.setMaskAllText(true) + val masked = takeMaskedScreenshot(isReplayAvailable = true) + + assertNotNull(unmasked) + assertNotNull(masked) + assertFalse(unmasked.contentEquals(masked)) + } + + @Test + fun `when masking is configured but replay is not available, returns no screenshot`() { + fixture.options.screenshot.setMaskAllText(true) + + val bytes = takeMaskedScreenshot(isReplayAvailable = false) + + assertNull(bytes) + } + + @Test + fun `when masking is configured but fails, returns no screenshot`() { + fixture.options.screenshot.setMaskAllText(true) + fixture.activity.findViewById(android.R.id.text1).throwOnTraversal = true + + val bytes = takeMaskedScreenshot(isReplayAvailable = true) + + assertNull(bytes) + } + + @Test + fun `public overload detects replay availability`() { + fixture.options.screenshot.setMaskAllText(true) + + // sentry-android-replay is on the test classpath + val bytes = + ScreenshotUtils.takeMaskedScreenshot( + fixture.activity, + fixture.options, + fixture.buildInfoProvider, + ) + + assertNotNull(bytes) + } +} + +private class TextActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val linearLayout = + LinearLayout(this).apply { + setBackgroundColor(Color.WHITE) + orientation = LinearLayout.VERTICAL + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + } + linearLayout.addView( + ThrowingTextView(this).apply { + id = android.R.id.text1 + text = "Sensitive text" + setTextColor(Color.BLACK) + textSize = 32f + layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) + } + ) + setContentView(linearLayout) + } +} + +/** Fails view hierarchy traversal on demand, to exercise the masking failure path. */ +private class ThrowingTextView(context: Context) : TextView(context) { + var throwOnTraversal = false + + override fun getTotalPaddingLeft(): Int { + if (throwOnTraversal) { + throw IllegalStateException("Traversal failure") + } + return super.getTotalPaddingLeft() + } +} From 40136c3ed45c0961bc2e4ace3ec3f6e3e3409b38 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Mon, 19 Oct 2026 10:33:52 +0200 Subject: [PATCH 2/5] changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6bd32c9e3b..a705c288862 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Features + +- Add `ScreenshotUtils.takeMaskedScreenshot` so Hybrid SDKs can capture screenshots masked according to `SentryAndroidOptions.getScreenshot()` ([#6281](https://github.com/getsentry/sentry-java/pull/6281)) + ### Fixes - Measure Session Replay's 1h recording cap and its touch-move debounce on a monotonic clock, so a device time change no longer stops a healthy recording early, keeps a finished one alive, or suppresses gesture events ([#6090](https://github.com/getsentry/sentry-java/pull/6090)) From e821ddc072837803e309149b83113db8702b8134 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Mon, 19 Oct 2026 10:43:54 +0200 Subject: [PATCH 3/5] ref(android): Narrow catches in ScreenshotMasking Catch only InterruptedException around the main-thread wait and RuntimeException around bitmap masking. Keep the broad catch for view hierarchy traversal, which runs custom View code, but let fatal errors through with ExceptionUtils.rethrowIfFatal. Co-Authored-By: Claude Opus 5.5 --- .../android/core/internal/util/ScreenshotMasking.java | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotMasking.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotMasking.java index 0457df4c2af..b0c2565acc4 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotMasking.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotMasking.java @@ -9,6 +9,7 @@ import io.sentry.android.replay.util.MaskRenderer; import io.sentry.android.replay.util.ViewsKt; import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode; +import io.sentry.util.ExceptionUtils; import io.sentry.util.thread.IThreadChecker; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; @@ -81,8 +82,9 @@ private ScreenshotMasking() {} SentryLevel.WARNING, "Timed out waiting for view hierarchy capture on main thread"); return null; } - } catch (Throwable e) { - logger.log(SentryLevel.ERROR, "Failed to capture view hierarchy", e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.log(SentryLevel.ERROR, "Interrupted while capturing view hierarchy", e); return null; } @@ -109,6 +111,8 @@ private ScreenshotMasking() {} ViewsKt.traverse(rootView, rootNode, screenshotOptions, logger, null); return rootNode; } catch (Throwable e) { + // Traversal calls into arbitrary (custom) View code, which can throw anything + ExceptionUtils.rethrowIfFatal(e); logger.log(SentryLevel.ERROR, "Failed to build view hierarchy", e); return null; } @@ -139,7 +143,7 @@ private ScreenshotMasking() {} } return mutableBitmap; - } catch (Throwable e) { + } catch (RuntimeException e) { logger.log(SentryLevel.ERROR, "Failed to mask screenshot", e); if (createdCopy) { if (!mutableBitmap.isRecycled()) { From 06677c462c80010f718bedc198ad9bf31b505895 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Mon, 19 Oct 2026 10:49:21 +0200 Subject: [PATCH 4/5] changelog: Move entry to Internal --- CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a705c288862..3dcf008dc6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,14 +2,14 @@ ## Unreleased -### Features - -- Add `ScreenshotUtils.takeMaskedScreenshot` so Hybrid SDKs can capture screenshots masked according to `SentryAndroidOptions.getScreenshot()` ([#6281](https://github.com/getsentry/sentry-java/pull/6281)) - ### Fixes - Measure Session Replay's 1h recording cap and its touch-move debounce on a monotonic clock, so a device time change no longer stops a healthy recording early, keeps a finished one alive, or suppresses gesture events ([#6090](https://github.com/getsentry/sentry-java/pull/6090)) +### Internal + +- Add `ScreenshotUtils.takeMaskedScreenshot` so Hybrid SDKs can capture screenshots masked according to `SentryAndroidOptions.getScreenshot()` ([#6281](https://github.com/getsentry/sentry-java/pull/6281)) + ## 8.61.0 ### Fixes From 57c6ba7ef7b3dc961dec8e6e181e35e59d14882c Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 20 Oct 2026 12:33:16 +0200 Subject: [PATCH 5/5] ref(android): Move replay availability check to ScreenshotMasking Add ScreenshotMasking.isAvailable() so the replay integration class name no longer needs to be public on SentryAndroid. Co-Authored-By: Claude Opus 5.5 --- sentry-android-core/api/sentry-android-core.api | 1 - .../java/io/sentry/android/core/SentryAndroid.java | 4 +--- .../core/internal/util/ScreenshotMasking.java | 13 ++++++++++++- .../android/core/internal/util/ScreenshotUtils.java | 7 +------ 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index a4872ffd24a..e8b95794ea0 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -409,7 +409,6 @@ public final class io/sentry/android/core/ScreenshotEventProcessor : io/sentry/i } public final class io/sentry/android/core/SentryAndroid { - public static final field SENTRY_REPLAY_INTEGRATION_CLASS_NAME Ljava/lang/String; public static fun init (Landroid/content/Context;)V public static fun init (Landroid/content/Context;Lio/sentry/ILogger;)V public static fun init (Landroid/content/Context;Lio/sentry/ILogger;Lio/sentry/Sentry$OptionsConfiguration;)V diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java index 38c634159ff..82916a248e6 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java @@ -23,7 +23,6 @@ import java.util.ArrayList; import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; -import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -39,8 +38,7 @@ public final class SentryAndroid { static final String SENTRY_TIMBER_INTEGRATION_CLASS_NAME = "io.sentry.android.timber.SentryTimberIntegration"; - @ApiStatus.Internal - public static final String SENTRY_REPLAY_INTEGRATION_CLASS_NAME = + static final String SENTRY_REPLAY_INTEGRATION_CLASS_NAME = "io.sentry.android.replay.ReplayIntegration"; static final String SENTRY_DISTRIBUTION_INTEGRATION_CLASS_NAME = diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotMasking.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotMasking.java index b0c2565acc4..4d3e2d15955 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotMasking.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotMasking.java @@ -10,6 +10,7 @@ import io.sentry.android.replay.util.ViewsKt; import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode; import io.sentry.util.ExceptionUtils; +import io.sentry.util.LoadClass; import io.sentry.util.thread.IThreadChecker; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; @@ -20,15 +21,25 @@ /** * Applies screenshot masking using the Session Replay view hierarchy approach. Requires the - * `sentry-android-replay` module at runtime, callers must check for its availability first. + * `sentry-android-replay` module at runtime, callers must check {@link #isAvailable} first. */ @ApiStatus.Internal public final class ScreenshotMasking { private static final long MASKING_TIMEOUT_MS = 2000; + // Checked instead of the masking classes, since it's the only replay class whose name is kept + // by the consumer proguard rules + private static final String REPLAY_INTEGRATION_CLASS_NAME = + "io.sentry.android.replay.ReplayIntegration"; + private ScreenshotMasking() {} + /** Whether the `sentry-android-replay` module, required for masking, is available at runtime. */ + public static boolean isAvailable(final @NotNull ILogger logger) { + return new LoadClass().isClassAvailable(REPLAY_INTEGRATION_CLASS_NAME, logger); + } + /** * Masks the given screenshot according to the screenshot options. * diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotUtils.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotUtils.java index 9c5bc910357..657ba54cbd2 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotUtils.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotUtils.java @@ -14,9 +14,7 @@ import io.sentry.ILogger; import io.sentry.SentryLevel; import io.sentry.android.core.BuildInfoProvider; -import io.sentry.android.core.SentryAndroid; import io.sentry.android.core.SentryAndroidOptions; -import io.sentry.util.LoadClass; import io.sentry.util.thread.IThreadChecker; import java.io.ByteArrayOutputStream; import java.util.concurrent.CountDownLatch; @@ -66,10 +64,7 @@ public class ScreenshotUtils { final @NotNull Activity activity, final @NotNull SentryAndroidOptions options, final @NotNull BuildInfoProvider buildInfoProvider) { - final boolean isReplayAvailable = - new LoadClass() - .isClassAvailable( - SentryAndroid.SENTRY_REPLAY_INTEGRATION_CLASS_NAME, options.getLogger()); + final boolean isReplayAvailable = ScreenshotMasking.isAvailable(options.getLogger()); return compressBitmapToPng( captureMaskedScreenshot(activity, options, buildInfoProvider, isReplayAvailable), options.getLogger());