From d1a9c4566b288f839395b558d996e612306fc802 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Fri, 25 Apr 2025 10:07:47 +0200 Subject: [PATCH 1/9] Add masking debug overlay --- .../android/replay/ScreenshotRecorder.kt | 24 +++++++ .../replay/util/DebugOverlayDrawable.kt | 66 +++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/util/DebugOverlayDrawable.kt diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index 39ad176c8d1..0a406094a72 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -1,5 +1,6 @@ package io.sentry.android.replay +import android.annotation.SuppressLint import android.annotation.TargetApi import android.content.Context import android.graphics.Bitmap @@ -21,6 +22,7 @@ import io.sentry.SentryLevel.INFO import io.sentry.SentryLevel.WARNING import io.sentry.SentryOptions import io.sentry.SentryReplayOptions +import io.sentry.android.replay.util.DebugOverlayDrawable import io.sentry.android.replay.util.MainLooperHandler import io.sentry.android.replay.util.addOnDrawListenerSafe import io.sentry.android.replay.util.getVisibleRects @@ -37,6 +39,7 @@ import java.util.concurrent.atomic.AtomicBoolean import kotlin.LazyThreadSafetyMode.NONE import kotlin.math.roundToInt +@SuppressLint("UseKtx") @TargetApi(26) internal class ScreenshotRecorder( val config: ScreenshotRecorderConfig, @@ -46,6 +49,10 @@ internal class ScreenshotRecorder( private val screenshotRecorderCallback: ScreenshotRecorderCallback? ) : ViewTreeObserver.OnDrawListener { + private companion object { + private const val DEBUG_MODE = false + } + private var rootView: WeakReference? = null private val maskingPaint by lazy(NONE) { Paint() } private val singlePixelBitmap: Bitmap by lazy(NONE) { @@ -70,6 +77,8 @@ internal class ScreenshotRecorder( private val isCapturing = AtomicBoolean(true) private val lastCaptureSuccessful = AtomicBoolean(false) + private val debugOverlayDrawable = DebugOverlayDrawable() + fun capture() { if (!isCapturing.get()) { if (options.sessionReplay.isDebug) { @@ -121,6 +130,8 @@ internal class ScreenshotRecorder( root.traverse(viewHierarchy, options) recorder.submitSafely(options, "screenshot_recorder.mask") { + val debugMasks = mutableListOf() + val canvas = Canvas(screenshot) canvas.setMatrix(prescaledMatrix) viewHierarchy.traverse { node -> @@ -158,10 +169,16 @@ internal class ScreenshotRecorder( visibleRects.forEach { rect -> canvas.drawRoundRect(RectF(rect), 10f, 10f, maskingPaint) } + if (DEBUG_MODE) { + debugMasks.addAll(visibleRects) + } } return@traverse true } + if (DEBUG_MODE) { + mainLooperHandler.post { debugOverlayDrawable.update(debugMasks) } + } screenshotRecorderCallback?.onScreenshotRecorded(screenshot) lastCaptureSuccessful.set(true) contentChanged.set(false) @@ -194,11 +211,18 @@ internal class ScreenshotRecorder( // next bind the new root rootView = WeakReference(root) root.addOnDrawListenerSafe(this) + if (DEBUG_MODE) { + root.overlay.add(debugOverlayDrawable) + } + // invalidate the flag to capture the first frame after new window is attached contentChanged.set(true) } fun unbind(root: View?) { + if (DEBUG_MODE) { + root?.overlay?.remove(debugOverlayDrawable) + } root?.removeOnDrawListenerSafe(this) } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/DebugOverlayDrawable.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/DebugOverlayDrawable.kt new file mode 100644 index 00000000000..e3b0117dc38 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/DebugOverlayDrawable.kt @@ -0,0 +1,66 @@ +package io.sentry.android.replay.util + +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.ColorFilter +import android.graphics.Paint +import android.graphics.PixelFormat +import android.graphics.Rect +import android.graphics.drawable.Drawable + +internal class DebugOverlayDrawable : Drawable() { + + private val paint = Paint(Paint.ANTI_ALIAS_FLAG) + private val padding = 6f + private val tmpRect = Rect() + private var masks: List = emptyList() + + override fun draw(canvas: Canvas) { + paint.textSize = 32f + paint.setColor(Color.BLACK) + + paint.strokeWidth = 4f + + for (mask in masks) { + paint.setColor(Color.BLACK) + paint.style = Paint.Style.STROKE + canvas.drawRect(mask, paint) + + paint.style = Paint.Style.FILL + val label = "${mask.left} ${mask.top}" + paint.getTextBounds(label, 0, label.length, tmpRect) + + paint.setColor(Color.WHITE) + canvas.drawRect( + mask.left.toFloat() + paint.strokeWidth / 2, + mask.top.toFloat() + paint.strokeWidth / 2, + mask.left.toFloat() + tmpRect.width() + padding + padding, + mask.top.toFloat() + tmpRect.height() + padding + padding, + paint + ) + paint.setColor(Color.BLACK) + canvas.drawText( + label, + mask.left.toFloat() + padding + paint.strokeWidth / 2, + mask.top.toFloat() + tmpRect.height() - tmpRect.bottom + padding + paint.strokeWidth / 2, + paint + ) + } + } + + override fun setAlpha(alpha: Int) { + // no-op + } + + override fun setColorFilter(colorFilter: ColorFilter?) { + // no-op + } + + @Deprecated("Deprecated in Java") + override fun getOpacity(): Int = PixelFormat.TRANSLUCENT + + fun update(masks: List) { + this.masks = masks + invalidateSelf() + } +} From ac42ba69c25b91ccc546cf2c3524cc8f4f8f7226 Mon Sep 17 00:00:00 2001 From: markushi Date: Wed, 7 May 2025 13:11:18 +0200 Subject: [PATCH 2/9] Improve contrast for debug mode --- .../main/java/io/sentry/android/replay/ScreenshotRecorder.kt | 3 +++ .../io/sentry/android/replay/util/DebugOverlayDrawable.kt | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index 0a406094a72..ec714b3a8e6 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -201,6 +201,9 @@ internal class ScreenshotRecorder( } contentChanged.set(true) + if (DEBUG_MODE) { + debugOverlayDrawable.invalidateSelf() + } } fun bind(root: View) { diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/DebugOverlayDrawable.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/DebugOverlayDrawable.kt index e3b0117dc38..d7b0381e310 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/DebugOverlayDrawable.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/DebugOverlayDrawable.kt @@ -22,7 +22,7 @@ internal class DebugOverlayDrawable : Drawable() { paint.strokeWidth = 4f for (mask in masks) { - paint.setColor(Color.BLACK) + paint.setColor(Color.argb(128, 255, 20, 20)) paint.style = Paint.Style.STROKE canvas.drawRect(mask, paint) @@ -30,7 +30,7 @@ internal class DebugOverlayDrawable : Drawable() { val label = "${mask.left} ${mask.top}" paint.getTextBounds(label, 0, label.length, tmpRect) - paint.setColor(Color.WHITE) + paint.setColor(Color.argb(128, 255, 255, 255)) canvas.drawRect( mask.left.toFloat() + paint.strokeWidth / 2, mask.top.toFloat() + paint.strokeWidth / 2, From 63940e0159027247a02c6c5464ecb9eb7a11a093 Mon Sep 17 00:00:00 2001 From: markushi Date: Mon, 12 May 2025 19:12:03 +0200 Subject: [PATCH 3/9] Improve readability --- .../replay/util/DebugOverlayDrawable.kt | 39 +++++++++++++------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/DebugOverlayDrawable.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/DebugOverlayDrawable.kt index d7b0381e310..9b18779cde5 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/DebugOverlayDrawable.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/DebugOverlayDrawable.kt @@ -15,30 +15,44 @@ internal class DebugOverlayDrawable : Drawable() { private val tmpRect = Rect() private var masks: List = emptyList() + companion object { + private val maskBackgroundColor = Color.argb(64, 255, 20, 20) + private val maskBorderColor = Color.argb(128, 255, 20, 20) + private const val TEXT_COLOR = Color.BLACK + private const val TEXT_OUTLINE_COLOR = Color.WHITE + + private const val STROKE_WIDTH = 6f + private const val TEXT_SIZE = 32f + } + override fun draw(canvas: Canvas) { - paint.textSize = 32f + paint.textSize = TEXT_SIZE paint.setColor(Color.BLACK) - paint.strokeWidth = 4f + paint.strokeWidth = STROKE_WIDTH for (mask in masks) { - paint.setColor(Color.argb(128, 255, 20, 20)) + paint.setColor(maskBackgroundColor) + paint.style = Paint.Style.FILL + canvas.drawRect(mask, paint) + + paint.setColor(maskBorderColor) paint.style = Paint.Style.STROKE canvas.drawRect(mask, paint) - paint.style = Paint.Style.FILL val label = "${mask.left} ${mask.top}" paint.getTextBounds(label, 0, label.length, tmpRect) - - paint.setColor(Color.argb(128, 255, 255, 255)) - canvas.drawRect( - mask.left.toFloat() + paint.strokeWidth / 2, - mask.top.toFloat() + paint.strokeWidth / 2, - mask.left.toFloat() + tmpRect.width() + padding + padding, - mask.top.toFloat() + tmpRect.height() + padding + padding, + paint.style = Paint.Style.STROKE + paint.setColor(TEXT_OUTLINE_COLOR) + canvas.drawText( + label, + mask.left.toFloat() + padding + paint.strokeWidth / 2, + mask.top.toFloat() + tmpRect.height() - tmpRect.bottom + padding + paint.strokeWidth / 2, paint ) - paint.setColor(Color.BLACK) + + paint.style = Paint.Style.FILL + paint.setColor(TEXT_COLOR) canvas.drawText( label, mask.left.toFloat() + padding + paint.strokeWidth / 2, @@ -61,6 +75,7 @@ internal class DebugOverlayDrawable : Drawable() { fun update(masks: List) { this.masks = masks + invalidateSelf() } } From bee207ed4582466e407c99e3690f1484cfa57697 Mon Sep 17 00:00:00 2001 From: markushi Date: Fri, 16 May 2025 11:00:44 +0200 Subject: [PATCH 4/9] Add ReplayIntegration.enableDebugMasking API, improve debug overlay --- .../api/sentry-android-replay.api | 7 +++ .../android/replay/ReplayIntegration.kt | 10 +++- .../android/replay/ScreenshotRecorder.kt | 24 ++++---- .../replay/util/DebugOverlayDrawable.kt | 60 +++++++++++++------ .../sentry/samples/android/MainActivity.java | 6 ++ .../src/main/res/layout/activity_main.xml | 6 ++ .../src/main/res/values/strings.xml | 1 + 7 files changed, 80 insertions(+), 34 deletions(-) diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index d4e20da038c..2dd2de6e3b8 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -52,11 +52,13 @@ public final class io/sentry/android/replay/ReplayCache : java/io/Closeable { public final class io/sentry/android/replay/ReplayIntegration : android/content/ComponentCallbacks, io/sentry/IConnectionStatusProvider$IConnectionStatusObserver, io/sentry/Integration, io/sentry/ReplayController, io/sentry/android/replay/ScreenshotRecorderCallback, io/sentry/android/replay/gestures/TouchRecorderCallback, io/sentry/transport/RateLimiter$IRateLimitObserver, java/io/Closeable { public static final field $stable I + public static final field Companion Lio/sentry/android/replay/ReplayIntegration$Companion; public fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;)V public fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V public synthetic fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun captureReplay (Ljava/lang/Boolean;)V public fun close ()V + public static final fun enableDebugMasking ()V public fun getBreadcrumbConverter ()Lio/sentry/ReplayBreadcrumbConverter; public final fun getReplayCacheDir ()Ljava/io/File; public fun getReplayId ()Lio/sentry/protocol/SentryId; @@ -76,6 +78,11 @@ public final class io/sentry/android/replay/ReplayIntegration : android/content/ public fun stop ()V } +public final class io/sentry/android/replay/ReplayIntegration$Companion { + public final fun enableDebugMasking ()V + public final fun getDebugMaskingEnabled ()Z +} + public abstract interface class io/sentry/android/replay/ScreenshotRecorderCallback { public abstract fun onScreenshotRecorded (Landroid/graphics/Bitmap;)V public abstract fun onScreenshotRecorded (Ljava/io/File;J)V diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 094ce469b1a..e9bb1a24666 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -71,11 +71,19 @@ public class ReplayIntegration( IConnectionStatusObserver, IRateLimitObserver { - private companion object { + public companion object { init { SentryIntegrationPackageStorage.getInstance() .addPackage("maven:io.sentry:sentry-android-replay", BuildConfig.VERSION_NAME) } + + public var debugMaskingEnabled: Boolean = false + private set + + @JvmStatic + public fun enableDebugMasking() { + debugMaskingEnabled = true + } } // needed for the Java's call site diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index ec714b3a8e6..35c66d1738c 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -49,10 +49,6 @@ internal class ScreenshotRecorder( private val screenshotRecorderCallback: ScreenshotRecorderCallback? ) : ViewTreeObserver.OnDrawListener { - private companion object { - private const val DEBUG_MODE = false - } - private var rootView: WeakReference? = null private val maskingPaint by lazy(NONE) { Paint() } private val singlePixelBitmap: Bitmap by lazy(NONE) { @@ -169,15 +165,21 @@ internal class ScreenshotRecorder( visibleRects.forEach { rect -> canvas.drawRoundRect(RectF(rect), 10f, 10f, maskingPaint) } - if (DEBUG_MODE) { + if (ReplayIntegration.debugMaskingEnabled) { debugMasks.addAll(visibleRects) } } return@traverse true } - if (DEBUG_MODE) { - mainLooperHandler.post { debugOverlayDrawable.update(debugMasks) } + if (ReplayIntegration.debugMaskingEnabled) { + mainLooperHandler.post { + if (debugOverlayDrawable.callback == null) { + root.overlay.add(debugOverlayDrawable) + } + debugOverlayDrawable.updateMasks(debugMasks) + root.postInvalidate() + } } screenshotRecorderCallback?.onScreenshotRecorded(screenshot) lastCaptureSuccessful.set(true) @@ -201,9 +203,6 @@ internal class ScreenshotRecorder( } contentChanged.set(true) - if (DEBUG_MODE) { - debugOverlayDrawable.invalidateSelf() - } } fun bind(root: View) { @@ -214,16 +213,13 @@ internal class ScreenshotRecorder( // next bind the new root rootView = WeakReference(root) root.addOnDrawListenerSafe(this) - if (DEBUG_MODE) { - root.overlay.add(debugOverlayDrawable) - } // invalidate the flag to capture the first frame after new window is attached contentChanged.set(true) } fun unbind(root: View?) { - if (DEBUG_MODE) { + if (ReplayIntegration.debugMaskingEnabled) { root?.overlay?.remove(debugOverlayDrawable) } root?.removeOnDrawListenerSafe(this) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/DebugOverlayDrawable.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/DebugOverlayDrawable.kt index 9b18779cde5..97054a4f80e 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/DebugOverlayDrawable.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/DebugOverlayDrawable.kt @@ -16,7 +16,7 @@ internal class DebugOverlayDrawable : Drawable() { private var masks: List = emptyList() companion object { - private val maskBackgroundColor = Color.argb(64, 255, 20, 20) + private val maskBackgroundColor = Color.argb(32, 255, 20, 20) private val maskBorderColor = Color.argb(128, 255, 20, 20) private const val TEXT_COLOR = Color.BLACK private const val TEXT_OUTLINE_COLOR = Color.WHITE @@ -40,28 +40,51 @@ internal class DebugOverlayDrawable : Drawable() { paint.style = Paint.Style.STROKE canvas.drawRect(mask, paint) - val label = "${mask.left} ${mask.top}" - paint.getTextBounds(label, 0, label.length, tmpRect) - paint.style = Paint.Style.STROKE - paint.setColor(TEXT_OUTLINE_COLOR) - canvas.drawText( - label, - mask.left.toFloat() + padding + paint.strokeWidth / 2, - mask.top.toFloat() + tmpRect.height() - tmpRect.bottom + padding + paint.strokeWidth / 2, - paint + val topLeftLabel = "${mask.left}/${mask.top}" + paint.getTextBounds(topLeftLabel, 0, topLeftLabel.length, tmpRect) + drawTextWithOutline( + canvas, + topLeftLabel, + mask.left.toFloat(), + mask.top.toFloat() ) - paint.style = Paint.Style.FILL - paint.setColor(TEXT_COLOR) - canvas.drawText( - label, - mask.left.toFloat() + padding + paint.strokeWidth / 2, - mask.top.toFloat() + tmpRect.height() - tmpRect.bottom + padding + paint.strokeWidth / 2, - paint + val bottomRightLabel = "${mask.right}/${mask.bottom}" + paint.getTextBounds(bottomRightLabel, 0, bottomRightLabel.length, tmpRect) + drawTextWithOutline( + canvas, + bottomRightLabel, + mask.right.toFloat() - tmpRect.width(), + mask.bottom.toFloat() + tmpRect.height() ) } } + private fun drawTextWithOutline( + canvas: Canvas, + bottomRightLabel: String, + x: Float, + y: Float + ) { + paint.setColor(TEXT_OUTLINE_COLOR) + paint.style = Paint.Style.STROKE + canvas.drawText( + bottomRightLabel, + x, + y, + paint + ) + + paint.setColor(TEXT_COLOR) + paint.style = Paint.Style.FILL + canvas.drawText( + bottomRightLabel, + x, + y, + paint + ) + } + override fun setAlpha(alpha: Int) { // no-op } @@ -73,9 +96,8 @@ internal class DebugOverlayDrawable : Drawable() { @Deprecated("Deprecated in Java") override fun getOpacity(): Int = PixelFormat.TRANSLUCENT - fun update(masks: List) { + fun updateMasks(masks: List) { this.masks = masks - invalidateSelf() } } diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java index 802e765a9e4..a8d0d923aaa 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java @@ -9,6 +9,7 @@ import io.sentry.MeasurementUnit; import io.sentry.Sentry; import io.sentry.UserFeedback; +import io.sentry.android.replay.ReplayIntegration; import io.sentry.instrumentation.file.SentryFileOutputStream; import io.sentry.protocol.SentryId; import io.sentry.protocol.User; @@ -275,6 +276,11 @@ public void run() { CoroutinesUtil.INSTANCE.throwInCoroutine(); }); + binding.enableReplayDebugMode.setOnClickListener( + view -> { + ReplayIntegration.enableDebugMasking(); + }); + setContentView(binding.getRoot()); } diff --git a/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml b/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml index 71c8059d588..fcb0553a58d 100644 --- a/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml +++ b/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml @@ -154,6 +154,12 @@ android:layout_height="wrap_content" android:text="@string/throw_in_coroutine"/> +