Skip to content

Commit ee24060

Browse files
antonisclaude
andauthored
feat(android): Add masked screenshot capture for Hybrid SDKs (#6281)
* feat(android): Add masked screenshot capture for Hybrid SDKs Hybrid SDKs capture screenshots for their own errors through ScreenshotUtils.takeScreenshot, which ignores the screenshot masking options. Add ScreenshotUtils.takeMaskedScreenshot, backed by a shared captureMaskedScreenshot that ScreenshotEventProcessor now uses as well, so native and hybrid screenshots follow the same masking rules. Refs getsentry/sentry-react-native#6900 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * changelog * ref(android): Narrow catches in ScreenshotMasking Catch only InterruptedException around the main-thread wait and RuntimeException around bitmap masking. Keep the broad catch for view hierarchy traversal, which runs custom View code, but let fatal errors through with ExceptionUtils.rethrowIfFatal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * changelog: Move entry to Internal * ref(android): Move replay availability check to ScreenshotMasking Add ScreenshotMasking.isAvailable() so the replay integration class name no longer needs to be public on SentryAndroid. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 258098e commit ee24060

6 files changed

Lines changed: 388 additions & 130 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
- Measure the hostname cache TTL on the monotonic ticker the options provide, so that a device time change no longer shortens or extends it ([#6100](https://github.com/getsentry/sentry-java/pull/6100))
1616
- Measure Session Replay's 1h recording cap and its touch-move debounce on a monotonic clock, so a device time change no longer stops a healthy recording early, keeps a finished one alive, or suppresses gesture events ([#6090](https://github.com/getsentry/sentry-java/pull/6090))
17+
- Add `ScreenshotUtils.takeMaskedScreenshot` so Hybrid SDKs can capture screenshots masked according to `SentryAndroidOptions.getScreenshot()` ([#6281](https://github.com/getsentry/sentry-java/pull/6281))
1718

1819
### Dependencies
1920

‎sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java‎

Lines changed: 4 additions & 129 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,23 @@
11
package io.sentry.android.core;
22

33
import static io.sentry.TypeCheckHint.ANDROID_ACTIVITY;
4-
import static io.sentry.android.core.internal.util.ScreenshotUtils.captureScreenshot;
54
import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion;
65

76
import android.app.Activity;
87
import android.graphics.Bitmap;
9-
import android.view.View;
108
import io.sentry.Attachment;
119
import io.sentry.Hint;
1210
import io.sentry.SentryEvent;
1311
import io.sentry.SentryLevel;
1412
import io.sentry.android.core.internal.util.AndroidCurrentDateProvider;
1513
import io.sentry.android.core.internal.util.Debouncer;
1614
import io.sentry.android.core.internal.util.ScreenshotUtils;
17-
import io.sentry.android.replay.util.MaskRenderer;
18-
import io.sentry.android.replay.util.ViewsKt;
19-
import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode;
2015
import io.sentry.internal.eventprocessor.SentryEventProcessor;
2116
import io.sentry.protocol.SentryTransaction;
2217
import io.sentry.util.ExceptionUtils;
2318
import io.sentry.util.HintUtils;
2419
import io.sentry.util.Objects;
25-
import java.util.concurrent.CountDownLatch;
26-
import java.util.concurrent.TimeUnit;
2720
import java.util.concurrent.atomic.AtomicBoolean;
28-
import java.util.concurrent.atomic.AtomicReference;
2921
import org.jetbrains.annotations.ApiStatus;
3022
import org.jetbrains.annotations.NotNull;
3123
import org.jetbrains.annotations.Nullable;
@@ -43,7 +35,6 @@ public final class ScreenshotEventProcessor implements SentryEventProcessor {
4335
private final @NotNull Debouncer debouncer;
4436
private static final long DEBOUNCE_WAIT_TIME_MS = 2000;
4537
private static final int DEBOUNCE_MAX_EXECUTIONS = 3;
46-
private static final long MASKING_TIMEOUT_MS = 2000;
4738

4839
private final boolean isReplayAvailable;
4940
private final AtomicBoolean isReplayModuleAbsenceLogged = new AtomicBoolean(false);
@@ -70,10 +61,6 @@ public ScreenshotEventProcessor(
7061
}
7162
}
7263

73-
private boolean isMaskingEnabled() {
74-
return !options.getScreenshot().getMaskViewClasses().isEmpty() && isReplayAvailable;
75-
}
76-
7764
@Override
7865
public @NotNull SentryTransaction process(
7966
@NotNull SentryTransaction transaction, @NotNull Hint hint) {
@@ -131,135 +118,23 @@ private boolean isMaskingEnabled() {
131118
return event;
132119
}
133120

134-
Bitmap screenshot =
135-
captureScreenshot(
136-
activity, options.getThreadChecker(), options.getLogger(), buildInfoProvider);
121+
final @Nullable Bitmap screenshot =
122+
ScreenshotUtils.captureMaskedScreenshot(
123+
activity, options, buildInfoProvider, isReplayAvailable);
137124
if (screenshot == null) {
138125
return event;
139126
}
140127

141-
// Apply masking if enabled and replay module is available
142-
if (isMaskingEnabled()) {
143-
final @Nullable ViewHierarchyNode rootNode = captureViewHierarchy(activity);
144-
if (rootNode == null) {
145-
screenshot.recycle();
146-
return event;
147-
}
148-
final @Nullable Bitmap masked = applyMasking(screenshot, rootNode);
149-
if (masked == null) {
150-
// applyMasking already recycles its bitmaps on failure
151-
return event;
152-
}
153-
screenshot = masked;
154-
}
155-
156-
final Bitmap finalScreenshot = screenshot;
157128
hint.setScreenshot(
158129
Attachment.fromByteProvider(
159-
() -> ScreenshotUtils.compressBitmapToPng(finalScreenshot, options.getLogger()),
130+
() -> ScreenshotUtils.compressBitmapToPng(screenshot, options.getLogger()),
160131
"screenshot.png",
161132
"image/png",
162133
false));
163134
hint.set(ANDROID_ACTIVITY, activity);
164135
return event;
165136
}
166137

167-
/**
168-
* Captures the view hierarchy on the main thread, since view traversal requires it. If already on
169-
* the main thread, captures directly; otherwise posts to the main thread and waits.
170-
*/
171-
private @Nullable ViewHierarchyNode captureViewHierarchy(final @NotNull Activity activity) {
172-
if (options.getThreadChecker().isMainThread()) {
173-
return buildViewHierarchy(activity);
174-
}
175-
176-
final AtomicReference<ViewHierarchyNode> result = new AtomicReference<>(null);
177-
final CountDownLatch latch = new CountDownLatch(1);
178-
179-
try {
180-
activity.runOnUiThread(
181-
() -> {
182-
try {
183-
result.set(buildViewHierarchy(activity));
184-
} finally {
185-
latch.countDown();
186-
}
187-
});
188-
189-
if (!latch.await(MASKING_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
190-
options
191-
.getLogger()
192-
.log(
193-
SentryLevel.WARNING, "Timed out waiting for view hierarchy capture on main thread");
194-
return null;
195-
}
196-
} catch (Throwable e) {
197-
options.getLogger().log(SentryLevel.ERROR, "Failed to capture view hierarchy", e);
198-
return null;
199-
}
200-
201-
return result.get();
202-
}
203-
204-
private @Nullable ViewHierarchyNode buildViewHierarchy(final @NotNull Activity activity) {
205-
try {
206-
final @Nullable View rootView =
207-
activity.getWindow() != null
208-
&& activity.getWindow().peekDecorView() != null
209-
&& activity.getWindow().peekDecorView().getRootView() != null
210-
? activity.getWindow().peekDecorView().getRootView()
211-
: null;
212-
if (rootView == null) {
213-
return null;
214-
}
215-
216-
final ViewHierarchyNode rootNode =
217-
ViewHierarchyNode.Companion.fromView(rootView, null, 0, options.getScreenshot());
218-
ViewsKt.traverse(rootView, rootNode, options.getScreenshot(), options.getLogger(), null);
219-
return rootNode;
220-
} catch (Throwable e) {
221-
options.getLogger().log(SentryLevel.ERROR, "Failed to build view hierarchy", e);
222-
return null;
223-
}
224-
}
225-
226-
private @Nullable Bitmap applyMasking(
227-
final @NotNull Bitmap screenshot, final @NotNull ViewHierarchyNode rootNode) {
228-
Bitmap mutableBitmap = screenshot;
229-
boolean createdCopy = false;
230-
try (final MaskRenderer maskRenderer = new MaskRenderer()) {
231-
// Make bitmap mutable if needed
232-
if (!screenshot.isMutable()) {
233-
mutableBitmap = screenshot.copy(Bitmap.Config.RGB_565, true);
234-
if (mutableBitmap == null) {
235-
screenshot.recycle();
236-
return null;
237-
}
238-
createdCopy = true;
239-
}
240-
241-
maskRenderer.renderMasks(mutableBitmap, rootNode, null);
242-
243-
// Recycle original if we created a copy
244-
if (createdCopy && !screenshot.isRecycled()) {
245-
screenshot.recycle();
246-
}
247-
248-
return mutableBitmap;
249-
} catch (Throwable e) {
250-
options.getLogger().log(SentryLevel.ERROR, "Failed to mask screenshot", e);
251-
if (createdCopy) {
252-
if (!mutableBitmap.isRecycled()) {
253-
mutableBitmap.recycle();
254-
}
255-
}
256-
if (!screenshot.isRecycled()) {
257-
screenshot.recycle();
258-
}
259-
return null;
260-
}
261-
}
262-
263138
@Override
264139
public @Nullable Long getOrder() {
265140
return 10000L;

‎sentry-android-core/src/main/java/io/sentry/android/core/SentryScreenshotOptions.java‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
* #setMaskAllText(boolean)} and/or {@link #setMaskAllImages(boolean)}.
1111
*
1212
* <p>Note: Screenshot masking requires the {@code sentry-android-replay} module to be present at
13-
* runtime. If the replay module is not available, screenshots will be captured without masking.
13+
* runtime. If masking is enabled but the replay module is not available, no screenshot is captured
14+
* to avoid leaking sensitive data.
1415
*/
1516
public final class SentryScreenshotOptions extends SentryMaskingOptions {
1617

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
package io.sentry.android.core.internal.util;
2+
3+
import android.app.Activity;
4+
import android.graphics.Bitmap;
5+
import android.view.View;
6+
import io.sentry.ILogger;
7+
import io.sentry.SentryLevel;
8+
import io.sentry.android.core.SentryScreenshotOptions;
9+
import io.sentry.android.replay.util.MaskRenderer;
10+
import io.sentry.android.replay.util.ViewsKt;
11+
import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode;
12+
import io.sentry.util.ExceptionUtils;
13+
import io.sentry.util.LoadClass;
14+
import io.sentry.util.thread.IThreadChecker;
15+
import java.util.concurrent.CountDownLatch;
16+
import java.util.concurrent.TimeUnit;
17+
import java.util.concurrent.atomic.AtomicReference;
18+
import org.jetbrains.annotations.ApiStatus;
19+
import org.jetbrains.annotations.NotNull;
20+
import org.jetbrains.annotations.Nullable;
21+
22+
/**
23+
* Applies screenshot masking using the Session Replay view hierarchy approach. Requires the
24+
* `sentry-android-replay` module at runtime, callers must check {@link #isAvailable} first.
25+
*/
26+
@ApiStatus.Internal
27+
public final class ScreenshotMasking {
28+
29+
private static final long MASKING_TIMEOUT_MS = 2000;
30+
31+
// Checked instead of the masking classes, since it's the only replay class whose name is kept
32+
// by the consumer proguard rules
33+
private static final String REPLAY_INTEGRATION_CLASS_NAME =
34+
"io.sentry.android.replay.ReplayIntegration";
35+
36+
private ScreenshotMasking() {}
37+
38+
/** Whether the `sentry-android-replay` module, required for masking, is available at runtime. */
39+
public static boolean isAvailable(final @NotNull ILogger logger) {
40+
return new LoadClass().isClassAvailable(REPLAY_INTEGRATION_CLASS_NAME, logger);
41+
}
42+
43+
/**
44+
* Masks the given screenshot according to the screenshot options.
45+
*
46+
* @return the masked bitmap, or null if masking failed. The passed screenshot is recycled on
47+
* failure, so a raw screenshot is never returned in place of a masked one.
48+
*/
49+
public static @Nullable Bitmap mask(
50+
final @NotNull Activity activity,
51+
final @NotNull Bitmap screenshot,
52+
final @NotNull SentryScreenshotOptions screenshotOptions,
53+
final @NotNull IThreadChecker threadChecker,
54+
final @NotNull ILogger logger) {
55+
final @Nullable ViewHierarchyNode rootNode =
56+
captureViewHierarchy(activity, screenshotOptions, threadChecker, logger);
57+
if (rootNode == null) {
58+
screenshot.recycle();
59+
return null;
60+
}
61+
// applyMasking already recycles its bitmaps on failure
62+
return applyMasking(screenshot, rootNode, logger);
63+
}
64+
65+
/**
66+
* Captures the view hierarchy on the main thread, since view traversal requires it. If already on
67+
* the main thread, captures directly; otherwise posts to the main thread and waits.
68+
*/
69+
private static @Nullable ViewHierarchyNode captureViewHierarchy(
70+
final @NotNull Activity activity,
71+
final @NotNull SentryScreenshotOptions screenshotOptions,
72+
final @NotNull IThreadChecker threadChecker,
73+
final @NotNull ILogger logger) {
74+
if (threadChecker.isMainThread()) {
75+
return buildViewHierarchy(activity, screenshotOptions, logger);
76+
}
77+
78+
final AtomicReference<ViewHierarchyNode> result = new AtomicReference<>(null);
79+
final CountDownLatch latch = new CountDownLatch(1);
80+
81+
try {
82+
activity.runOnUiThread(
83+
() -> {
84+
try {
85+
result.set(buildViewHierarchy(activity, screenshotOptions, logger));
86+
} finally {
87+
latch.countDown();
88+
}
89+
});
90+
91+
if (!latch.await(MASKING_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
92+
logger.log(
93+
SentryLevel.WARNING, "Timed out waiting for view hierarchy capture on main thread");
94+
return null;
95+
}
96+
} catch (InterruptedException e) {
97+
Thread.currentThread().interrupt();
98+
logger.log(SentryLevel.ERROR, "Interrupted while capturing view hierarchy", e);
99+
return null;
100+
}
101+
102+
return result.get();
103+
}
104+
105+
private static @Nullable ViewHierarchyNode buildViewHierarchy(
106+
final @NotNull Activity activity,
107+
final @NotNull SentryScreenshotOptions screenshotOptions,
108+
final @NotNull ILogger logger) {
109+
try {
110+
final @Nullable View rootView =
111+
activity.getWindow() != null
112+
&& activity.getWindow().peekDecorView() != null
113+
&& activity.getWindow().peekDecorView().getRootView() != null
114+
? activity.getWindow().peekDecorView().getRootView()
115+
: null;
116+
if (rootView == null) {
117+
return null;
118+
}
119+
120+
final ViewHierarchyNode rootNode =
121+
ViewHierarchyNode.Companion.fromView(rootView, null, 0, screenshotOptions);
122+
ViewsKt.traverse(rootView, rootNode, screenshotOptions, logger, null);
123+
return rootNode;
124+
} catch (Throwable e) {
125+
// Traversal calls into arbitrary (custom) View code, which can throw anything
126+
ExceptionUtils.rethrowIfFatal(e);
127+
logger.log(SentryLevel.ERROR, "Failed to build view hierarchy", e);
128+
return null;
129+
}
130+
}
131+
132+
private static @Nullable Bitmap applyMasking(
133+
final @NotNull Bitmap screenshot,
134+
final @NotNull ViewHierarchyNode rootNode,
135+
final @NotNull ILogger logger) {
136+
Bitmap mutableBitmap = screenshot;
137+
boolean createdCopy = false;
138+
try (final MaskRenderer maskRenderer = new MaskRenderer()) {
139+
// Make bitmap mutable if needed
140+
if (!screenshot.isMutable()) {
141+
mutableBitmap = screenshot.copy(Bitmap.Config.RGB_565, true);
142+
if (mutableBitmap == null) {
143+
screenshot.recycle();
144+
return null;
145+
}
146+
createdCopy = true;
147+
}
148+
149+
maskRenderer.renderMasks(mutableBitmap, rootNode, null);
150+
151+
// Recycle original if we created a copy
152+
if (createdCopy && !screenshot.isRecycled()) {
153+
screenshot.recycle();
154+
}
155+
156+
return mutableBitmap;
157+
} catch (RuntimeException e) {
158+
logger.log(SentryLevel.ERROR, "Failed to mask screenshot", e);
159+
if (createdCopy) {
160+
if (!mutableBitmap.isRecycled()) {
161+
mutableBitmap.recycle();
162+
}
163+
}
164+
if (!screenshot.isRecycled()) {
165+
screenshot.recycle();
166+
}
167+
return null;
168+
}
169+
}
170+
}

0 commit comments

Comments
 (0)