diff --git a/CHANGELOG.md b/CHANGELOG.md index 496c5333ec..15817dd70d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java index 6283b47f95..8b51c512c8 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java @@ -1,12 +1,10 @@ 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; @@ -14,18 +12,12 @@ 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; @@ -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); @@ -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) { @@ -131,32 +118,16 @@ 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)); @@ -164,102 +135,6 @@ private boolean isMaskingEnabled() { 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 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; diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryScreenshotOptions.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryScreenshotOptions.java index 8680ed907d..6e1cceb760 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryScreenshotOptions.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryScreenshotOptions.java @@ -10,7 +10,8 @@ * #setMaskAllText(boolean)} and/or {@link #setMaskAllImages(boolean)}. * *

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 { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotMasking.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotMasking.java new file mode 100644 index 0000000000..4d3e2d1595 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotMasking.java @@ -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 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; + } + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotUtils.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotUtils.java index 092caa040d..657ba54cbd 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotUtils.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotUtils.java @@ -14,6 +14,7 @@ import io.sentry.ILogger; import io.sentry.SentryLevel; import io.sentry.android.core.BuildInfoProvider; +import io.sentry.android.core.SentryAndroidOptions; import io.sentry.util.thread.IThreadChecker; import java.io.ByteArrayOutputStream; import java.util.concurrent.CountDownLatch; @@ -51,6 +52,62 @@ public class ScreenshotUtils { return compressBitmapToPng(screenshot, logger); } + // Used by Hybrid SDKs + /** + * Takes a screenshot and masks it according to {@link SentryAndroidOptions#getScreenshot()}, the + * same way {@link io.sentry.android.core.ScreenshotEventProcessor} does for native errors. + * + * @see #captureMaskedScreenshot(Activity, SentryAndroidOptions, BuildInfoProvider, boolean) + * @noinspection unused + */ + public static @Nullable byte[] takeMaskedScreenshot( + final @NotNull Activity activity, + final @NotNull SentryAndroidOptions options, + final @NotNull BuildInfoProvider buildInfoProvider) { + final boolean isReplayAvailable = ScreenshotMasking.isAvailable(options.getLogger()); + return compressBitmapToPng( + captureMaskedScreenshot(activity, options, buildInfoProvider, isReplayAvailable), + options.getLogger()); + } + + /** + * Captures a screenshot and masks it according to {@link SentryAndroidOptions#getScreenshot()}. + * Shared by {@link io.sentry.android.core.ScreenshotEventProcessor} and Hybrid SDKs, so native + * and hybrid screenshots follow the same masking rules. + * + *

If no masking is configured, the screenshot is returned unmasked. If masking is configured + * but the `sentry-android-replay` module is not available, or masking fails, null is returned to + * avoid leaking sensitive data. + */ + public static @Nullable Bitmap captureMaskedScreenshot( + final @NotNull Activity activity, + final @NotNull SentryAndroidOptions options, + final @NotNull BuildInfoProvider buildInfoProvider, + final boolean isReplayAvailable) { + final boolean isMaskingConfigured = !options.getScreenshot().getMaskViewClasses().isEmpty(); + if (isMaskingConfigured && !isReplayAvailable) { + options + .getLogger() + .log(SentryLevel.WARNING, "Screenshot masking requires sentry-android-replay module"); + return null; + } + + final @Nullable Bitmap screenshot = + captureScreenshot( + activity, options.getThreadChecker(), options.getLogger(), buildInfoProvider); + if (screenshot == null || !isMaskingConfigured) { + return screenshot; + } + + // mask() recycles the screenshot on failure, so a raw screenshot is never returned + return ScreenshotMasking.mask( + activity, + screenshot, + options.getScreenshot(), + options.getThreadChecker(), + options.getLogger()); + } + public static @Nullable Bitmap captureScreenshot( final @NotNull Activity activity, final @NotNull ILogger logger, diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/ScreenshotUtilsMaskedScreenshotTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/ScreenshotUtilsMaskedScreenshotTest.kt new file mode 100644 index 0000000000..9bc6f9f85b --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/ScreenshotUtilsMaskedScreenshotTest.kt @@ -0,0 +1,154 @@ +package io.sentry.android.core.internal.util + +import android.app.Activity +import android.content.Context +import android.graphics.Color +import android.os.Bundle +import android.widget.LinearLayout +import android.widget.LinearLayout.LayoutParams +import android.widget.TextView +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.android.core.BuildInfoProvider +import io.sentry.android.core.SentryAndroidOptions +import io.sentry.util.thread.IThreadChecker +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.Robolectric.buildActivity +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import org.robolectric.shadows.ShadowPixelCopy + +@RunWith(AndroidJUnit4::class) +@Config(shadows = [ShadowPixelCopy::class], sdk = [30]) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class ScreenshotUtilsMaskedScreenshotTest { + + private class Fixture { + lateinit var activity: Activity + val threadChecker = mock() + val options = + SentryAndroidOptions().apply { + dsn = "https://key@sentry.io/proj" + threadChecker = this@Fixture.threadChecker + } + val buildInfoProvider = BuildInfoProvider(options.logger) + + init { + whenever(threadChecker.isMainThread).thenReturn(true) + } + } + + private lateinit var fixture: Fixture + + private fun takeMaskedScreenshot(isReplayAvailable: Boolean): ByteArray? = + ScreenshotUtils.compressBitmapToPng( + ScreenshotUtils.captureMaskedScreenshot( + fixture.activity, + fixture.options, + fixture.buildInfoProvider, + isReplayAvailable, + ), + fixture.options.logger, + ) + + @BeforeTest + fun `set up`() { + System.setProperty("robolectric.areWindowsMarkedVisible", "true") + System.setProperty("robolectric.pixelCopyRenderMode", "hardware") + + fixture = Fixture() + fixture.activity = buildActivity(TextActivity::class.java, null).setup().get() + } + + @Test + fun `when masking is not configured, returns an unmasked screenshot`() { + val bytes = takeMaskedScreenshot(isReplayAvailable = false) + + assertNotNull(bytes) + } + + @Test + fun `when masking is configured, returns a masked screenshot`() { + val unmasked = takeMaskedScreenshot(isReplayAvailable = true) + fixture.options.screenshot.setMaskAllText(true) + val masked = takeMaskedScreenshot(isReplayAvailable = true) + + assertNotNull(unmasked) + assertNotNull(masked) + assertFalse(unmasked.contentEquals(masked)) + } + + @Test + fun `when masking is configured but replay is not available, returns no screenshot`() { + fixture.options.screenshot.setMaskAllText(true) + + val bytes = takeMaskedScreenshot(isReplayAvailable = false) + + assertNull(bytes) + } + + @Test + fun `when masking is configured but fails, returns no screenshot`() { + fixture.options.screenshot.setMaskAllText(true) + fixture.activity.findViewById(android.R.id.text1).throwOnTraversal = true + + val bytes = takeMaskedScreenshot(isReplayAvailable = true) + + assertNull(bytes) + } + + @Test + fun `public overload detects replay availability`() { + fixture.options.screenshot.setMaskAllText(true) + + // sentry-android-replay is on the test classpath + val bytes = + ScreenshotUtils.takeMaskedScreenshot( + fixture.activity, + fixture.options, + fixture.buildInfoProvider, + ) + + assertNotNull(bytes) + } +} + +private class TextActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val linearLayout = + LinearLayout(this).apply { + setBackgroundColor(Color.WHITE) + orientation = LinearLayout.VERTICAL + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + } + linearLayout.addView( + ThrowingTextView(this).apply { + id = android.R.id.text1 + text = "Sensitive text" + setTextColor(Color.BLACK) + textSize = 32f + layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) + } + ) + setContentView(linearLayout) + } +} + +/** Fails view hierarchy traversal on demand, to exercise the masking failure path. */ +private class ThrowingTextView(context: Context) : TextView(context) { + var throwOnTraversal = false + + override fun getTotalPaddingLeft(): Int { + if (throwOnTraversal) { + throw IllegalStateException("Traversal failure") + } + return super.getTotalPaddingLeft() + } +}