Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## Unreleased

### Features

- Add debug mode for Session Replay masking ([#4357](https://github.com/getsentry/sentry-java/pull/4357))
- Use `Sentry.replay().enableDebugMaskingOverlay()` to overlay the screen with the Session Replay masks.
- The masks will be invalidated at most once per `frameRate` (default 1 fps).

## 8.12.0

### Features
Expand Down
3 changes: 3 additions & 0 deletions sentry-android-replay/api/sentry-android-replay.api
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,12 @@ public final class io/sentry/android/replay/ReplayIntegration : android/content/
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
public fun captureReplay (Ljava/lang/Boolean;)V
public fun close ()V
public fun disableDebugMaskingOverlay ()V
public fun enableDebugMaskingOverlay ()V
public fun getBreadcrumbConverter ()Lio/sentry/ReplayBreadcrumbConverter;
public final fun getReplayCacheDir ()Ljava/io/File;
public fun getReplayId ()Lio/sentry/protocol/SentryId;
public fun isDebugMaskingOverlayEnabled ()Z
public fun isRecording ()Z
public fun onConfigurationChanged (Landroid/content/res/Configuration;)V
public fun onConnectionStatusChanged (Lio/sentry/IConnectionStatusProvider$ConnectionStatus;)V
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ public class ReplayIntegration(
this.mainLooperHandler = mainLooperHandler ?: MainLooperHandler()
this.gestureRecorderProvider = gestureRecorderProvider
}

private var debugMaskingEnabled: Boolean = false
private lateinit var options: SentryOptions
private var scopes: IScopes? = null
private var recorder: Recorder? = null
Expand Down Expand Up @@ -251,6 +251,16 @@ public class ReplayIntegration(
pauseInternal()
}

override fun enableDebugMaskingOverlay() {
debugMaskingEnabled = true
}

override fun disableDebugMaskingOverlay() {
debugMaskingEnabled = false
}

override fun isDebugMaskingOverlayEnabled(): Boolean = debugMaskingEnabled

private fun pauseInternal() {
lifecycleLock.acquire().use {
if (!isEnabled.get() || !lifecycle.isAllowed(PAUSED)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -70,6 +73,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) {
Expand Down Expand Up @@ -121,6 +126,8 @@ internal class ScreenshotRecorder(
root.traverse(viewHierarchy, options)

recorder.submitSafely(options, "screenshot_recorder.mask") {
val debugMasks = mutableListOf<Rect>()

val canvas = Canvas(screenshot)
canvas.setMatrix(prescaledMatrix)
viewHierarchy.traverse { node ->
Expand Down Expand Up @@ -158,10 +165,22 @@ internal class ScreenshotRecorder(
visibleRects.forEach { rect ->
canvas.drawRoundRect(RectF(rect), 10f, 10f, maskingPaint)
}
if (options.replayController.isDebugMaskingOverlayEnabled()) {
debugMasks.addAll(visibleRects)
}
}
return@traverse true
}

if (options.replayController.isDebugMaskingOverlayEnabled()) {
mainLooperHandler.post {
if (debugOverlayDrawable.callback == null) {
root.overlay.add(debugOverlayDrawable)
}
debugOverlayDrawable.updateMasks(debugMasks)
root.postInvalidate()
}
}
screenshotRecorderCallback?.onScreenshotRecorded(screenshot)
lastCaptureSuccessful.set(true)
contentChanged.set(false)
Expand Down Expand Up @@ -194,11 +213,15 @@ internal class ScreenshotRecorder(
// next bind the new root
rootView = WeakReference(root)
root.addOnDrawListenerSafe(this)

// invalidate the flag to capture the first frame after new window is attached
contentChanged.set(true)
}

fun unbind(root: View?) {
if (options.replayController.isDebugMaskingOverlayEnabled()) {
root?.overlay?.remove(debugOverlayDrawable)
}
root?.removeOnDrawListenerSafe(this)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
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<Rect> = emptyList()

companion object {
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

private const val STROKE_WIDTH = 6f
private const val TEXT_SIZE = 32f
}

override fun draw(canvas: Canvas) {
paint.textSize = TEXT_SIZE
paint.setColor(Color.BLACK)

paint.strokeWidth = STROKE_WIDTH

for (mask in masks) {
paint.setColor(maskBackgroundColor)
paint.style = Paint.Style.FILL
canvas.drawRect(mask, paint)

paint.setColor(maskBorderColor)
paint.style = Paint.Style.STROKE
canvas.drawRect(mask, paint)

val topLeftLabel = "${mask.left}/${mask.top}"
paint.getTextBounds(topLeftLabel, 0, topLeftLabel.length, tmpRect)
drawTextWithOutline(
canvas,
topLeftLabel,
mask.left.toFloat(),
mask.top.toFloat()
)

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
}

override fun setColorFilter(colorFilter: ColorFilter?) {
// no-op
}

@Deprecated("Deprecated in Java")
override fun getOpacity(): Int = PixelFormat.TRANSLUCENT

fun updateMasks(masks: List<Rect>) {
this.masks = masks
invalidateSelf()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -867,6 +867,26 @@ class ReplayIntegrationTest {
verify(recorder).resume()
}

@Test
fun `debug masking is disabled by default`() {
val replay = fixture.getSut(
context
)
assertFalse(replay.isDebugMaskingOverlayEnabled)
}

@Test
fun `debug masking can be enabled and disabled`() {
val replay = fixture.getSut(
context
)
replay.enableDebugMaskingOverlay()
assertTrue(replay.isDebugMaskingOverlayEnabled)

replay.disableDebugMaskingOverlay()
assertFalse(replay.isDebugMaskingOverlayEnabled)
}

private fun getSessionCaptureStrategy(options: SentryOptions): SessionCaptureStrategy {
return SessionCaptureStrategy(
options,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,11 @@ public void run() {
CoroutinesUtil.INSTANCE.throwInCoroutine();
});

binding.enableReplayDebugMode.setOnClickListener(
view -> {
Sentry.replay().enableDebugMaskingOverlay();
});

setContentView(binding.getRoot());
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,12 @@
android:layout_height="wrap_content"
android:text="@string/throw_in_coroutine"/>

<Button
android:id="@+id/enable_replay_debug_mode"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/enable_replay_debug_mode"/>

</LinearLayout>

</ScrollView>
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
<string name="open_metrics">Delightful Developer Metrics</string>
<string name="test_timber_integration">Test Timber</string>
<string name="throw_in_coroutine">Throw exception in coroutine</string>
<string name="enable_replay_debug_mode">Enable Replay Debug Mode</string>
<string name="back_main">Back to Main Activity</string>
<string name="tap_me">text</string>
<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.
Expand Down
12 changes: 11 additions & 1 deletion sentry/api/sentry.api
Original file line number Diff line number Diff line change
Expand Up @@ -817,6 +817,11 @@ public abstract interface class io/sentry/IPerformanceSnapshotCollector : io/sen
public abstract fun setup ()V
}

public abstract interface class io/sentry/IReplayApi {
public abstract fun disableDebugMaskingOverlay ()V
public abstract fun enableDebugMaskingOverlay ()V
}

public abstract interface class io/sentry/IScope {
public abstract fun addAttachment (Lio/sentry/Attachment;)V
public abstract fun addBreadcrumb (Lio/sentry/Breadcrumb;)V
Expand Down Expand Up @@ -1567,9 +1572,12 @@ public final class io/sentry/NoOpReplayBreadcrumbConverter : io/sentry/ReplayBre

public final class io/sentry/NoOpReplayController : io/sentry/ReplayController {
public fun captureReplay (Ljava/lang/Boolean;)V
public fun disableDebugMaskingOverlay ()V
public fun enableDebugMaskingOverlay ()V
public fun getBreadcrumbConverter ()Lio/sentry/ReplayBreadcrumbConverter;
public static fun getInstance ()Lio/sentry/NoOpReplayController;
public fun getReplayId ()Lio/sentry/protocol/SentryId;
public fun isDebugMaskingOverlayEnabled ()Z
public fun isRecording ()Z
public fun pause ()V
public fun resume ()V
Expand Down Expand Up @@ -2170,10 +2178,11 @@ public abstract interface class io/sentry/ReplayBreadcrumbConverter {
public abstract fun convert (Lio/sentry/Breadcrumb;)Lio/sentry/rrweb/RRWebEvent;
}

public abstract interface class io/sentry/ReplayController {
public abstract interface class io/sentry/ReplayController : io/sentry/IReplayApi {
public abstract fun captureReplay (Ljava/lang/Boolean;)V
public abstract fun getBreadcrumbConverter ()Lio/sentry/ReplayBreadcrumbConverter;
public abstract fun getReplayId ()Lio/sentry/protocol/SentryId;
public abstract fun isDebugMaskingOverlayEnabled ()Z
public abstract fun isRecording ()Z
public abstract fun pause ()V
public abstract fun resume ()V
Expand Down Expand Up @@ -2580,6 +2589,7 @@ public final class io/sentry/Sentry {
public static fun pushScope ()Lio/sentry/ISentryLifecycleToken;
public static fun removeExtra (Ljava/lang/String;)V
public static fun removeTag (Ljava/lang/String;)V
public static fun replay ()Lio/sentry/IReplayApi;
public static fun reportFullyDisplayed ()V
public static fun setCurrentHub (Lio/sentry/IHub;)Lio/sentry/ISentryLifecycleToken;
public static fun setCurrentScopes (Lio/sentry/IScopes;)Lio/sentry/ISentryLifecycleToken;
Expand Down
16 changes: 16 additions & 0 deletions sentry/src/main/java/io/sentry/IReplayApi.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package io.sentry;

public interface IReplayApi {

/**
* Draws a masking overlay on top of the screen to help visualize which parts of the screen are
* masked by Session Replay. This is only useful for debugging purposes and should not be used in
* production environments.
*
* <p>Expect the top level view to be invalidated more often than usual, as the overlay is drawn
* on top of it.
*/
void enableDebugMaskingOverlay();

void disableDebugMaskingOverlay();
}
11 changes: 11 additions & 0 deletions sentry/src/main/java/io/sentry/NoOpReplayController.java
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,15 @@ public void setBreadcrumbConverter(@NotNull ReplayBreadcrumbConverter converter)
public @NotNull ReplayBreadcrumbConverter getBreadcrumbConverter() {
return NoOpReplayBreadcrumbConverter.getInstance();
}

@Override
public boolean isDebugMaskingOverlayEnabled() {
return false;
}

@Override
public void enableDebugMaskingOverlay() {}

@Override
public void disableDebugMaskingOverlay() {}
}
4 changes: 3 additions & 1 deletion sentry/src/main/java/io/sentry/ReplayController.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import org.jetbrains.annotations.Nullable;

@ApiStatus.Internal
public interface ReplayController {
public interface ReplayController extends IReplayApi {
void start();

void stop();
Expand All @@ -26,4 +26,6 @@ public interface ReplayController {

@NotNull
ReplayBreadcrumbConverter getBreadcrumbConverter();

boolean isDebugMaskingOverlayEnabled();
}
Loading
Loading