Skip to content
Open
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mask rendering blocks Android UI thread

Medium Severity

takeMaskedScreenshot() performs hierarchy traversal and full-bitmap mask rendering inside the UI-thread runnable. Complex screens can freeze rendering or trigger an ANR, while background callers may time out and receive null even though masking continues.

Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit 1f29c11. Configure here.

doneSignal.countDown();
};

Expand All @@ -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);
}
Comment on lines +583 to +585

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The takeMaskedScreenshot method leaks a Bitmap object on every call because it fails to call recycle() after the bitmap is compressed, in both masked and unmasked code paths.
Severity: HIGH

Suggested Fix

Wrap the Bitmap usage in a try-finally block within the takeMaskedScreenshot method. In the finally block, ensure that the Bitmap object (screenshot or masked) is recycled by calling recycle() on it, but only after it has been successfully compressed to a byte array.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location:
packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java#L583-L585

Potential issue: The `takeMaskedScreenshot` method leaks a `Bitmap` object's native
memory on every invocation. When screenshot masking is not configured, the original
`screenshot` `Bitmap` is compressed but never recycled. When masking is successful, the
`masked` `Bitmap` is compressed but also not recycled. Since this can be called
frequently, this memory leak can accumulate quickly, potentially leading to
`OutOfMemoryError` crashes in the application.

Also affects:

  • packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java:591~593


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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Disabled masking now requires Replay

Medium Severity

getScreenshot() is non-null even when masking is disabled. Returning it unconditionally routes every capture through Replay masking, so apps excluding sentry-android-replay now receive null from captureScreenshot() and ordinary captures can fail due to unrelated masking errors.

Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit bfb341e. Configure here.

}

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 =
Expand Down
Loading