Repository navigation
feat(android): Add masked screenshot capture for Hybrid SDKs #6281
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
795e5c8
feat(android): Add masked screenshot capture for Hybrid SDKs
antonis 40136c3
changelog
antonis e821ddc
ref(android): Narrow catches in ScreenshotMasking
antonis 06677c4
changelog: Move entry to Internal
antonis 57c6ba7
ref(android): Move replay availability check to ScreenshotMasking
antonis dd01500
Merge remote-tracking branch 'origin/main' into feat/hybrid-masked-sc…
antonis File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
170 changes: 170 additions & 0 deletions
170
...ry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotMasking.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
|
|
||
| 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; | ||
| } | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.