From ec7d4efefc12fd024a74ad1b280ba5c42239e3a7 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 22 Oct 2026 12:33:09 +0200 Subject: [PATCH 1/3] fix(android): Apply screenshot masking options to error and feedback screenshots Use sentry-java's takeMaskedScreenshot for screenshots captured from JS, default maskAllText/maskAllImages to true as documented, and honor Sentry.Mask/Sentry.Unmask when the replay module is available. Fixes #6900 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../react/RNSentryScreenshotOptionsTest.kt | 112 ++++++++++++++++++ .../io/sentry/react/RNSentryModuleImpl.java | 13 +- .../java/io/sentry/react/RNSentryStart.java | 85 ++++++++----- packages/core/src/js/options.ts | 3 + 5 files changed, 183 insertions(+), 31 deletions(-) create mode 100644 packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryScreenshotOptionsTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index ba4d58843d..a691549313 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ - Remove the `@sentry/browser` feedback modules and the server-only `@sentry/core` modules from native bundles built on Windows. Before this fix, `includeWebFeedback: false` made these bundles fail at launch ([#6915](https://github.com/getsentry/sentry-react-native/pull/6915)) - Stop wrapping the SDK's own `RNSentry` module in `turboModuleContextIntegration` by default. Each wrapped call wrote to the native scope several times, which added CPU time to every SDK call on iOS ([#6921](https://github.com/getsentry/sentry-react-native/pull/6921)) - To wrap `RNSentry` again, add `{ name: 'RNSentry' }` to the `modules` option. +- Apply `screenshot` masking options to Android error and feedback screenshots ([#6900](https://github.com/getsentry/sentry-react-native/issues/6900)) ### Dependencies diff --git a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryScreenshotOptionsTest.kt b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryScreenshotOptionsTest.kt new file mode 100644 index 0000000000..9cb675237f --- /dev/null +++ b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryScreenshotOptionsTest.kt @@ -0,0 +1,112 @@ +package io.sentry.react + +import com.facebook.react.bridge.JavaOnlyArray +import com.facebook.react.bridge.JavaOnlyMap +import io.sentry.SentryMaskingOptions +import io.sentry.android.core.SentryScreenshotOptions +import io.sentry.react.replay.RNSentryReplayMask +import io.sentry.react.replay.RNSentryReplayUnmask +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +class RNSentryScreenshotOptionsTest { + @Test + fun `masks text, images and svgs by default when no screenshot options are passed`() { + val screenshot = SentryScreenshotOptions() + + RNSentryStart.configureScreenshotMasking(screenshot, null, true) + + assertTrue(screenshot.maskViewClasses.contains(SentryMaskingOptions.TEXT_VIEW_CLASS_NAME)) + assertTrue(screenshot.maskViewClasses.contains(SentryMaskingOptions.IMAGE_VIEW_CLASS_NAME)) + assertTrue(screenshot.maskViewClasses.contains("com.horcrux.svg.SvgView")) + } + + @Test + fun `masks text, images and svgs by default when the masking keys are missing`() { + val screenshot = SentryScreenshotOptions() + + RNSentryStart.configureScreenshotMasking(screenshot, JavaOnlyMap.of(), true) + + assertTrue(screenshot.maskViewClasses.contains(SentryMaskingOptions.TEXT_VIEW_CLASS_NAME)) + assertTrue(screenshot.maskViewClasses.contains(SentryMaskingOptions.IMAGE_VIEW_CLASS_NAME)) + assertTrue(screenshot.maskViewClasses.contains("com.horcrux.svg.SvgView")) + } + + @Test + fun `masks only Sentry Mask content when masking is disabled`() { + val screenshot = SentryScreenshotOptions() + + RNSentryStart.configureScreenshotMasking( + screenshot, + JavaOnlyMap.of("maskAllText", false, "maskAllImages", false), + true, + ) + + // Only content stays masked + assertEquals(setOf(RNSentryReplayMask::class.java.name), screenshot.maskViewClasses) + } + + @Test + fun `does not mask anything when masking is disabled and replay is not available`() { + val screenshot = SentryScreenshotOptions() + + RNSentryStart.configureScreenshotMasking( + screenshot, + JavaOnlyMap.of("maskAllText", false, "maskAllImages", false), + false, + ) + + assertEquals(emptySet(), screenshot.maskViewClasses) + assertNull(screenshot.maskViewContainerClass) + } + + @Test + fun `masks only text when images are unmasked`() { + val screenshot = SentryScreenshotOptions() + + RNSentryStart.configureScreenshotMasking( + screenshot, + JavaOnlyMap.of("maskAllText", true, "maskAllImages", false), + true, + ) + + assertTrue(screenshot.maskViewClasses.contains(SentryMaskingOptions.TEXT_VIEW_CLASS_NAME)) + assertFalse(screenshot.maskViewClasses.contains(SentryMaskingOptions.IMAGE_VIEW_CLASS_NAME)) + assertFalse(screenshot.maskViewClasses.contains("com.horcrux.svg.SvgView")) + } + + @Test + fun `adds custom masked and unmasked view classes`() { + val screenshot = SentryScreenshotOptions() + + RNSentryStart.configureScreenshotMasking( + screenshot, + JavaOnlyMap.of( + "maskedViewClasses", + JavaOnlyArray.of("com.example.Secret"), + "unmaskedViewClasses", + JavaOnlyArray.of("com.example.Public"), + ), + true, + ) + + assertTrue(screenshot.maskViewClasses.contains("com.example.Secret")) + assertTrue(screenshot.unmaskViewClasses.contains("com.example.Public")) + } + + @Test + fun `sets the Mask and Unmask container classes`() { + val screenshot = SentryScreenshotOptions() + + RNSentryStart.configureScreenshotMasking(screenshot, null, true) + + assertEquals(RNSentryReplayMask::class.java.name, screenshot.maskViewContainerClass) + assertEquals(RNSentryReplayUnmask::class.java.name, screenshot.unmaskViewContainerClass) + } +} diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java index d100f388f1..fc820be696 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java @@ -1,6 +1,6 @@ package io.sentry.react; -import static io.sentry.android.core.internal.util.ScreenshotUtils.takeScreenshot; +import static io.sentry.android.core.internal.util.ScreenshotUtils.takeMaskedScreenshot; import static io.sentry.vendor.Base64.NO_PADDING; import static io.sentry.vendor.Base64.NO_WRAP; import static java.util.concurrent.TimeUnit.SECONDS; @@ -644,11 +644,20 @@ public void captureScreenshot(Promise promise) { } private static byte[] takeScreenshotOnUiThread(Activity activity) { + final SentryOptions options = Sentry.getCurrentScopes().getOptions(); + if (!(options instanceof SentryAndroidOptions)) { + // Without the native options the masking configuration is unknown, so don't risk an + // unmasked screenshot + logger.log(SentryLevel.WARNING, "SentryAndroidOptions not available, can't take screenshot."); + return null; + } + final SentryAndroidOptions androidOptions = (SentryAndroidOptions) options; + CountDownLatch doneSignal = new CountDownLatch(1); final byte[][] bytesWrapper = {{}}; // wrapper to be able to set the value in the runnable final Runnable runTakeScreenshot = () -> { - bytesWrapper[0] = takeScreenshot(activity, logger, buildInfo); + bytesWrapper[0] = takeMaskedScreenshot(activity, androidOptions, buildInfo); doneSignal.countDown(); }; diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryStart.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryStart.java index de3ec607e0..a3c411922e 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryStart.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryStart.java @@ -21,6 +21,8 @@ import io.sentry.android.core.NdkIntegration; import io.sentry.android.core.SentryAndroid; import io.sentry.android.core.SentryAndroidOptions; +import io.sentry.android.core.SentryScreenshotOptions; +import io.sentry.android.core.internal.util.ScreenshotMasking; import io.sentry.protocol.SdkVersion; import io.sentry.react.replay.RNSentryReplayMask; import io.sentry.react.replay.RNSentryReplayUnmask; @@ -141,35 +143,10 @@ static void getSentryAndroidOptions( if (rnOptions.hasKey("attachScreenshot")) { options.setAttachScreenshot(rnOptions.getBoolean("attachScreenshot")); } - if (rnOptions.hasKey("screenshot")) { - @Nullable final ReadableMap screenshotOptions = rnOptions.getMap("screenshot"); - if (screenshotOptions != null) { - if (screenshotOptions.hasKey("maskAllText")) { - options.getScreenshot().setMaskAllText(screenshotOptions.getBoolean("maskAllText")); - } - if (screenshotOptions.hasKey("maskAllImages")) { - options.getScreenshot().setMaskAllImages(screenshotOptions.getBoolean("maskAllImages")); - } - if (screenshotOptions.hasKey("maskedViewClasses")) { - @Nullable - final ReadableArray maskedClasses = screenshotOptions.getArray("maskedViewClasses"); - if (maskedClasses != null) { - for (int i = 0; i < maskedClasses.size(); i++) { - options.getScreenshot().addMaskViewClass(maskedClasses.getString(i)); - } - } - } - if (screenshotOptions.hasKey("unmaskedViewClasses")) { - @Nullable - final ReadableArray unmaskedClasses = screenshotOptions.getArray("unmaskedViewClasses"); - if (unmaskedClasses != null) { - for (int i = 0; i < unmaskedClasses.size(); i++) { - options.getScreenshot().addUnmaskViewClass(unmaskedClasses.getString(i)); - } - } - } - } - } + configureScreenshotMasking( + options.getScreenshot(), + rnOptions.hasKey("screenshot") ? rnOptions.getMap("screenshot") : null, + ScreenshotMasking.isAvailable(logger)); if (rnOptions.hasKey("attachViewHierarchy")) { options.setAttachViewHierarchy(rnOptions.getBoolean("attachViewHierarchy")); } @@ -423,6 +400,56 @@ private static boolean isReplayEnabled(SentryReplayOptions replayOptions) { || replayOptions.getOnErrorSampleRate() != null; } + /** + * Masking is enabled by default, as documented in the JS options, while the native Android SDK + * leaves it off until its next major. Package-private for unit testing. + */ + static void configureScreenshotMasking( + final @NotNull SentryScreenshotOptions screenshot, + final @Nullable ReadableMap rnScreenshotOptions, + final boolean isReplayAvailable) { + final boolean maskAllText = + rnScreenshotOptions == null + || !rnScreenshotOptions.hasKey("maskAllText") + || rnScreenshotOptions.getBoolean("maskAllText"); + final boolean maskAllImages = + rnScreenshotOptions == null + || !rnScreenshotOptions.hasKey("maskAllImages") + || rnScreenshotOptions.getBoolean("maskAllImages"); + screenshot.setMaskAllText(maskAllText); + screenshot.setMaskAllImages(maskAllImages); + if (maskAllImages) { + screenshot.addMaskViewClass("com.horcrux.svg.SvgView"); // react-native-svg + } + + if (rnScreenshotOptions != null && rnScreenshotOptions.hasKey("maskedViewClasses")) { + @Nullable + final ReadableArray maskedClasses = rnScreenshotOptions.getArray("maskedViewClasses"); + if (maskedClasses != null) { + for (int i = 0; i < maskedClasses.size(); i++) { + screenshot.addMaskViewClass(maskedClasses.getString(i)); + } + } + } + if (rnScreenshotOptions != null && rnScreenshotOptions.hasKey("unmaskedViewClasses")) { + @Nullable + final ReadableArray unmaskedClasses = rnScreenshotOptions.getArray("unmaskedViewClasses"); + if (unmaskedClasses != null) { + for (int i = 0; i < unmaskedClasses.size(); i++) { + screenshot.addUnmaskViewClass(unmaskedClasses.getString(i)); + } + } + } + + // Honor and in error screenshots, the same as in Session Replay. + // Setting the mask container also turns masking on, which needs the replay module. Without it + // no screenshot is taken at all, so skip it to keep the masking opt-out working. + if (isReplayAvailable) { + screenshot.setMaskViewContainerClass(RNSentryReplayMask.class.getName()); + screenshot.setUnmaskViewContainerClass(RNSentryReplayUnmask.class.getName()); + } + } + // Package-private for unit testing (see RNSentryReplayOptionsTest), matching the other // testable static helpers in this class. static SentryReplayOptions getReplayOptions(@NotNull ReadableMap rnOptions) { diff --git a/packages/core/src/js/options.ts b/packages/core/src/js/options.ts index 7760bcc5d1..01c2f9f661 100644 --- a/packages/core/src/js/options.ts +++ b/packages/core/src/js/options.ts @@ -377,6 +377,9 @@ export interface BaseReactNativeOptions { /** * Options for configuring screenshot masking on error screenshots. * When `attachScreenshot` is enabled, these options control what gets masked in the screenshot. + * + * On Android, masking requires the `sentry-android-replay` module. If it's excluded, + * no screenshot is attached unless both `maskAllText` and `maskAllImages` are `false`. */ screenshot?: { /** From 4548114bfc5495e538311863210fc57a7d6bd112 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 22 Oct 2026 12:33:31 +0200 Subject: [PATCH 2/3] docs(changelog): Link PR #6930 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a691549313..62d4642828 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +28,7 @@ - Remove the `@sentry/browser` feedback modules and the server-only `@sentry/core` modules from native bundles built on Windows. Before this fix, `includeWebFeedback: false` made these bundles fail at launch ([#6915](https://github.com/getsentry/sentry-react-native/pull/6915)) - Stop wrapping the SDK's own `RNSentry` module in `turboModuleContextIntegration` by default. Each wrapped call wrote to the native scope several times, which added CPU time to every SDK call on iOS ([#6921](https://github.com/getsentry/sentry-react-native/pull/6921)) - To wrap `RNSentry` again, add `{ name: 'RNSentry' }` to the `modules` option. -- Apply `screenshot` masking options to Android error and feedback screenshots ([#6900](https://github.com/getsentry/sentry-react-native/issues/6900)) +- Apply `screenshot` masking options to Android error and feedback screenshots ([#6930](https://github.com/getsentry/sentry-react-native/pull/6930)) ### Dependencies From dcdf9c108e3b1e5701df5697f214bd4b684e4a8e Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 22 Oct 2026 12:42:11 +0200 Subject: [PATCH 3/3] fix(android): Return empty array instead of null for missing options Co-Authored-By: Claude Opus 5.5 --- .../src/main/java/io/sentry/react/RNSentryModuleImpl.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java index 321889139a..161a0cd000 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java @@ -676,7 +676,7 @@ private static byte[] takeScreenshotOnUiThread(Activity activity) { // Without the native options the masking configuration is unknown, so don't risk an // unmasked screenshot logger.log(SentryLevel.WARNING, "SentryAndroidOptions not available, can't take screenshot."); - return null; + return new byte[0]; } final SentryAndroidOptions androidOptions = (SentryAndroidOptions) options;