diff --git a/CHANGELOG.md b/CHANGELOG.md index 39a32b7603..23a4d069f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ - Start and stop the native UI profiler for `_experiments.profilingOptions` in both the `trace` and `manual` lifecycles, and export `Sentry.profiler` ([#6916](https://github.com/getsentry/sentry-react-native/pull/6916)) - Before, the profiler ran only for the app start profile, because native tracing is off in React Native and no API controlled the profiler from JS. - The SDK adds the new `uiProfilingIntegration` by default when `profilingOptions` is set. If you set `defaultIntegrations: false`, add `Sentry.uiProfilingIntegration()` to `integrations`, because the SDK ignores `profilingOptions` without it. +- Apply `screenshot` masking options to Android error and feedback screenshots ([#6930](https://github.com/getsentry/sentry-react-native/pull/6930)) ### 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 2b313e8050..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 @@ -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; @@ -671,11 +671,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 new byte[0]; + } + 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 0b80ef4d42..d64f5b175e 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")); } @@ -409,6 +386,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?: { /**