Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
dde1cf9
WIP
romtsn Jun 30, 2025
ea48169
refactor(replay): Use main thread handler to schedule replay capture
romtsn Jun 30, 2025
2ab706a
Remove logging in test
romtsn Jun 30, 2025
6b45f95
Merge branch 'main' into rz/ref/capturer-main-thread
romtsn Jul 2, 2025
55c69c0
Format code
getsentry-bot Jul 7, 2025
cda9443
Merge branch 'main' into rz/ref/capturer-main-thread
romtsn Jul 7, 2025
1fa757f
Changelog
romtsn Jul 7, 2025
78118d7
Initial impl of text ignoring canvas approach
markushi Jul 15, 2025
9c96090
Extend options
markushi Jul 16, 2025
7b6b9db
Fix race conditions
markushi Jul 21, 2025
6adbca9
Cleanup
markushi Jul 21, 2025
a5db384
Draw rects for text, utilize Bitmap.asShared
markushi Jul 24, 2025
b4d8e03
Fix processing on main thread, picture canvas size
markushi Jul 25, 2025
49b8d9a
Naive way to measure PixelCopy processing duration
markushi Jul 25, 2025
b96aa95
Cache bitmap sampling
markushi Jul 29, 2025
e211d3b
Simplify text drawing
markushi Jul 29, 2025
46feab3
Merge branch 'main' into markushi/canvas-approach
markushi Aug 6, 2025
6717776
Merge branch 'markushi/canvas-approach' of github.com:getsentry/sentr…
markushi Aug 6, 2025
58ade25
Add trace sections
markushi Aug 7, 2025
6bf46d0
Default to Canvas
markushi Aug 7, 2025
ae09b33
temp
markushi Sep 9, 2025
cec6f89
Merge branch 'main' into markushi/canvas-approach
markushi Sep 22, 2025
fbbbb13
cleanup
markushi Sep 22, 2025
53654eb
Add FramesTimingTracker, cleanup
markushi Sep 22, 2025
38fe0b9
Merge branch 'main' into markushi/canvas-approach
markushi Sep 22, 2025
4bd6fbb
Cleanup
markushi Sep 22, 2025
cb9e7f6
Cleanup
markushi Oct 2, 2025
9cb8201
Move from ApiStatus.Internal to ApiStatus.Experimental
markushi Oct 2, 2025
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
Original file line number Diff line number Diff line change
Expand Up @@ -4,60 +4,55 @@ import android.annotation.SuppressLint
import android.annotation.TargetApi
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Matrix
import android.graphics.Paint
import android.graphics.Rect
import android.graphics.RectF
import android.view.PixelCopy
import android.view.View
import android.view.ViewTreeObserver
import io.sentry.ScreenshotStrategyType
import io.sentry.SentryLevel.DEBUG
import io.sentry.SentryLevel.INFO
import io.sentry.SentryLevel.WARNING
import io.sentry.SentryOptions
import io.sentry.SentryReplayOptions
import io.sentry.android.replay.screenshot.CanvasStrategy
import io.sentry.android.replay.screenshot.PixelCopyStrategy
import io.sentry.android.replay.screenshot.ScreenshotStrategy
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
import io.sentry.android.replay.util.removeOnDrawListenerSafe
import io.sentry.android.replay.util.submitSafely
import io.sentry.android.replay.util.traverse
import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode
import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode
import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode
import java.io.File
import java.lang.ref.WeakReference
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.LazyThreadSafetyMode.NONE
import kotlin.math.roundToInt

@SuppressLint("UseKtx")
@TargetApi(26)
internal class ScreenshotRecorder(
val config: ScreenshotRecorderConfig,
val options: SentryOptions,
private val mainLooperHandler: MainLooperHandler,
private val recorder: ScheduledExecutorService,
private val screenshotRecorderCallback: ScreenshotRecorderCallback?,
val handler: MainLooperHandler,
executorService: ScheduledExecutorService,
screenshotRecorderCallback: ScreenshotRecorderCallback?,
) : ViewTreeObserver.OnDrawListener {
private var rootView: WeakReference<View>? = null
private val maskingPaint by lazy(NONE) { Paint() }
private val singlePixelBitmap: Bitmap by
lazy(NONE) { Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) }
private val screenshot =
Bitmap.createBitmap(config.recordingWidth, config.recordingHeight, Bitmap.Config.ARGB_8888)
private val singlePixelBitmapCanvas: Canvas by lazy(NONE) { Canvas(singlePixelBitmap) }
private val prescaledMatrix by
lazy(NONE) { Matrix().apply { preScale(config.scaleFactorX, config.scaleFactorY) } }
private val contentChanged = AtomicBoolean(false)
private val isCapturing = AtomicBoolean(true)
private val lastCaptureSuccessful = AtomicBoolean(false)

private val debugOverlayDrawable = DebugOverlayDrawable()
private val contentChanged = AtomicBoolean(false)

private val screenshotStrategy: ScreenshotStrategy =
when (options.sessionReplay.screenshotStrategy) {
ScreenshotStrategyType.CANVAS ->
CanvasStrategy(executorService, screenshotRecorderCallback, options, config)
ScreenshotStrategyType.PIXEL_COPY ->
PixelCopyStrategy(
executorService,
handler,
screenshotRecorderCallback,
options,
config,
debugOverlayDrawable,
)
}

fun capture() {
if (options.sessionReplay.isDebug) {
Expand All @@ -75,12 +70,12 @@ internal class ScreenshotRecorder(
DEBUG,
"Capturing screenshot, contentChanged: %s, lastCaptureSuccessful: %s",
contentChanged.get(),
lastCaptureSuccessful.get(),
screenshotStrategy.lastCaptureSuccessful(),
)
}

if (!contentChanged.get() && lastCaptureSuccessful.get()) {
screenshotRecorderCallback?.onScreenshotRecorded(screenshot)
if (!contentChanged.get()) {
screenshotStrategy.emitLastScreenshot()
return
}

Expand All @@ -98,93 +93,9 @@ internal class ScreenshotRecorder(

try {
contentChanged.set(false)
PixelCopy.request(
window,
screenshot,
{ copyResult: Int ->
if (copyResult != PixelCopy.SUCCESS) {
options.logger.log(INFO, "Failed to capture replay recording: %d", copyResult)
lastCaptureSuccessful.set(false)
return@request
}

// TODO: handle animations with heuristics (e.g. if we fall under this condition 2 times
// in a row, we should capture)
if (contentChanged.get()) {
options.logger.log(INFO, "Failed to determine view hierarchy, not capturing")
lastCaptureSuccessful.set(false)
return@request
}

// TODO: disableAllMasking here and dont traverse?
val viewHierarchy = ViewHierarchyNode.fromView(root, null, 0, options)
root.traverse(viewHierarchy, options)

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

val canvas = Canvas(screenshot)
canvas.setMatrix(prescaledMatrix)
viewHierarchy.traverse { node ->
if (node.shouldMask && (node.width > 0 && node.height > 0)) {
node.visibleRect ?: return@traverse false

// TODO: investigate why it returns true on RN when it shouldn't
// if (viewHierarchy.isObscured(node)) {
// return@traverse true
// }

val (visibleRects, color) =
when (node) {
is ImageViewHierarchyNode -> {
listOf(node.visibleRect) to screenshot.dominantColorForRect(node.visibleRect)
}

is TextViewHierarchyNode -> {
val textColor =
node.layout?.dominantTextColor ?: node.dominantColor ?: Color.BLACK
node.layout.getVisibleRects(
node.visibleRect,
node.paddingLeft,
node.paddingTop,
) to textColor
}

else -> {
listOf(node.visibleRect) to Color.BLACK
}
}

maskingPaint.setColor(color)
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)
}
},
mainLooperHandler.handler,
)
screenshotStrategy.capture(root)
} catch (e: Throwable) {
options.logger.log(WARNING, "Failed to capture replay recording", e)
lastCaptureSuccessful.set(false)
}
}

Expand All @@ -199,6 +110,7 @@ internal class ScreenshotRecorder(
}

contentChanged.set(true)
screenshotStrategy.onContentChanged()
}

fun bind(root: View) {
Expand All @@ -212,6 +124,7 @@ internal class ScreenshotRecorder(

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

fun unbind(root: View?) {
Expand All @@ -235,29 +148,9 @@ internal class ScreenshotRecorder(
fun close() {
unbind(rootView?.get())
rootView?.clear()
if (!screenshot.isRecycled) {
screenshot.recycle()
}
screenshotStrategy.close()
isCapturing.set(false)
}

private fun Bitmap.dominantColorForRect(rect: Rect): Int {
// TODO: maybe this ceremony can be just simplified to
// TODO: multiplying the visibleRect by the prescaledMatrix
val visibleRect = Rect(rect)
val visibleRectF = RectF(visibleRect)

// since we take screenshot with lower scale, we also
// have to apply the same scale to the visibleRect to get the
// correct screenshot part to determine the dominant color
prescaledMatrix.mapRect(visibleRectF)
// round it back to integer values, because drawBitmap below accepts Rect only
visibleRectF.round(visibleRect)
// draw part of the screenshot (visibleRect) to a single pixel bitmap
singlePixelBitmapCanvas.drawBitmap(this, visibleRect, Rect(0, 0, 1, 1), null)
// get the pixel color (= dominant color)
return singlePixelBitmap.getPixel(0, 0)
}
}

public data class ScreenshotRecorderConfig(
Expand Down
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are the changes in this class necessary? I don't see rootView or currentCaptureDelay being used anywhere

Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,6 @@ internal class WindowRecorder(
private val mainLooperHandler: MainLooperHandler,
private val replayExecutor: ScheduledExecutorService,
) : Recorder, OnRootViewsChangedListener {
internal companion object {
private const val TAG = "WindowRecorder"
}

private val isRecording = AtomicBoolean(false)
private val rootViews = ArrayList<WeakReference<View>>()
Expand All @@ -45,6 +42,10 @@ internal class WindowRecorder(
var config: ScreenshotRecorderConfig? = null
private val isRecording = AtomicBoolean(true)

private var currentCaptureDelay = 0L

private var rootView = WeakReference<View>(null)

fun resume() {
if (options.sessionReplay.isDebug) {
options.logger.log(DEBUG, "Resuming the capture runnable.")
Expand Down Expand Up @@ -105,12 +106,17 @@ internal class WindowRecorder(
)
}
}

fun bind(newRoot: View) {
rootView = WeakReference(newRoot)
}
}

override fun onRootViewsChanged(root: View, added: Boolean) {
rootViewsLock.acquire().use {
if (added) {
rootViews.add(WeakReference(root))
capturer?.bind(root)
capturer?.recorder?.bind(root)
determineWindowSize(root)
} else {
Expand Down Expand Up @@ -188,6 +194,7 @@ internal class WindowRecorder(

val newRoot = rootViews.lastOrNull()?.get()
if (newRoot != null) {
capturer?.bind(newRoot)
capturer?.recorder?.bind(newRoot)
}

Expand Down
Loading
Loading