Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

- 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))
- 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))
- Add `ScreenshotUtils.takeMaskedScreenshot` so Hybrid SDKs can capture screenshots masked according to `SentryAndroidOptions.getScreenshot()` ([#6281](https://github.com/getsentry/sentry-java/pull/6281))

### Dependencies

Expand Down
Original file line number Diff line number Diff line change
@@ -1,31 +1,23 @@
package io.sentry.android.core;

import static io.sentry.TypeCheckHint.ANDROID_ACTIVITY;
import static io.sentry.android.core.internal.util.ScreenshotUtils.captureScreenshot;
import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion;

import android.app.Activity;
import android.graphics.Bitmap;
import android.view.View;
import io.sentry.Attachment;
import io.sentry.Hint;
import io.sentry.SentryEvent;
import io.sentry.SentryLevel;
import io.sentry.android.core.internal.util.AndroidCurrentDateProvider;
import io.sentry.android.core.internal.util.Debouncer;
import io.sentry.android.core.internal.util.ScreenshotUtils;
import io.sentry.android.replay.util.MaskRenderer;
import io.sentry.android.replay.util.ViewsKt;
import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode;
import io.sentry.internal.eventprocessor.SentryEventProcessor;
import io.sentry.protocol.SentryTransaction;
import io.sentry.util.ExceptionUtils;
import io.sentry.util.HintUtils;
import io.sentry.util.Objects;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
Expand All @@ -43,7 +35,6 @@ public final class ScreenshotEventProcessor implements SentryEventProcessor {
private final @NotNull Debouncer debouncer;
private static final long DEBOUNCE_WAIT_TIME_MS = 2000;
private static final int DEBOUNCE_MAX_EXECUTIONS = 3;
private static final long MASKING_TIMEOUT_MS = 2000;

private final boolean isReplayAvailable;
private final AtomicBoolean isReplayModuleAbsenceLogged = new AtomicBoolean(false);
Expand All @@ -70,10 +61,6 @@ public ScreenshotEventProcessor(
}
}

private boolean isMaskingEnabled() {
return !options.getScreenshot().getMaskViewClasses().isEmpty() && isReplayAvailable;
}

@Override
public @NotNull SentryTransaction process(
@NotNull SentryTransaction transaction, @NotNull Hint hint) {
Expand Down Expand Up @@ -131,135 +118,23 @@ private boolean isMaskingEnabled() {
return event;
}

Bitmap screenshot =
captureScreenshot(
activity, options.getThreadChecker(), options.getLogger(), buildInfoProvider);
final @Nullable Bitmap screenshot =
ScreenshotUtils.captureMaskedScreenshot(
activity, options, buildInfoProvider, isReplayAvailable);
if (screenshot == null) {
return event;
}

// Apply masking if enabled and replay module is available
if (isMaskingEnabled()) {
final @Nullable ViewHierarchyNode rootNode = captureViewHierarchy(activity);
if (rootNode == null) {
screenshot.recycle();
return event;
}
final @Nullable Bitmap masked = applyMasking(screenshot, rootNode);
if (masked == null) {
// applyMasking already recycles its bitmaps on failure
return event;
}
screenshot = masked;
}

final Bitmap finalScreenshot = screenshot;
hint.setScreenshot(
Attachment.fromByteProvider(
() -> ScreenshotUtils.compressBitmapToPng(finalScreenshot, options.getLogger()),
() -> ScreenshotUtils.compressBitmapToPng(screenshot, options.getLogger()),
"screenshot.png",
"image/png",
false));
hint.set(ANDROID_ACTIVITY, activity);
return event;
}

/**
* Captures the view hierarchy on the main thread, since view traversal requires it. If already on
* the main thread, captures directly; otherwise posts to the main thread and waits.
*/
private @Nullable ViewHierarchyNode captureViewHierarchy(final @NotNull Activity activity) {
if (options.getThreadChecker().isMainThread()) {
return buildViewHierarchy(activity);
}

final AtomicReference<ViewHierarchyNode> result = new AtomicReference<>(null);
final CountDownLatch latch = new CountDownLatch(1);

try {
activity.runOnUiThread(
() -> {
try {
result.set(buildViewHierarchy(activity));
} finally {
latch.countDown();
}
});

if (!latch.await(MASKING_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
options
.getLogger()
.log(
SentryLevel.WARNING, "Timed out waiting for view hierarchy capture on main thread");
return null;
}
} catch (Throwable e) {
options.getLogger().log(SentryLevel.ERROR, "Failed to capture view hierarchy", e);
return null;
}

return result.get();
}

private @Nullable ViewHierarchyNode buildViewHierarchy(final @NotNull Activity activity) {
try {
final @Nullable View rootView =
activity.getWindow() != null
&& activity.getWindow().peekDecorView() != null
&& activity.getWindow().peekDecorView().getRootView() != null
? activity.getWindow().peekDecorView().getRootView()
: null;
if (rootView == null) {
return null;
}

final ViewHierarchyNode rootNode =
ViewHierarchyNode.Companion.fromView(rootView, null, 0, options.getScreenshot());
ViewsKt.traverse(rootView, rootNode, options.getScreenshot(), options.getLogger(), null);
return rootNode;
} catch (Throwable e) {
options.getLogger().log(SentryLevel.ERROR, "Failed to build view hierarchy", e);
return null;
}
}

private @Nullable Bitmap applyMasking(
final @NotNull Bitmap screenshot, final @NotNull ViewHierarchyNode rootNode) {
Bitmap mutableBitmap = screenshot;
boolean createdCopy = false;
try (final MaskRenderer maskRenderer = new MaskRenderer()) {
// Make bitmap mutable if needed
if (!screenshot.isMutable()) {
mutableBitmap = screenshot.copy(Bitmap.Config.RGB_565, true);
if (mutableBitmap == null) {
screenshot.recycle();
return null;
}
createdCopy = true;
}

maskRenderer.renderMasks(mutableBitmap, rootNode, null);

// Recycle original if we created a copy
if (createdCopy && !screenshot.isRecycled()) {
screenshot.recycle();
}

return mutableBitmap;
} catch (Throwable e) {
options.getLogger().log(SentryLevel.ERROR, "Failed to mask screenshot", e);
if (createdCopy) {
if (!mutableBitmap.isRecycled()) {
mutableBitmap.recycle();
}
}
if (!screenshot.isRecycled()) {
screenshot.recycle();
}
return null;
}
}

@Override
public @Nullable Long getOrder() {
return 10000L;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
* #setMaskAllText(boolean)} and/or {@link #setMaskAllImages(boolean)}.
*
* <p>Note: Screenshot masking requires the {@code sentry-android-replay} module to be present at
* runtime. If the replay module is not available, screenshots will be captured without masking.
* runtime. If masking is enabled but the replay module is not available, no screenshot is captured
* to avoid leaking sensitive data.
*/
public final class SentryScreenshotOptions extends SentryMaskingOptions {

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
package io.sentry.android.core.internal.util;

import android.app.Activity;
import android.graphics.Bitmap;
import android.view.View;
import io.sentry.ILogger;
import io.sentry.SentryLevel;
import io.sentry.android.core.SentryScreenshotOptions;
import io.sentry.android.replay.util.MaskRenderer;
import io.sentry.android.replay.util.ViewsKt;
import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode;
import io.sentry.util.ExceptionUtils;
import io.sentry.util.LoadClass;
import io.sentry.util.thread.IThreadChecker;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

/**
* Applies screenshot masking using the Session Replay view hierarchy approach. Requires the
* `sentry-android-replay` module at runtime, callers must check {@link #isAvailable} first.
*/
@ApiStatus.Internal
public final class ScreenshotMasking {

private static final long MASKING_TIMEOUT_MS = 2000;

// Checked instead of the masking classes, since it's the only replay class whose name is kept
// by the consumer proguard rules
private static final String REPLAY_INTEGRATION_CLASS_NAME =
"io.sentry.android.replay.ReplayIntegration";

private ScreenshotMasking() {}

/** Whether the `sentry-android-replay` module, required for masking, is available at runtime. */
public static boolean isAvailable(final @NotNull ILogger logger) {
return new LoadClass().isClassAvailable(REPLAY_INTEGRATION_CLASS_NAME, logger);
}

/**
* Masks the given screenshot according to the screenshot options.
*
* @return the masked bitmap, or null if masking failed. The passed screenshot is recycled on
* failure, so a raw screenshot is never returned in place of a masked one.
*/
public static @Nullable Bitmap mask(
final @NotNull Activity activity,
final @NotNull Bitmap screenshot,
final @NotNull SentryScreenshotOptions screenshotOptions,
final @NotNull IThreadChecker threadChecker,
final @NotNull ILogger logger) {
final @Nullable ViewHierarchyNode rootNode =
captureViewHierarchy(activity, screenshotOptions, threadChecker, logger);
if (rootNode == null) {
screenshot.recycle();
return null;
}
// applyMasking already recycles its bitmaps on failure
return applyMasking(screenshot, rootNode, logger);
}

/**
* Captures the view hierarchy on the main thread, since view traversal requires it. If already on
* the main thread, captures directly; otherwise posts to the main thread and waits.
*/
private static @Nullable ViewHierarchyNode captureViewHierarchy(
final @NotNull Activity activity,
final @NotNull SentryScreenshotOptions screenshotOptions,
final @NotNull IThreadChecker threadChecker,
final @NotNull ILogger logger) {
if (threadChecker.isMainThread()) {
return buildViewHierarchy(activity, screenshotOptions, logger);
}

final AtomicReference<ViewHierarchyNode> result = new AtomicReference<>(null);
final CountDownLatch latch = new CountDownLatch(1);

try {
activity.runOnUiThread(
() -> {
try {
result.set(buildViewHierarchy(activity, screenshotOptions, logger));
} finally {
latch.countDown();
}
});

if (!latch.await(MASKING_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
logger.log(
SentryLevel.WARNING, "Timed out waiting for view hierarchy capture on main thread");
return null;
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
logger.log(SentryLevel.ERROR, "Interrupted while capturing view hierarchy", e);
return null;
Comment thread
cursor[bot] marked this conversation as resolved.
}

return result.get();
}

private static @Nullable ViewHierarchyNode buildViewHierarchy(
final @NotNull Activity activity,
final @NotNull SentryScreenshotOptions screenshotOptions,
final @NotNull ILogger logger) {
try {
final @Nullable View rootView =
activity.getWindow() != null
&& activity.getWindow().peekDecorView() != null
&& activity.getWindow().peekDecorView().getRootView() != null
? activity.getWindow().peekDecorView().getRootView()
: null;
if (rootView == null) {
return null;
}

final ViewHierarchyNode rootNode =
ViewHierarchyNode.Companion.fromView(rootView, null, 0, screenshotOptions);
ViewsKt.traverse(rootView, rootNode, screenshotOptions, logger, null);
return rootNode;
} catch (Throwable e) {
// Traversal calls into arbitrary (custom) View code, which can throw anything
ExceptionUtils.rethrowIfFatal(e);
logger.log(SentryLevel.ERROR, "Failed to build view hierarchy", e);
return null;
}
}

private static @Nullable Bitmap applyMasking(
final @NotNull Bitmap screenshot,
final @NotNull ViewHierarchyNode rootNode,
final @NotNull ILogger logger) {
Bitmap mutableBitmap = screenshot;
boolean createdCopy = false;
try (final MaskRenderer maskRenderer = new MaskRenderer()) {
// Make bitmap mutable if needed
if (!screenshot.isMutable()) {
mutableBitmap = screenshot.copy(Bitmap.Config.RGB_565, true);
if (mutableBitmap == null) {
screenshot.recycle();
return null;
}
createdCopy = true;
}

maskRenderer.renderMasks(mutableBitmap, rootNode, null);

// Recycle original if we created a copy
if (createdCopy && !screenshot.isRecycled()) {
screenshot.recycle();
}

return mutableBitmap;
} catch (RuntimeException e) {
logger.log(SentryLevel.ERROR, "Failed to mask screenshot", e);
if (createdCopy) {
if (!mutableBitmap.isRecycled()) {
mutableBitmap.recycle();
}
}
if (!screenshot.isRecycled()) {
screenshot.recycle();
}
return null;
}
}
}
Loading
Loading