feat(screenshot): Add screenshot masking using view hierarchy by romtsn · Pull Request #5077 · getsentry/sentry-java

Expand Up @@ -6,6 +6,7 @@
import android.app.Activity; import android.graphics.Bitmap; import android.view.View; import io.sentry.Attachment; import io.sentry.EventProcessor; import io.sentry.Hint; Expand All @@ -14,9 +15,16 @@ 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.protocol.SentryTransaction; 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 @@ -34,10 +42,15 @@ public final class ScreenshotEventProcessor implements EventProcessor { 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);
public ScreenshotEventProcessor( final @NotNull SentryAndroidOptions options, final @NotNull BuildInfoProvider buildInfoProvider) { final @NotNull BuildInfoProvider buildInfoProvider, final boolean isReplayAvailable) { this.options = Objects.requireNonNull(options, "SentryAndroidOptions is required"); this.buildInfoProvider = Objects.requireNonNull(buildInfoProvider, "BuildInfoProvider is required"); Expand All @@ -47,11 +60,17 @@ public ScreenshotEventProcessor( DEBOUNCE_WAIT_TIME_MS, DEBOUNCE_MAX_EXECUTIONS);
this.isReplayAvailable = isReplayAvailable;
if (options.isAttachScreenshot()) { addIntegrationToSdkVersion("Screenshot"); } }
private boolean isMaskingEnabled() { return !options.getScreenshot().getMaskViewClasses().isEmpty() && isReplayAvailable; }
@Override public @NotNull SentryTransaction process( @NotNull SentryTransaction transaction, @NotNull Hint hint) { Expand All @@ -71,6 +90,15 @@ public ScreenshotEventProcessor(
return event; } if (!isReplayAvailable && !options.getScreenshot().getMaskViewClasses().isEmpty()) { if (!isReplayModuleAbsenceLogged.getAndSet(true)) { options .getLogger() .log(SentryLevel.WARNING, "Screenshot masking requires sentry-android-replay module"); } return event; }
final @Nullable Activity activity = CurrentActivityHolder.getInstance().getActivity(); if (activity == null || HintUtils.isFromHybridSdk(hint)) { return event; Expand All @@ -89,23 +117,135 @@ public ScreenshotEventProcessor( return event; }
final Bitmap screenshot = Bitmap screenshot = captureScreenshot( activity, options.getThreadChecker(), options.getLogger(), buildInfoProvider); 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(screenshot, options.getLogger()), () -> ScreenshotUtils.compressBitmapToPng(finalScreenshot, 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()); 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.ARGB_8888, 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