diff --git a/CHANGELOG.md b/CHANGELOG.md index 26502bf6b5..735d841cb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ ### Fixes +- Mask React Native's prepared text layout views in Android Session Replay and error screenshots ([#7011](https://github.com/getsentry/sentry-react-native/pull/7011)) - Apply `Sentry.Mask` and `Sentry.Unmask` on iOS when you start a replay manually with both replay sample rates at `0` ([#7008](https://github.com/getsentry/sentry-react-native/pull/7008)) ### 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 index 9cb675237f..63bfa9d1c5 100644 --- 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 @@ -38,6 +38,28 @@ class RNSentryScreenshotOptionsTest { assertTrue(screenshot.maskViewClasses.contains("com.horcrux.svg.SvgView")) } + @Test + fun `masks prepared text layout views by default`() { + val screenshot = SentryScreenshotOptions() + + RNSentryStart.configureScreenshotMasking(screenshot, null, true) + + assertTrue(screenshot.maskViewClasses.contains(PREPARED_LAYOUT_TEXT_VIEW)) + } + + @Test + fun `does not mask prepared text layout views when text masking is disabled`() { + val screenshot = SentryScreenshotOptions() + + RNSentryStart.configureScreenshotMasking( + screenshot, + JavaOnlyMap.of("maskAllText", false), + true, + ) + + assertFalse(screenshot.maskViewClasses.contains(PREPARED_LAYOUT_TEXT_VIEW)) + } + @Test fun `masks only Sentry Mask content when masking is disabled`() { val screenshot = SentryScreenshotOptions() @@ -109,4 +131,9 @@ class RNSentryScreenshotOptionsTest { assertEquals(RNSentryReplayMask::class.java.name, screenshot.maskViewContainerClass) assertEquals(RNSentryReplayUnmask::class.java.name, screenshot.unmaskViewContainerClass) } + + private companion object { + // Pinned as a literal on purpose: it has to match React Native's class name. + const val PREPARED_LAYOUT_TEXT_VIEW = "com.facebook.react.views.text.PreparedLayoutTextView" + } } 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 2025268110..a2db2a7992 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 @@ -35,6 +35,11 @@ final class RNSentryStart { + // Renders by default since React Native 0.89. It is a ViewGroup that draws the text + // itself, so the android.widget.TextView match behind maskAllText does not cover it. + private static final String PREPARED_LAYOUT_TEXT_VIEW_CLASS = + "com.facebook.react.views.text.PreparedLayoutTextView"; + private RNSentryStart() { throw new AssertionError("Utility class should not be instantiated"); } @@ -407,6 +412,9 @@ static void configureScreenshotMasking( || rnScreenshotOptions.getBoolean("maskAllImages"); screenshot.setMaskAllText(maskAllText); screenshot.setMaskAllImages(maskAllImages); + if (maskAllText) { + screenshot.addMaskViewClass(PREPARED_LAYOUT_TEXT_VIEW_CLASS); + } if (maskAllImages) { screenshot.addMaskViewClass("com.horcrux.svg.SvgView"); // react-native-svg } @@ -464,6 +472,9 @@ static SentryReplayOptions getReplayOptions(@NotNull ReadableMap rnOptions) { ? rnOptions.getDouble("replaysOnErrorSampleRate") : null); + // Added before the early returns below, where text stays masked by the native default. + androidReplayOptions.addMaskViewClass(PREPARED_LAYOUT_TEXT_VIEW_CLASS); + if (!rnOptions.hasKey("mobileReplayOptions")) { return androidReplayOptions; } @@ -472,9 +483,13 @@ static SentryReplayOptions getReplayOptions(@NotNull ReadableMap rnOptions) { return androidReplayOptions; } - androidReplayOptions.setMaskAllText( + final boolean maskAllText = !rnMobileReplayOptions.hasKey("maskAllText") - || rnMobileReplayOptions.getBoolean("maskAllText")); + || rnMobileReplayOptions.getBoolean("maskAllText"); + androidReplayOptions.setMaskAllText(maskAllText); + if (!maskAllText) { + androidReplayOptions.addUnmaskViewClass(PREPARED_LAYOUT_TEXT_VIEW_CLASS); + } androidReplayOptions.setMaskAllImages( !rnMobileReplayOptions.hasKey("maskAllImages") || rnMobileReplayOptions.getBoolean("maskAllImages")); diff --git a/packages/core/android/src/test/java/io/sentry/react/RNSentryReplayOptionsTest.java b/packages/core/android/src/test/java/io/sentry/react/RNSentryReplayOptionsTest.java index 7e904d8eec..46a3e816b3 100644 --- a/packages/core/android/src/test/java/io/sentry/react/RNSentryReplayOptionsTest.java +++ b/packages/core/android/src/test/java/io/sentry/react/RNSentryReplayOptionsTest.java @@ -20,6 +20,9 @@ public class RNSentryReplayOptionsTest { private static final String MASKED_CLASS = "com.example.MaskedView"; private static final String UNMASKED_CLASS = "com.example.UnmaskedView"; + // Pinned as a literal on purpose: it has to match React Native's class name. + private static final String PREPARED_LAYOUT_TEXT_VIEW = + "com.facebook.react.views.text.PreparedLayoutTextView"; private static ReadableArray singletonArray(String value) { final ReadableArray array = mock(ReadableArray.class); @@ -63,6 +66,39 @@ public void addsUserUnmaskedViewClasses() { assertTrue(replayOptions.getUnmaskViewClasses().contains(UNMASKED_CLASS)); } + @Test + public void masksPreparedTextLayoutViewsByDefault() { + final ReadableMap mobileReplayOptions = mock(ReadableMap.class); + + final SentryReplayOptions replayOptions = + RNSentryStart.getReplayOptions(rnOptionsWith(mobileReplayOptions)); + + assertTrue(replayOptions.getMaskViewClasses().contains(PREPARED_LAYOUT_TEXT_VIEW)); + } + + @Test + public void masksPreparedTextLayoutViewsWhenMobileReplayOptionsAreMissing() { + final ReadableMap rnOptions = mock(ReadableMap.class); + when(rnOptions.hasKey("replaysOnErrorSampleRate")).thenReturn(true); + when(rnOptions.getDouble("replaysOnErrorSampleRate")).thenReturn(0.75); + + final SentryReplayOptions replayOptions = RNSentryStart.getReplayOptions(rnOptions); + + assertTrue(replayOptions.getMaskViewClasses().contains(PREPARED_LAYOUT_TEXT_VIEW)); + } + + @Test + public void doesNotMaskPreparedTextLayoutViewsWhenTextMaskingIsDisabled() { + final ReadableMap mobileReplayOptions = mock(ReadableMap.class); + when(mobileReplayOptions.hasKey("maskAllText")).thenReturn(true); + when(mobileReplayOptions.getBoolean("maskAllText")).thenReturn(false); + + final SentryReplayOptions replayOptions = + RNSentryStart.getReplayOptions(rnOptionsWith(mobileReplayOptions)); + + assertFalse(replayOptions.getMaskViewClasses().contains(PREPARED_LAYOUT_TEXT_VIEW)); + } + @Test public void doesNotAddUserClassesWhenNotProvided() { final ReadableMap mobileReplayOptions = mock(ReadableMap.class);