diff --git a/CHANGELOG.md b/CHANGELOG.md index 2abeb90664..9f6b9e179d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Features +- Expose screenshot masking options (`screenshot.maskAllText`, `screenshot.maskAllImages`, `screenshot.maskedViewClasses`, `screenshot.unmaskedViewClasses`) for error screenshots ([#6007](https://github.com/getsentry/sentry-react-native/pull/6007)) - Warn Expo users at Metro startup when prebuilt native projects are missing Sentry configuration ([#5984](https://github.com/getsentry/sentry-react-native/pull/5984)) ### Dependencies diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryStartTests.swift b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryStartTests.swift index b9d12200cf..c437b83049 100644 --- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryStartTests.swift +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryStartTests.swift @@ -197,6 +197,34 @@ final class RNSentryStartTests: XCTestCase { } } + func testScreenshotMaskingOptions() throws { + try startFromRN(options: [ + "dsn": "https://abcd@efgh.ingest.sentry.io/123456", + "attachScreenshot": true, + "screenshot": [ + "maskAllText": false, + "maskAllImages": true + ] + ]) + + let actualOptions = PrivateSentrySDKOnly.options + XCTAssertTrue(actualOptions.attachScreenshot) + XCTAssertFalse(actualOptions.screenshot.maskAllText) + XCTAssertTrue(actualOptions.screenshot.maskAllImages) + } + + func testScreenshotMaskingOptionsDefaults() throws { + try startFromRN(options: [ + "dsn": "https://abcd@efgh.ingest.sentry.io/123456", + "attachScreenshot": true + ]) + + let actualOptions = PrivateSentrySDKOnly.options + XCTAssertTrue(actualOptions.attachScreenshot) + XCTAssertTrue(actualOptions.screenshot.maskAllText) + XCTAssertTrue(actualOptions.screenshot.maskAllImages) + } + func startFromRN(options: [String: Any]) throws { var error: NSError? RNSentryStart.start(options: options, error: &error) 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 ebf305ad9d..1ab8394b66 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 @@ -2,6 +2,7 @@ import android.app.Activity; import android.content.Context; +import com.facebook.react.bridge.ReadableArray; import com.facebook.react.bridge.ReadableMap; import com.facebook.react.bridge.ReadableType; import com.facebook.react.common.JavascriptException; @@ -136,6 +137,35 @@ 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)); + } + } + } + } + } if (rnOptions.hasKey("attachViewHierarchy")) { options.setAttachViewHierarchy(rnOptions.getBoolean("attachViewHierarchy")); } diff --git a/packages/core/ios/RNSentryStart.m b/packages/core/ios/RNSentryStart.m index 03ca9e2ccd..fcc60857e9 100644 --- a/packages/core/ios/RNSentryStart.m +++ b/packages/core/ios/RNSentryStart.m @@ -55,6 +55,46 @@ + (SentryOptions *_Nullable)createOptionsWithDictionary:(NSDictionary *_Nonnull) return nil; } +#if SENTRY_HAS_UIKIT + NSDictionary *screenshotDict = mutableOptions[@"screenshot"]; + if ([screenshotDict isKindOfClass:[NSDictionary class]]) { + id maskAllText = screenshotDict[@"maskAllText"]; + if ([maskAllText isKindOfClass:[NSNumber class]]) { + sentryOptions.screenshot.maskAllText = [maskAllText boolValue]; + } + id maskAllImages = screenshotDict[@"maskAllImages"]; + if ([maskAllImages isKindOfClass:[NSNumber class]]) { + sentryOptions.screenshot.maskAllImages = [maskAllImages boolValue]; + } + id maskedViewClasses = screenshotDict[@"maskedViewClasses"]; + if ([maskedViewClasses isKindOfClass:[NSArray class]]) { + NSMutableArray *classes = [NSMutableArray array]; + for (id className in maskedViewClasses) { + if ([className isKindOfClass:[NSString class]]) { + Class cls = NSClassFromString(className); + if (cls != nil) { + [classes addObject:cls]; + } + } + } + sentryOptions.screenshot.maskedViewClasses = classes; + } + id unmaskedViewClasses = screenshotDict[@"unmaskedViewClasses"]; + if ([unmaskedViewClasses isKindOfClass:[NSArray class]]) { + NSMutableArray *classes = [NSMutableArray array]; + for (id className in unmaskedViewClasses) { + if ([className isKindOfClass:[NSString class]]) { + Class cls = NSClassFromString(className); + if (cls != nil) { + [classes addObject:cls]; + } + } + } + sentryOptions.screenshot.unmaskedViewClasses = classes; + } + } +#endif + // Exclude Dev Server and Sentry Dsn request from Breadcrumbs NSString *dsn = [self getURLFromDSN:[mutableOptions valueForKey:@"dsn"]]; // TODO: For Auto Init from JS dev server is resolved automatically, for init from options file diff --git a/packages/core/src/js/options.ts b/packages/core/src/js/options.ts index abe769069b..4dc92f7bdd 100644 --- a/packages/core/src/js/options.ts +++ b/packages/core/src/js/options.ts @@ -194,6 +194,37 @@ export interface BaseReactNativeOptions { */ attachScreenshot?: boolean; + /** + * Options for configuring screenshot masking on error screenshots. + * When `attachScreenshot` is enabled, these options control what gets masked in the screenshot. + */ + screenshot?: { + /** + * Mask all text content in error screenshots. + * + * @default true + */ + maskAllText?: boolean; + /** + * Mask all images in error screenshots. + * + * @default true + */ + maskAllImages?: boolean; + /** + * A list of native view class names to mask in error screenshots. + * Useful for masking views from third-party native libraries (e.g., map views, payment forms). + * + * @example ['com.example.MyCustomView'] // Android + * @example ['MKMapView'] // iOS + */ + maskedViewClasses?: string[]; + /** + * A list of native view class names to exclude from masking in error screenshots. + */ + unmaskedViewClasses?: string[]; + }; + /** * When enabled Sentry includes the current view hierarchy in the error attachments. * diff --git a/packages/core/test/wrapper.test.ts b/packages/core/test/wrapper.test.ts index 29b6c99781..eb7ac283e5 100644 --- a/packages/core/test/wrapper.test.ts +++ b/packages/core/test/wrapper.test.ts @@ -414,6 +414,32 @@ describe('Tests Native Wrapper', () => { expect(initParameter.strictTraceContinuation).toBe(true); }); + test('passes screenshot options to native SDK', async () => { + await NATIVE.initNativeSdk({ + dsn: 'test', + enableNative: true, + autoInitializeNativeSdk: true, + screenshot: { + maskAllText: false, + maskAllImages: true, + maskedViewClasses: ['com.example.MyView'], + unmaskedViewClasses: ['com.example.SafeView'], + }, + devServerUrl: undefined, + defaultSidecarUrl: undefined, + mobileReplayOptions: undefined, + }); + + expect(RNSentry.initNativeSdk).toHaveBeenCalled(); + const initParameter = (RNSentry.initNativeSdk as jest.MockedFunction).mock.calls[0][0]; + expect(initParameter.screenshot).toEqual({ + maskAllText: false, + maskAllImages: true, + maskedViewClasses: ['com.example.MyView'], + unmaskedViewClasses: ['com.example.SafeView'], + }); + }); + test('passes orgId option to native SDK', async () => { await NATIVE.initNativeSdk({ dsn: 'test', diff --git a/samples/react-native/src/App.tsx b/samples/react-native/src/App.tsx index eb81ad6282..5501974645 100644 --- a/samples/react-native/src/App.tsx +++ b/samples/react-native/src/App.tsx @@ -152,6 +152,11 @@ Sentry.init({ attachStacktrace: true, // Attach screenshots to events. attachScreenshot: true, + // Screenshot masking options. + screenshot: { + maskAllText: true, + maskAllImages: true, + }, // Attach view hierarchy to events. attachViewHierarchy: true, // Enables capture failed requests in JS and native.