Skip to content

Commit 3334b5a

Browse files
authored
Merge effe222 into 8696c89
2 parents 8696c89 + effe222 commit 3334b5a

File tree

16 files changed

+244
-3
lines changed

16 files changed

+244
-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 `SentryAndroid.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-core/api/sentry-android-core.api

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,7 @@ public final class io/sentry/android/core/SentryAndroid {
280280
public static fun init (Landroid/content/Context;Lio/sentry/ILogger;)V
281281
public static fun init (Landroid/content/Context;Lio/sentry/ILogger;Lio/sentry/Sentry$OptionsConfiguration;)V
282282
public static fun init (Landroid/content/Context;Lio/sentry/Sentry$OptionsConfiguration;)V
283+
public static fun replay ()Lio/sentry/IReplayApi;
283284
}
284285

285286
public final class io/sentry/android/core/SentryAndroidDateProvider : io/sentry/SentryDateProvider {

sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import android.os.Process;
77
import android.os.SystemClock;
88
import io.sentry.ILogger;
9+
import io.sentry.IReplayApi;
910
import io.sentry.IScopes;
1011
import io.sentry.ISentryLifecycleToken;
1112
import io.sentry.Integration;
@@ -254,4 +255,9 @@ private static void deduplicateIntegrations(
254255
}
255256
}
256257
}
258+
259+
@NotNull
260+
public static IReplayApi replay() {
261+
return Sentry.getCurrentScopes().getScope().getOptions().getReplayController();
262+
}
257263
}

sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import io.sentry.DateUtils
1515
import io.sentry.Hint
1616
import io.sentry.ILogger
1717
import io.sentry.ISentryClient
18+
import io.sentry.ReplayController
1819
import io.sentry.Sentry
1920
import io.sentry.Sentry.OptionsConfiguration
2021
import io.sentry.SentryEnvelope
@@ -540,6 +541,21 @@ class SentryAndroidTest {
540541
assertTrue(optionsRef.eventProcessors.any { it is AnrV2EventProcessor })
541542
}
542543

544+
@Test
545+
fun `replay debug masking is forwarded to replay controller`() {
546+
val replayController = mock<ReplayController>()
547+
fixture.initSut(context = mock<Application>()) { options ->
548+
options.dsn = "https://key@sentry.io/123"
549+
options.setReplayController(replayController)
550+
}
551+
552+
SentryAndroid.replay().enableDebugMaskingOverlay()
553+
verify(replayController).enableDebugMaskingOverlay()
554+
555+
SentryAndroid.replay().disableDebugMaskingOverlay()
556+
verify(replayController).disableDebugMaskingOverlay()
557+
}
558+
543559
private fun prefillScopeCache(options: SentryOptions, cacheDir: String) {
544560
val scopeDir = File(cacheDir, SCOPE_CACHE).also { it.mkdirs() }
545561
val queueFile = QueueFile.Builder(File(scopeDir, BREADCRUMBS_FILENAME)).build()

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: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import io.sentry.ISpan;
99
import io.sentry.MeasurementUnit;
1010
import io.sentry.Sentry;
11+
import io.sentry.android.replay.ReplayIntegration;
1112
import io.sentry.instrumentation.file.SentryFileOutputStream;
1213
import io.sentry.protocol.Feedback;
1314
import io.sentry.protocol.User;
@@ -273,6 +274,11 @@ public void run() {
273274
CoroutinesUtil.INSTANCE.throwInCoroutine();
274275
});
275276

277+
binding.enableReplayDebugMode.setOnClickListener(
278+
view -> {
279+
ReplayIntegration.enableDebugMasking();
280+
});
281+
276282
setContentView(binding.getRoot());
277283
}
278284

0 commit comments

Comments
 (0)