diff --git a/CHANGELOG.md b/CHANGELOG.md index 30cfcb16d2..8536a090d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,10 @@ ### Fixes +- Apply screenshot masking in Android `captureScreenshot()` when `screenshot` options are configured ([#6565](https://github.com/getsentry/sentry-react-native/pull/6565)) + + Hybrid SDK screenshot capture (`NATIVE.captureScreenshot()`, used by the Feedback Widget and custom integrations) returned unmasked window captures on Android while iOS redacts text and images via `SentryViewPhotographer`. Error-screenshot masking (`attachScreenshot` + `ScreenshotEventProcessor`) was unaffected. Android `captureScreenshot()` now reuses the same view-hierarchy masking pipeline for configured `screenshot` options such as `maskAllText` and `maskAllImages`. + - Attach `debug_meta` to JS error events on Hermes when the Debug ID stack match fails ([#6545](https://github.com/getsentry/sentry-react-native/pull/6545)) - `sentry-expo-upload-sourcemaps` now reads plugin config when the plugin is registered as `@sentry/react-native` ([#6543](https://github.com/getsentry/sentry-react-native/pull/6543)) - Make the `RNSentry` SPEC CHECKSUM in `Podfile.lock` machine-independent ([#6534](https://github.com/getsentry/sentry-react-native/pull/6534)) 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 f2e2e09a14..a7e6d9c6c7 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,5 @@ package io.sentry.react; -import static io.sentry.android.core.internal.util.ScreenshotUtils.takeScreenshot; import static io.sentry.vendor.Base64.NO_PADDING; import static io.sentry.vendor.Base64.NO_WRAP; import static java.util.concurrent.TimeUnit.SECONDS; @@ -10,8 +9,10 @@ import android.content.pm.PackageInfo; import android.content.pm.PackageManager; import android.content.res.AssetManager; +import android.graphics.Bitmap; import android.net.Uri; import android.util.SparseIntArray; +import android.view.View; import androidx.annotation.VisibleForTesting; import androidx.core.app.FrameMetricsAggregator; import androidx.fragment.app.FragmentActivity; @@ -47,10 +48,15 @@ import io.sentry.android.core.SentryAndroidDateProvider; import io.sentry.android.core.SentryAndroidOptions; import io.sentry.android.core.SentryFramesDelayResult; +import io.sentry.android.core.SentryScreenshotOptions; import io.sentry.android.core.SentryShakeDetector; import io.sentry.android.core.ViewHierarchyEventProcessor; import io.sentry.android.core.internal.debugmeta.AssetsDebugMetaLoader; +import io.sentry.android.core.internal.util.ScreenshotUtils; import io.sentry.android.core.internal.util.SentryFrameMetricsCollector; +import io.sentry.android.replay.util.MaskRenderer; +import io.sentry.android.replay.util.ViewsKt; +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode; import io.sentry.android.core.performance.AppStartMetrics; import io.sentry.profilemeasurements.ProfileMeasurement; import io.sentry.profilemeasurements.ProfileMeasurementValue; @@ -546,7 +552,7 @@ private static byte[] takeScreenshotOnUiThread(Activity activity) { 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); doneSignal.countDown(); }; @@ -566,6 +572,84 @@ private static byte[] takeScreenshotOnUiThread(Activity activity) { return bytesWrapper[0]; } + private static @Nullable byte[] takeMaskedScreenshot(final @NotNull Activity activity) { + final @Nullable Bitmap screenshot = + ScreenshotUtils.captureScreenshot(activity, logger, buildInfo); + if (screenshot == null) { + return null; + } + + final @Nullable SentryScreenshotOptions maskingOptions = screenshotMaskingOptions(); + if (maskingOptions == null) { + return ScreenshotUtils.compressBitmapToPng(screenshot, logger); + } + + final @Nullable Bitmap masked = maskScreenshot(activity, screenshot, maskingOptions); + if (masked == null) { + return null; + } + + return ScreenshotUtils.compressBitmapToPng(masked, logger); + } + + private static @Nullable SentryScreenshotOptions screenshotMaskingOptions() { + final @NotNull SentryOptions options = ScopesAdapter.getInstance().getOptions(); + if (!(options instanceof SentryAndroidOptions)) { + return null; + } + + return ((SentryAndroidOptions) options).getScreenshot(); + } + + private static @Nullable Bitmap maskScreenshot( + final @NotNull Activity activity, + final @NotNull Bitmap screenshot, + final @NotNull SentryScreenshotOptions maskingOptions) { + Bitmap mutableScreenshot = screenshot; + boolean createdCopy = false; + try { + final @Nullable View rootView = + activity.getWindow() != null && activity.getWindow().peekDecorView() != null + ? activity.getWindow().peekDecorView().getRootView() + : null; + if (rootView == null) { + screenshot.recycle(); + return null; + } + + final @NotNull ViewHierarchyNode rootNode = + ViewHierarchyNode.Companion.fromView(rootView, null, 0, maskingOptions); + ViewsKt.traverse(rootView, rootNode, maskingOptions, logger, null); + + if (!screenshot.isMutable()) { + mutableScreenshot = screenshot.copy(Bitmap.Config.ARGB_8888, true); + if (mutableScreenshot == null) { + screenshot.recycle(); + return null; + } + createdCopy = true; + } + + try (final MaskRenderer maskRenderer = new MaskRenderer()) { + maskRenderer.renderMasks(mutableScreenshot, rootNode, null); + } + + if (createdCopy && !screenshot.isRecycled()) { + screenshot.recycle(); + } + return mutableScreenshot; + } catch (Throwable e) { // NOPMD - masking must never crash the screenshot flow + logger.log(SentryLevel.ERROR, "Failed to mask screenshot.", e); + if (createdCopy && !mutableScreenshot.isRecycled()) { + mutableScreenshot.recycle(); + } + if (!screenshot.isRecycled()) { + screenshot.recycle(); + } + return null; + } + } + public void fetchViewHierarchy(Promise promise) { final @Nullable Activity activity = getCurrentActivity(); final @Nullable ViewHierarchy viewHierarchy =