Skip to content

Commit 21f2cfa

Browse files
authored
Add masking debug overlay (#4357)
* Add masking debug overlay * Improve contrast for debug mode * Improve readability * Add ReplayIntegration.enableDebugMasking API, improve debug overlay * Update Changelog * Introduce IReplayApi * Update Changelog * Fix Sample app * Move replay API from SentryAndroid to Sentry
1 parent a87214f commit 21f2cfa

File tree

15 files changed

+240
-3
lines changed

15 files changed

+240
-3
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
### Features
6+
7+
- Add debug mode for Session Replay masking ([#4357](https://github.com/getsentry/sentry-java/pull/4357))
8+
- Use `Sentry.replay().enableDebugMaskingOverlay()` to overlay the screen with the Session Replay masks.
9+
- The masks will be invalidated at most once per `frameRate` (default 1 fps).
10+
311
## 8.12.0
412

513
### Features

sentry-android-replay/api/sentry-android-replay.api

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,12 @@ public final class io/sentry/android/replay/ReplayIntegration : android/content/
5757
public synthetic fun <init> (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
5858
public fun captureReplay (Ljava/lang/Boolean;)V
5959
public fun close ()V
60+
public fun disableDebugMaskingOverlay ()V
61+
public fun enableDebugMaskingOverlay ()V
6062
public fun getBreadcrumbConverter ()Lio/sentry/ReplayBreadcrumbConverter;
6163
public final fun getReplayCacheDir ()Ljava/io/File;
6264
public fun getReplayId ()Lio/sentry/protocol/SentryId;
65+
public fun isDebugMaskingOverlayEnabled ()Z
6366
public fun isRecording ()Z
6467
public fun onConfigurationChanged (Landroid/content/res/Configuration;)V
6568
public fun onConnectionStatusChanged (Lio/sentry/IConnectionStatusProvider$ConnectionStatus;)V

sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ public class ReplayIntegration(
101101
this.mainLooperHandler = mainLooperHandler ?: MainLooperHandler()
102102
this.gestureRecorderProvider = gestureRecorderProvider
103103
}
104-
104+
private var debugMaskingEnabled: Boolean = false
105105
private lateinit var options: SentryOptions
106106
private var scopes: IScopes? = null
107107
private var recorder: Recorder? = null
@@ -251,6 +251,16 @@ public class ReplayIntegration(
251251
pauseInternal()
252252
}
253253

254+
override fun enableDebugMaskingOverlay() {
255+
debugMaskingEnabled = true
256+
}
257+
258+
override fun disableDebugMaskingOverlay() {
259+
debugMaskingEnabled = false
260+
}
261+
262+
override fun isDebugMaskingOverlayEnabled(): Boolean = debugMaskingEnabled
263+
254264
private fun pauseInternal() {
255265
lifecycleLock.acquire().use {
256266
if (!isEnabled.get() || !lifecycle.isAllowed(PAUSED)) {

sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package io.sentry.android.replay
22

3+
import android.annotation.SuppressLint
34
import android.annotation.TargetApi
45
import android.content.Context
56
import android.graphics.Bitmap
@@ -21,6 +22,7 @@ import io.sentry.SentryLevel.INFO
2122
import io.sentry.SentryLevel.WARNING
2223
import io.sentry.SentryOptions
2324
import io.sentry.SentryReplayOptions
25+
import io.sentry.android.replay.util.DebugOverlayDrawable
2426
import io.sentry.android.replay.util.MainLooperHandler
2527
import io.sentry.android.replay.util.addOnDrawListenerSafe
2628
import io.sentry.android.replay.util.getVisibleRects
@@ -37,6 +39,7 @@ import java.util.concurrent.atomic.AtomicBoolean
3739
import kotlin.LazyThreadSafetyMode.NONE
3840
import kotlin.math.roundToInt
3941

42+
@SuppressLint("UseKtx")
4043
@TargetApi(26)
4144
internal class ScreenshotRecorder(
4245
val config: ScreenshotRecorderConfig,
@@ -70,6 +73,8 @@ internal class ScreenshotRecorder(
7073
private val isCapturing = AtomicBoolean(true)
7174
private val lastCaptureSuccessful = AtomicBoolean(false)
7275

76+
private val debugOverlayDrawable = DebugOverlayDrawable()
77+
7378
fun capture() {
7479
if (!isCapturing.get()) {
7580
if (options.sessionReplay.isDebug) {
@@ -121,6 +126,8 @@ internal class ScreenshotRecorder(
121126
root.traverse(viewHierarchy, options)
122127

123128
recorder.submitSafely(options, "screenshot_recorder.mask") {
129+
val debugMasks = mutableListOf<Rect>()
130+
124131
val canvas = Canvas(screenshot)
125132
canvas.setMatrix(prescaledMatrix)
126133
viewHierarchy.traverse { node ->
@@ -158,10 +165,22 @@ internal class ScreenshotRecorder(
158165
visibleRects.forEach { rect ->
159166
canvas.drawRoundRect(RectF(rect), 10f, 10f, maskingPaint)
160167
}
168+
if (options.replayController.isDebugMaskingOverlayEnabled()) {
169+
debugMasks.addAll(visibleRects)
170+
}
161171
}
162172
return@traverse true
163173
}
164174

175+
if (options.replayController.isDebugMaskingOverlayEnabled()) {
176+
mainLooperHandler.post {
177+
if (debugOverlayDrawable.callback == null) {
178+
root.overlay.add(debugOverlayDrawable)
179+
}
180+
debugOverlayDrawable.updateMasks(debugMasks)
181+
root.postInvalidate()
182+
}
183+
}
165184
screenshotRecorderCallback?.onScreenshotRecorded(screenshot)
166185
lastCaptureSuccessful.set(true)
167186
contentChanged.set(false)
@@ -194,11 +213,15 @@ internal class ScreenshotRecorder(
194213
// next bind the new root
195214
rootView = WeakReference(root)
196215
root.addOnDrawListenerSafe(this)
216+
197217
// invalidate the flag to capture the first frame after new window is attached
198218
contentChanged.set(true)
199219
}
200220

201221
fun unbind(root: View?) {
222+
if (options.replayController.isDebugMaskingOverlayEnabled()) {
223+
root?.overlay?.remove(debugOverlayDrawable)
224+
}
202225
root?.removeOnDrawListenerSafe(this)
203226
}
204227

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package io.sentry.android.replay.util
2+
3+
import android.graphics.Canvas
4+
import android.graphics.Color
5+
import android.graphics.ColorFilter
6+
import android.graphics.Paint
7+
import android.graphics.PixelFormat
8+
import android.graphics.Rect
9+
import android.graphics.drawable.Drawable
10+
11+
internal class DebugOverlayDrawable : Drawable() {
12+
13+
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
14+
private val padding = 6f
15+
private val tmpRect = Rect()
16+
private var masks: List<Rect> = emptyList()
17+
18+
companion object {
19+
private val maskBackgroundColor = Color.argb(32, 255, 20, 20)
20+
private val maskBorderColor = Color.argb(128, 255, 20, 20)
21+
private const val TEXT_COLOR = Color.BLACK
22+
private const val TEXT_OUTLINE_COLOR = Color.WHITE
23+
24+
private const val STROKE_WIDTH = 6f
25+
private const val TEXT_SIZE = 32f
26+
}
27+
28+
override fun draw(canvas: Canvas) {
29+
paint.textSize = TEXT_SIZE
30+
paint.setColor(Color.BLACK)
31+
32+
paint.strokeWidth = STROKE_WIDTH
33+
34+
for (mask in masks) {
35+
paint.setColor(maskBackgroundColor)
36+
paint.style = Paint.Style.FILL
37+
canvas.drawRect(mask, paint)
38+
39+
paint.setColor(maskBorderColor)
40+
paint.style = Paint.Style.STROKE
41+
canvas.drawRect(mask, paint)
42+
43+
val topLeftLabel = "${mask.left}/${mask.top}"
44+
paint.getTextBounds(topLeftLabel, 0, topLeftLabel.length, tmpRect)
45+
drawTextWithOutline(
46+
canvas,
47+
topLeftLabel,
48+
mask.left.toFloat(),
49+
mask.top.toFloat()
50+
)
51+
52+
val bottomRightLabel = "${mask.right}/${mask.bottom}"
53+
paint.getTextBounds(bottomRightLabel, 0, bottomRightLabel.length, tmpRect)
54+
drawTextWithOutline(
55+
canvas,
56+
bottomRightLabel,
57+
mask.right.toFloat() - tmpRect.width(),
58+
mask.bottom.toFloat() + tmpRect.height()
59+
)
60+
}
61+
}
62+
63+
private fun drawTextWithOutline(
64+
canvas: Canvas,
65+
bottomRightLabel: String,
66+
x: Float,
67+
y: Float
68+
) {
69+
paint.setColor(TEXT_OUTLINE_COLOR)
70+
paint.style = Paint.Style.STROKE
71+
canvas.drawText(
72+
bottomRightLabel,
73+
x,
74+
y,
75+
paint
76+
)
77+
78+
paint.setColor(TEXT_COLOR)
79+
paint.style = Paint.Style.FILL
80+
canvas.drawText(
81+
bottomRightLabel,
82+
x,
83+
y,
84+
paint
85+
)
86+
}
87+
88+
override fun setAlpha(alpha: Int) {
89+
// no-op
90+
}
91+
92+
override fun setColorFilter(colorFilter: ColorFilter?) {
93+
// no-op
94+
}
95+
96+
@Deprecated("Deprecated in Java")
97+
override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
98+
99+
fun updateMasks(masks: List<Rect>) {
100+
this.masks = masks
101+
invalidateSelf()
102+
}
103+
}

sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -867,6 +867,26 @@ class ReplayIntegrationTest {
867867
verify(recorder).resume()
868868
}
869869

870+
@Test
871+
fun `debug masking is disabled by default`() {
872+
val replay = fixture.getSut(
873+
context
874+
)
875+
assertFalse(replay.isDebugMaskingOverlayEnabled)
876+
}
877+
878+
@Test
879+
fun `debug masking can be enabled and disabled`() {
880+
val replay = fixture.getSut(
881+
context
882+
)
883+
replay.enableDebugMaskingOverlay()
884+
assertTrue(replay.isDebugMaskingOverlayEnabled)
885+
886+
replay.disableDebugMaskingOverlay()
887+
assertFalse(replay.isDebugMaskingOverlayEnabled)
888+
}
889+
870890
private fun getSessionCaptureStrategy(options: SentryOptions): SessionCaptureStrategy {
871891
return SessionCaptureStrategy(
872892
options,

sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,11 @@ public void run() {
273273
CoroutinesUtil.INSTANCE.throwInCoroutine();
274274
});
275275

276+
binding.enableReplayDebugMode.setOnClickListener(
277+
view -> {
278+
Sentry.replay().enableDebugMaskingOverlay();
279+
});
280+
276281
setContentView(binding.getRoot());
277282
}
278283

sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,12 @@
154154
android:layout_height="wrap_content"
155155
android:text="@string/throw_in_coroutine"/>
156156

157+
<Button
158+
android:id="@+id/enable_replay_debug_mode"
159+
android:layout_width="wrap_content"
160+
android:layout_height="wrap_content"
161+
android:text="@string/enable_replay_debug_mode"/>
162+
157163
</LinearLayout>
158164

159165
</ScrollView>

sentry-samples/sentry-samples-android/src/main/res/values/strings.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
<string name="open_metrics">Delightful Developer Metrics</string>
2828
<string name="test_timber_integration">Test Timber</string>
2929
<string name="throw_in_coroutine">Throw exception in coroutine</string>
30+
<string name="enable_replay_debug_mode">Enable Replay Debug Mode</string>
3031
<string name="back_main">Back to Main Activity</string>
3132
<string name="tap_me">text</string>
3233
<string name="lipsum">Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin nibh lorem, venenatis sed nulla vel, venenatis sodales augue. Mauris varius elit eu ligula volutpat, sed tincidunt orci porttitor. Donec et dignissim lacus, sed luctus ipsum. Praesent ornare luctus tortor sit amet ultricies. Cras iaculis et diam et vulputate. Cras ut iaculis mauris, non pellentesque diam. Nunc in laoreet diam, vitae accumsan eros. Morbi non nunc ac eros molestie placerat vitae id dolor. Quisque ornare aliquam ipsum, a dapibus tortor. In eu sodales tellus.

sentry/api/sentry.api

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -817,6 +817,11 @@ public abstract interface class io/sentry/IPerformanceSnapshotCollector : io/sen
817817
public abstract fun setup ()V
818818
}
819819

820+
public abstract interface class io/sentry/IReplayApi {
821+
public abstract fun disableDebugMaskingOverlay ()V
822+
public abstract fun enableDebugMaskingOverlay ()V
823+
}
824+
820825
public abstract interface class io/sentry/IScope {
821826
public abstract fun addAttachment (Lio/sentry/Attachment;)V
822827
public abstract fun addBreadcrumb (Lio/sentry/Breadcrumb;)V
@@ -1567,9 +1572,12 @@ public final class io/sentry/NoOpReplayBreadcrumbConverter : io/sentry/ReplayBre
15671572

15681573
public final class io/sentry/NoOpReplayController : io/sentry/ReplayController {
15691574
public fun captureReplay (Ljava/lang/Boolean;)V
1575+
public fun disableDebugMaskingOverlay ()V
1576+
public fun enableDebugMaskingOverlay ()V
15701577
public fun getBreadcrumbConverter ()Lio/sentry/ReplayBreadcrumbConverter;
15711578
public static fun getInstance ()Lio/sentry/NoOpReplayController;
15721579
public fun getReplayId ()Lio/sentry/protocol/SentryId;
1580+
public fun isDebugMaskingOverlayEnabled ()Z
15731581
public fun isRecording ()Z
15741582
public fun pause ()V
15751583
public fun resume ()V
@@ -2170,10 +2178,11 @@ public abstract interface class io/sentry/ReplayBreadcrumbConverter {
21702178
public abstract fun convert (Lio/sentry/Breadcrumb;)Lio/sentry/rrweb/RRWebEvent;
21712179
}
21722180

2173-
public abstract interface class io/sentry/ReplayController {
2181+
public abstract interface class io/sentry/ReplayController : io/sentry/IReplayApi {
21742182
public abstract fun captureReplay (Ljava/lang/Boolean;)V
21752183
public abstract fun getBreadcrumbConverter ()Lio/sentry/ReplayBreadcrumbConverter;
21762184
public abstract fun getReplayId ()Lio/sentry/protocol/SentryId;
2185+
public abstract fun isDebugMaskingOverlayEnabled ()Z
21772186
public abstract fun isRecording ()Z
21782187
public abstract fun pause ()V
21792188
public abstract fun resume ()V
@@ -2580,6 +2589,7 @@ public final class io/sentry/Sentry {
25802589
public static fun pushScope ()Lio/sentry/ISentryLifecycleToken;
25812590
public static fun removeExtra (Ljava/lang/String;)V
25822591
public static fun removeTag (Ljava/lang/String;)V
2592+
public static fun replay ()Lio/sentry/IReplayApi;
25832593
public static fun reportFullyDisplayed ()V
25842594
public static fun setCurrentHub (Lio/sentry/IHub;)Lio/sentry/ISentryLifecycleToken;
25852595
public static fun setCurrentScopes (Lio/sentry/IScopes;)Lio/sentry/ISentryLifecycleToken;

0 commit comments

Comments
 (0)