Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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 <Sentry.Mask> 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<String>(), 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)
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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();
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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"));
}
Expand Down Expand Up @@ -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 <Sentry.Mask> and <Sentry.Unmask> 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) {
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/js/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?: {
/**
Expand Down
Loading