Skip to content

Commit 62a7ddc

Browse files
antonisclaude
andauthored
fix(android): Apply screenshot masking options to error and feedback screenshots (#6930)
* 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 <noreply@anthropic.com> * docs(changelog): Link PR #6930 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(android): Return empty array instead of null for missing options Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Move to unreleased section --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent b668114 commit 62a7ddc

5 files changed

Lines changed: 186 additions & 31 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
99
## Unreleased
1010

11+
### Fixes
12+
13+
- Apply `screenshot` masking options to Android error and feedback screenshots ([#6930](https://github.com/getsentry/sentry-react-native/pull/6930))
14+
1115
### Dependencies
1216

1317
- Bump CLI from v3.8.1 to v3.8.2 ([#6933](https://github.com/getsentry/sentry-react-native/pull/6933))
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
package io.sentry.react
2+
3+
import com.facebook.react.bridge.JavaOnlyArray
4+
import com.facebook.react.bridge.JavaOnlyMap
5+
import io.sentry.SentryMaskingOptions
6+
import io.sentry.android.core.SentryScreenshotOptions
7+
import io.sentry.react.replay.RNSentryReplayMask
8+
import io.sentry.react.replay.RNSentryReplayUnmask
9+
import org.junit.Assert.assertEquals
10+
import org.junit.Assert.assertFalse
11+
import org.junit.Assert.assertNull
12+
import org.junit.Assert.assertTrue
13+
import org.junit.Test
14+
import org.junit.runner.RunWith
15+
import org.junit.runners.JUnit4
16+
17+
@RunWith(JUnit4::class)
18+
class RNSentryScreenshotOptionsTest {
19+
@Test
20+
fun `masks text, images and svgs by default when no screenshot options are passed`() {
21+
val screenshot = SentryScreenshotOptions()
22+
23+
RNSentryStart.configureScreenshotMasking(screenshot, null, true)
24+
25+
assertTrue(screenshot.maskViewClasses.contains(SentryMaskingOptions.TEXT_VIEW_CLASS_NAME))
26+
assertTrue(screenshot.maskViewClasses.contains(SentryMaskingOptions.IMAGE_VIEW_CLASS_NAME))
27+
assertTrue(screenshot.maskViewClasses.contains("com.horcrux.svg.SvgView"))
28+
}
29+
30+
@Test
31+
fun `masks text, images and svgs by default when the masking keys are missing`() {
32+
val screenshot = SentryScreenshotOptions()
33+
34+
RNSentryStart.configureScreenshotMasking(screenshot, JavaOnlyMap.of(), true)
35+
36+
assertTrue(screenshot.maskViewClasses.contains(SentryMaskingOptions.TEXT_VIEW_CLASS_NAME))
37+
assertTrue(screenshot.maskViewClasses.contains(SentryMaskingOptions.IMAGE_VIEW_CLASS_NAME))
38+
assertTrue(screenshot.maskViewClasses.contains("com.horcrux.svg.SvgView"))
39+
}
40+
41+
@Test
42+
fun `masks only Sentry Mask content when masking is disabled`() {
43+
val screenshot = SentryScreenshotOptions()
44+
45+
RNSentryStart.configureScreenshotMasking(
46+
screenshot,
47+
JavaOnlyMap.of("maskAllText", false, "maskAllImages", false),
48+
true,
49+
)
50+
51+
// Only <Sentry.Mask> content stays masked
52+
assertEquals(setOf(RNSentryReplayMask::class.java.name), screenshot.maskViewClasses)
53+
}
54+
55+
@Test
56+
fun `does not mask anything when masking is disabled and replay is not available`() {
57+
val screenshot = SentryScreenshotOptions()
58+
59+
RNSentryStart.configureScreenshotMasking(
60+
screenshot,
61+
JavaOnlyMap.of("maskAllText", false, "maskAllImages", false),
62+
false,
63+
)
64+
65+
assertEquals(emptySet<String>(), screenshot.maskViewClasses)
66+
assertNull(screenshot.maskViewContainerClass)
67+
}
68+
69+
@Test
70+
fun `masks only text when images are unmasked`() {
71+
val screenshot = SentryScreenshotOptions()
72+
73+
RNSentryStart.configureScreenshotMasking(
74+
screenshot,
75+
JavaOnlyMap.of("maskAllText", true, "maskAllImages", false),
76+
true,
77+
)
78+
79+
assertTrue(screenshot.maskViewClasses.contains(SentryMaskingOptions.TEXT_VIEW_CLASS_NAME))
80+
assertFalse(screenshot.maskViewClasses.contains(SentryMaskingOptions.IMAGE_VIEW_CLASS_NAME))
81+
assertFalse(screenshot.maskViewClasses.contains("com.horcrux.svg.SvgView"))
82+
}
83+
84+
@Test
85+
fun `adds custom masked and unmasked view classes`() {
86+
val screenshot = SentryScreenshotOptions()
87+
88+
RNSentryStart.configureScreenshotMasking(
89+
screenshot,
90+
JavaOnlyMap.of(
91+
"maskedViewClasses",
92+
JavaOnlyArray.of("com.example.Secret"),
93+
"unmaskedViewClasses",
94+
JavaOnlyArray.of("com.example.Public"),
95+
),
96+
true,
97+
)
98+
99+
assertTrue(screenshot.maskViewClasses.contains("com.example.Secret"))
100+
assertTrue(screenshot.unmaskViewClasses.contains("com.example.Public"))
101+
}
102+
103+
@Test
104+
fun `sets the Mask and Unmask container classes`() {
105+
val screenshot = SentryScreenshotOptions()
106+
107+
RNSentryStart.configureScreenshotMasking(screenshot, null, true)
108+
109+
assertEquals(RNSentryReplayMask::class.java.name, screenshot.maskViewContainerClass)
110+
assertEquals(RNSentryReplayUnmask::class.java.name, screenshot.unmaskViewContainerClass)
111+
}
112+
}

‎packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
package io.sentry.react;
22

3-
import static io.sentry.android.core.internal.util.ScreenshotUtils.takeScreenshot;
3+
import static io.sentry.android.core.internal.util.ScreenshotUtils.takeMaskedScreenshot;
44
import static io.sentry.vendor.Base64.NO_PADDING;
55
import static io.sentry.vendor.Base64.NO_WRAP;
66
import static java.util.concurrent.TimeUnit.SECONDS;
@@ -671,11 +671,20 @@ public void captureScreenshot(Promise promise) {
671671
}
672672

673673
private static byte[] takeScreenshotOnUiThread(Activity activity) {
674+
final SentryOptions options = Sentry.getCurrentScopes().getOptions();
675+
if (!(options instanceof SentryAndroidOptions)) {
676+
// Without the native options the masking configuration is unknown, so don't risk an
677+
// unmasked screenshot
678+
logger.log(SentryLevel.WARNING, "SentryAndroidOptions not available, can't take screenshot.");
679+
return new byte[0];
680+
}
681+
final SentryAndroidOptions androidOptions = (SentryAndroidOptions) options;
682+
674683
CountDownLatch doneSignal = new CountDownLatch(1);
675684
final byte[][] bytesWrapper = {{}}; // wrapper to be able to set the value in the runnable
676685
final Runnable runTakeScreenshot =
677686
() -> {
678-
bytesWrapper[0] = takeScreenshot(activity, logger, buildInfo);
687+
bytesWrapper[0] = takeMaskedScreenshot(activity, androidOptions, buildInfo);
679688
doneSignal.countDown();
680689
};
681690

‎packages/core/android/src/main/java/io/sentry/react/RNSentryStart.java‎

Lines changed: 56 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
import io.sentry.android.core.NdkIntegration;
2222
import io.sentry.android.core.SentryAndroid;
2323
import io.sentry.android.core.SentryAndroidOptions;
24+
import io.sentry.android.core.SentryScreenshotOptions;
25+
import io.sentry.android.core.internal.util.ScreenshotMasking;
2426
import io.sentry.protocol.SdkVersion;
2527
import io.sentry.react.replay.RNSentryReplayMask;
2628
import io.sentry.react.replay.RNSentryReplayUnmask;
@@ -141,35 +143,10 @@ static void getSentryAndroidOptions(
141143
if (rnOptions.hasKey("attachScreenshot")) {
142144
options.setAttachScreenshot(rnOptions.getBoolean("attachScreenshot"));
143145
}
144-
if (rnOptions.hasKey("screenshot")) {
145-
@Nullable final ReadableMap screenshotOptions = rnOptions.getMap("screenshot");
146-
if (screenshotOptions != null) {
147-
if (screenshotOptions.hasKey("maskAllText")) {
148-
options.getScreenshot().setMaskAllText(screenshotOptions.getBoolean("maskAllText"));
149-
}
150-
if (screenshotOptions.hasKey("maskAllImages")) {
151-
options.getScreenshot().setMaskAllImages(screenshotOptions.getBoolean("maskAllImages"));
152-
}
153-
if (screenshotOptions.hasKey("maskedViewClasses")) {
154-
@Nullable
155-
final ReadableArray maskedClasses = screenshotOptions.getArray("maskedViewClasses");
156-
if (maskedClasses != null) {
157-
for (int i = 0; i < maskedClasses.size(); i++) {
158-
options.getScreenshot().addMaskViewClass(maskedClasses.getString(i));
159-
}
160-
}
161-
}
162-
if (screenshotOptions.hasKey("unmaskedViewClasses")) {
163-
@Nullable
164-
final ReadableArray unmaskedClasses = screenshotOptions.getArray("unmaskedViewClasses");
165-
if (unmaskedClasses != null) {
166-
for (int i = 0; i < unmaskedClasses.size(); i++) {
167-
options.getScreenshot().addUnmaskViewClass(unmaskedClasses.getString(i));
168-
}
169-
}
170-
}
171-
}
172-
}
146+
configureScreenshotMasking(
147+
options.getScreenshot(),
148+
rnOptions.hasKey("screenshot") ? rnOptions.getMap("screenshot") : null,
149+
ScreenshotMasking.isAvailable(logger));
173150
if (rnOptions.hasKey("attachViewHierarchy")) {
174151
options.setAttachViewHierarchy(rnOptions.getBoolean("attachViewHierarchy"));
175152
}
@@ -409,6 +386,56 @@ private static boolean isReplayEnabled(SentryReplayOptions replayOptions) {
409386
|| replayOptions.getOnErrorSampleRate() != null;
410387
}
411388

389+
/**
390+
* Masking is enabled by default, as documented in the JS options, while the native Android SDK
391+
* leaves it off until its next major. Package-private for unit testing.
392+
*/
393+
static void configureScreenshotMasking(
394+
final @NotNull SentryScreenshotOptions screenshot,
395+
final @Nullable ReadableMap rnScreenshotOptions,
396+
final boolean isReplayAvailable) {
397+
final boolean maskAllText =
398+
rnScreenshotOptions == null
399+
|| !rnScreenshotOptions.hasKey("maskAllText")
400+
|| rnScreenshotOptions.getBoolean("maskAllText");
401+
final boolean maskAllImages =
402+
rnScreenshotOptions == null
403+
|| !rnScreenshotOptions.hasKey("maskAllImages")
404+
|| rnScreenshotOptions.getBoolean("maskAllImages");
405+
screenshot.setMaskAllText(maskAllText);
406+
screenshot.setMaskAllImages(maskAllImages);
407+
if (maskAllImages) {
408+
screenshot.addMaskViewClass("com.horcrux.svg.SvgView"); // react-native-svg
409+
}
410+
411+
if (rnScreenshotOptions != null && rnScreenshotOptions.hasKey("maskedViewClasses")) {
412+
@Nullable
413+
final ReadableArray maskedClasses = rnScreenshotOptions.getArray("maskedViewClasses");
414+
if (maskedClasses != null) {
415+
for (int i = 0; i < maskedClasses.size(); i++) {
416+
screenshot.addMaskViewClass(maskedClasses.getString(i));
417+
}
418+
}
419+
}
420+
if (rnScreenshotOptions != null && rnScreenshotOptions.hasKey("unmaskedViewClasses")) {
421+
@Nullable
422+
final ReadableArray unmaskedClasses = rnScreenshotOptions.getArray("unmaskedViewClasses");
423+
if (unmaskedClasses != null) {
424+
for (int i = 0; i < unmaskedClasses.size(); i++) {
425+
screenshot.addUnmaskViewClass(unmaskedClasses.getString(i));
426+
}
427+
}
428+
}
429+
430+
// Honor <Sentry.Mask> and <Sentry.Unmask> in error screenshots, the same as in Session Replay.
431+
// Setting the mask container also turns masking on, which needs the replay module. Without it
432+
// no screenshot is taken at all, so skip it to keep the masking opt-out working.
433+
if (isReplayAvailable) {
434+
screenshot.setMaskViewContainerClass(RNSentryReplayMask.class.getName());
435+
screenshot.setUnmaskViewContainerClass(RNSentryReplayUnmask.class.getName());
436+
}
437+
}
438+
412439
// Package-private for unit testing (see RNSentryReplayOptionsTest), matching the other
413440
// testable static helpers in this class.
414441
static SentryReplayOptions getReplayOptions(@NotNull ReadableMap rnOptions) {

‎packages/core/src/js/options.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,9 @@ export interface BaseReactNativeOptions {
377377
/**
378378
* Options for configuring screenshot masking on error screenshots.
379379
* When `attachScreenshot` is enabled, these options control what gets masked in the screenshot.
380+
*
381+
* On Android, masking requires the `sentry-android-replay` module. If it's excluded,
382+
* no screenshot is attached unless both `maskAllText` and `maskAllImages` are `false`.
380383
*/
381384
screenshot?: {
382385
/**

0 commit comments

Comments
 (0)