From df58de195ac8c918ae28dbfd2257c0491d94bfe4 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 23 Apr 2026 19:05:34 +0200 Subject: [PATCH 01/13] feat(replay): Capture SurfaceView content (experimental) SurfaceView (used by Unity, video players, maps, and similar) renders to a separate Surface that is composited by SurfaceFlinger outside of the View hierarchy. PixelCopy.request(window, ...) only captures the Window surface, so SurfaceView regions appeared as transparent/black holes in Session Replay recordings. When the experimental option options.sessionReplay.isCaptureSurfaceViews is enabled, each visible SurfaceView is now captured separately via PixelCopy.request(surfaceView, ...) and composited onto the screenshot using PorterDuff.DST_OVER, so the SurfaceView content draws behind the Window content (which has transparent holes where the SurfaceViews are). Because SurfaceView redraws do not trigger ViewTreeObserver.OnDrawListener, the recorder bypasses the contentChanged guard when SurfaceViews are present, so subsequent frames are re-captured at the configured frame rate instead of reusing the last screenshot. The option defaults to false to preserve existing behavior. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 5 + .../android/replay/ScreenshotRecorder.kt | 2 +- .../replay/screenshot/PixelCopyStrategy.kt | 175 +++++++++++++++--- .../replay/screenshot/ScreenshotStrategy.kt | 7 + .../io/sentry/android/replay/util/Views.kt | 11 +- .../replay/viewhierarchy/ViewHierarchyNode.kt | 48 +++++ .../java/io/sentry/SentryReplayOptions.java | 28 +++ 7 files changed, 251 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dabfab7294..3a309840cbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## 8.40.0 +### Features + +- Session Replay: experimental support for capturing `SurfaceView` content (e.g. Unity, video players, maps) ([#5333](https://github.com/getsentry/sentry-java/pull/5333)) + - To enable, set `options.sessionReplay.isCaptureSurfaceViews = true` + ### Fixes - Fix `NoSuchMethodError` for `LayoutCoordinates.localBoundingBoxOf$default` on Compose touch dispatch with AGP 8.13 and `minSdk < 24` ([#5302](https://github.com/getsentry/sentry-java/pull/5302)) 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 8cc7bccede3..c0b2b86fb98 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 @@ -70,7 +70,7 @@ internal class ScreenshotRecorder( ) } - if (!contentChanged.get()) { + if (!contentChanged.get() && !screenshotStrategy.hasSurfaceViews()) { screenshotStrategy.emitLastScreenshot() return } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/PixelCopyStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/PixelCopyStrategy.kt index ec3f36647c3..1e64aede823 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/PixelCopyStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/PixelCopyStrategy.kt @@ -2,7 +2,13 @@ package io.sentry.android.replay.screenshot import android.annotation.SuppressLint import android.graphics.Bitmap +import android.graphics.Canvas import android.graphics.Matrix +import android.graphics.Paint +import android.graphics.PorterDuff +import android.graphics.PorterDuffXfermode +import android.graphics.Rect +import android.graphics.RectF import android.view.PixelCopy import android.view.View import io.sentry.SentryLevel.DEBUG @@ -19,6 +25,7 @@ import io.sentry.android.replay.util.ReplayRunnable import io.sentry.android.replay.util.traverse import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger import kotlin.LazyThreadSafetyMode.NONE @SuppressLint("UseKtx") @@ -40,6 +47,16 @@ internal class PixelCopyStrategy( private val maskRenderer = MaskRenderer() private val contentChanged = AtomicBoolean(false) private val isClosed = AtomicBoolean(false) + private val hasSurfaceViews = AtomicBoolean(false) + private val dstOverPaint by + lazy(NONE) { Paint().apply { xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_OVER) } } + private val screenshotCanvas by lazy(NONE) { Canvas(screenshot) } + private val tmpSrcRect = Rect() + private val tmpDstRect = RectF() + private val windowLocation = IntArray(2) + private val svLocation = IntArray(2) + + private class SurfaceViewCapture(val bitmap: Bitmap, val x: Int, val y: Int) @SuppressLint("NewApi") override fun capture(root: View) { @@ -81,31 +98,22 @@ internal class PixelCopyStrategy( // TODO: disableAllMasking here and dont traverse? val viewHierarchy = ViewHierarchyNode.fromView(root, null, 0, options.sessionReplay) - root.traverse(viewHierarchy, options.sessionReplay, options.logger) - - executor.submit( - ReplayRunnable("screenshot_recorder.mask") { - if (isClosed.get() || screenshot.isRecycled) { - options.logger.log(DEBUG, "PixelCopyStrategy is closed, skipping masking") - return@ReplayRunnable - } + val captureSurfaceViewsEnabled = options.sessionReplay.isCaptureSurfaceViews + val surfaceViewNodes = + if (captureSurfaceViewsEnabled) { + mutableListOf() + } else { + null + } + root.traverse(viewHierarchy, options.sessionReplay, options.logger, surfaceViewNodes) - val debugMasks = maskRenderer.renderMasks(screenshot, viewHierarchy, prescaledMatrix) + hasSurfaceViews.set(surfaceViewNodes?.isNotEmpty() == 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) - } - ) + if (surfaceViewNodes.isNullOrEmpty()) { + submitMaskingAndCallback(root, viewHierarchy) + } else { + captureSurfaceViews(root, surfaceViewNodes, viewHierarchy) + } }, mainLooperHandler.handler, ) @@ -115,6 +123,123 @@ internal class PixelCopyStrategy( } } + private fun submitMaskingAndCallback(root: View, viewHierarchy: ViewHierarchyNode) { + executor.submit( + ReplayRunnable("screenshot_recorder.mask") { applyMaskingAndNotify(root, viewHierarchy) } + ) + } + + private fun applyMaskingAndNotify(root: View, viewHierarchy: ViewHierarchyNode) { + if (isClosed.get() || screenshot.isRecycled) { + options.logger.log(DEBUG, "PixelCopyStrategy is closed, skipping masking") + return + } + + val debugMasks = maskRenderer.renderMasks(screenshot, viewHierarchy, prescaledMatrix) + + 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) + } + + @SuppressLint("NewApi") + private fun captureSurfaceViews( + root: View, + surfaceViewNodes: List, + viewHierarchy: ViewHierarchyNode, + ) { + root.getLocationOnScreen(windowLocation) + + val captures = arrayOfNulls(surfaceViewNodes.size) + val remaining = AtomicInteger(surfaceViewNodes.size) + + fun onCaptureComplete() { + if (remaining.decrementAndGet() == 0) { + compositeSurfaceViewsAndMask(root, captures, viewHierarchy) + } + } + + for ((index, node) in surfaceViewNodes.withIndex()) { + val surfaceView = node.surfaceViewRef.get() + if (surfaceView == null || !surfaceView.holder.surface.isValid) { + onCaptureComplete() + continue + } + + try { + val svBitmap = + Bitmap.createBitmap(surfaceView.width, surfaceView.height, Bitmap.Config.ARGB_8888) + + surfaceView.getLocationOnScreen(svLocation) + val capturedX = svLocation[0] + val capturedY = svLocation[1] + + PixelCopy.request( + surfaceView, + svBitmap, + { copyResult: Int -> + if (copyResult == PixelCopy.SUCCESS) { + captures[index] = SurfaceViewCapture(svBitmap, capturedX, capturedY) + } else { + svBitmap.recycle() + options.logger.log(INFO, "Failed to capture SurfaceView: %d", copyResult) + } + onCaptureComplete() + }, + mainLooperHandler.handler, + ) + } catch (e: Throwable) { + options.logger.log(WARNING, "Failed to capture SurfaceView", e) + onCaptureComplete() + } + } + } + + private fun compositeSurfaceViewsAndMask( + root: View, + captures: Array, + viewHierarchy: ViewHierarchyNode, + ) { + executor.submit( + ReplayRunnable("screenshot_recorder.composite") { + if (isClosed.get() || screenshot.isRecycled) { + options.logger.log(DEBUG, "PixelCopyStrategy is closed, skipping compositing") + return@ReplayRunnable + } + + for (capture in captures) { + if (capture == null) continue + if (capture.bitmap.isRecycled) continue + + val left = (capture.x - windowLocation[0]) * config.scaleFactorX + val top = (capture.y - windowLocation[1]) * config.scaleFactorY + tmpSrcRect.set(0, 0, capture.bitmap.width, capture.bitmap.height) + tmpDstRect.set( + left, + top, + left + capture.bitmap.width * config.scaleFactorX, + top + capture.bitmap.height * config.scaleFactorY, + ) + + // DST_OVER draws the SurfaceView content behind the existing Window content + screenshotCanvas.drawBitmap(capture.bitmap, tmpSrcRect, tmpDstRect, dstOverPaint) + capture.bitmap.recycle() + } + + applyMaskingAndNotify(root, viewHierarchy) + } + ) + } + override fun onContentChanged() { contentChanged.set(true) } @@ -123,6 +248,10 @@ internal class PixelCopyStrategy( return lastCaptureSuccessful.get() } + override fun hasSurfaceViews(): Boolean { + return hasSurfaceViews.get() + } + override fun emitLastScreenshot() { if (lastCaptureSuccessful() && !screenshot.isRecycled) { screenshotRecorderCallback?.onScreenshotRecorded(screenshot) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/ScreenshotStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/ScreenshotStrategy.kt index a7b2334ea77..e636982bf85 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/ScreenshotStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/ScreenshotStrategy.kt @@ -12,4 +12,11 @@ internal interface ScreenshotStrategy { fun lastCaptureSuccessful(): Boolean fun emitLastScreenshot() + + /** + * Whether the last capture detected SurfaceViews that render independently of the View tree. When + * true, the recorder bypasses the contentChanged guard since SurfaceView redraws don't trigger + * ViewTreeObserver.OnDrawListener. + */ + fun hasSurfaceViews(): Boolean = false } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt index d0583cdaa6a..7d32ea84a37 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt @@ -34,10 +34,12 @@ import java.lang.NullPointerException * @param logger Logger for error reporting during Compose traversal */ @SuppressLint("UseKtx") +@JvmOverloads internal fun View.traverse( parentNode: ViewHierarchyNode, options: SentryMaskingOptions, logger: ILogger, + surfaceViewNodes: MutableList? = null, ) { if (this !is ViewGroup) { return @@ -59,7 +61,14 @@ internal fun View.traverse( if (child != null) { val childNode = ViewHierarchyNode.fromView(child, parentNode, indexOfChild(child), options) childNodes.add(childNode) - child.traverse(childNode, options, logger) + if ( + surfaceViewNodes != null && + childNode is ViewHierarchyNode.SurfaceViewHierarchyNode && + childNode.isVisible + ) { + surfaceViewNodes.add(childNode) + } + child.traverse(childNode, options, logger, surfaceViewNodes) } } parentNode.children = childNodes diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt index f54fa79da10..e55ba659a8e 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt @@ -3,6 +3,7 @@ package io.sentry.android.replay.viewhierarchy import android.annotation.SuppressLint import android.annotation.TargetApi import android.graphics.Rect +import android.view.SurfaceView import android.view.View import android.view.ViewParent import android.widget.ImageView @@ -15,6 +16,7 @@ import io.sentry.android.replay.util.isMaskable import io.sentry.android.replay.util.isVisibleToUser import io.sentry.android.replay.util.toOpaque import io.sentry.android.replay.util.totalPaddingTopSafe +import java.lang.ref.WeakReference @SuppressLint("UseRequiresApi") @TargetApi(26) @@ -121,6 +123,34 @@ internal sealed class ViewHierarchyNode( visibleRect, ) + class SurfaceViewHierarchyNode( + val surfaceViewRef: WeakReference, + x: Float, + y: Float, + width: Int, + height: Int, + elevation: Float, + distance: Int, + parent: ViewHierarchyNode? = null, + shouldMask: Boolean = false, + isImportantForContentCapture: Boolean = false, + isVisible: Boolean = false, + visibleRect: Rect? = null, + ) : + ViewHierarchyNode( + x, + y, + width, + height, + elevation, + distance, + parent, + shouldMask, + isImportantForContentCapture, + isVisible, + visibleRect, + ) + /** * Basically replicating this: * https://developer.android.com/reference/android/view/View#isImportantForContentCapture() but @@ -379,6 +409,24 @@ internal sealed class ViewHierarchyNode( visibleRect = visibleRect, ) } + + is SurfaceView -> { + parent?.setImportantForCaptureToAncestors(true) + return SurfaceViewHierarchyNode( + surfaceViewRef = WeakReference(view), + x = view.x, + y = view.y, + width = view.width, + height = view.height, + elevation = (parent?.elevation ?: 0f) + view.elevation, + distance = distance, + parent = parent, + shouldMask = shouldMask, + isImportantForContentCapture = true, + isVisible = isVisible, + visibleRect = visibleRect, + ) + } } return GenericViewHierarchyNode( diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index d4e0fd257cd..ed57948f505 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -146,6 +146,14 @@ public enum SentryReplayQuality { @ApiStatus.Experimental private @NotNull ScreenshotStrategyType screenshotStrategy = ScreenshotStrategyType.PIXEL_COPY; + /** + * Whether to capture SurfaceView content (e.g. Unity, video players, maps) during replay + * recording. When enabled, each SurfaceView in the view hierarchy will be captured separately via + * PixelCopy and composited onto the screenshot. Only applies when {@link #screenshotStrategy} is + * {@link ScreenshotStrategyType#PIXEL_COPY}. Default is disabled. + */ + @ApiStatus.Experimental private boolean captureSurfaceViews = false; + /** * Capture request and response details for XHR and fetch requests that match the given URLs. * Default is empty (network details not collected). @@ -383,6 +391,26 @@ public void setScreenshotStrategy(final @NotNull ScreenshotStrategyType screensh this.screenshotStrategy = screenshotStrategy; } + /** + * Whether SurfaceView capture is enabled. See {@link #captureSurfaceViews}. + * + * @return true if SurfaceView capture is enabled + */ + @ApiStatus.Experimental + public boolean isCaptureSurfaceViews() { + return captureSurfaceViews; + } + + /** + * Enables or disables SurfaceView capture. See {@link #captureSurfaceViews}. + * + * @param captureSurfaceViews true to enable SurfaceView capture + */ + @ApiStatus.Experimental + public void setCaptureSurfaceViews(final boolean captureSurfaceViews) { + this.captureSurfaceViews = captureSurfaceViews; + } + /** * Gets the list of URLs for which network request and response details should be captured. * From 153c6eaaaf611488910d24ea967833df87662eca Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 23 Apr 2026 23:57:39 +0200 Subject: [PATCH 02/13] test(replay): Cover SurfaceView capture paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add unit tests for the new SurfaceView capture support and extract a compositeSurfaceViewInto helper so the drawing contract can be verified with hand-built bitmaps (Robolectric's ShadowPixelCopy cannot produce meaningful SurfaceView pixels because there is no real GL producer). The tests cover: - ViewHierarchyNode.fromView returns SurfaceViewHierarchyNode vs. generic - View.traverse collects SurfaceView nodes when a list is supplied, not when it is null, and skips invisible SurfaceViews - PixelCopyStrategy leaves hasSurfaceViews false when the option is off - PixelCopyStrategy flags hasSurfaceViews true when the option is on - PixelCopyStrategy completes gracefully when a SurfaceView has no valid surface (the common Robolectric case) - compositeSurfaceViewInto fills transparent holes behind existing window content via DST_OVER, and respects both window offset and scale factors Also fixes a latent NPE in captureSurfaceViews when SurfaceHolder.surface is null (not just invalid) — happens before the surface is created. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../replay/screenshot/PixelCopyStrategy.kt | 62 +++++-- .../screenshot/PixelCopyStrategyTest.kt | 171 ++++++++++++++++++ .../sentry/android/replay/util/ViewsTest.kt | 88 +++++++++ .../viewhierarchy/ViewHierarchyNodeTest.kt | 43 +++++ 4 files changed, 352 insertions(+), 12 deletions(-) create mode 100644 sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNodeTest.kt diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/PixelCopyStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/PixelCopyStrategy.kt index 1e64aede823..8ccfe5f31b4 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/PixelCopyStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/PixelCopyStrategy.kt @@ -170,7 +170,9 @@ internal class PixelCopyStrategy( for ((index, node) in surfaceViewNodes.withIndex()) { val surfaceView = node.surfaceViewRef.get() - if (surfaceView == null || !surfaceView.holder.surface.isValid) { + // holder.surface can be null before the surface is created — guard against NPE. + val surface = surfaceView?.holder?.surface + if (surfaceView == null || surface == null || !surface.isValid) { onCaptureComplete() continue } @@ -220,18 +222,19 @@ internal class PixelCopyStrategy( if (capture == null) continue if (capture.bitmap.isRecycled) continue - val left = (capture.x - windowLocation[0]) * config.scaleFactorX - val top = (capture.y - windowLocation[1]) * config.scaleFactorY - tmpSrcRect.set(0, 0, capture.bitmap.width, capture.bitmap.height) - tmpDstRect.set( - left, - top, - left + capture.bitmap.width * config.scaleFactorX, - top + capture.bitmap.height * config.scaleFactorY, + compositeSurfaceViewInto( + screenshotCanvas, + dstOverPaint, + tmpSrcRect, + tmpDstRect, + capture.bitmap, + capture.x, + capture.y, + windowLocation[0], + windowLocation[1], + config.scaleFactorX, + config.scaleFactorY, ) - - // DST_OVER draws the SurfaceView content behind the existing Window content - screenshotCanvas.drawBitmap(capture.bitmap, tmpSrcRect, tmpDstRect, dstOverPaint) capture.bitmap.recycle() } @@ -277,3 +280,38 @@ internal class PixelCopyStrategy( ) } } + +/** + * Composites [sourceBitmap] (a SurfaceView capture) onto [destCanvas] (wrapping the recording + * screenshot) using [destPaint] (expected to have DST_OVER xfermode), so the SurfaceView content + * draws _behind_ existing Window content — filling the transparent holes the Window PixelCopy + * leaves where SurfaceViews are. + * + * Extracted for testability — the compositing is pure drawing logic that can be driven with + * hand-built bitmaps, while the surrounding [PixelCopyStrategy.captureSurfaceViews] flow depends + * on a real SurfaceView producer that Robolectric cannot provide. + */ +internal fun compositeSurfaceViewInto( + destCanvas: Canvas, + destPaint: Paint, + tmpSrc: Rect, + tmpDst: RectF, + sourceBitmap: Bitmap, + sourceX: Int, + sourceY: Int, + windowX: Int, + windowY: Int, + scaleFactorX: Float, + scaleFactorY: Float, +) { + val left = (sourceX - windowX) * scaleFactorX + val top = (sourceY - windowY) * scaleFactorY + tmpSrc.set(0, 0, sourceBitmap.width, sourceBitmap.height) + tmpDst.set( + left, + top, + left + sourceBitmap.width * scaleFactorX, + top + sourceBitmap.height * scaleFactorY, + ) + destCanvas.drawBitmap(sourceBitmap, tmpSrc, tmpDst, destPaint) +} diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/screenshot/PixelCopyStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/screenshot/PixelCopyStrategyTest.kt index 29a3089e686..df3495a0d75 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/screenshot/PixelCopyStrategyTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/screenshot/PixelCopyStrategyTest.kt @@ -1,9 +1,19 @@ package io.sentry.android.replay.screenshot import android.app.Activity +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.PorterDuff +import android.graphics.PorterDuffXfermode +import android.graphics.Rect +import android.graphics.RectF import android.os.Bundle import android.os.Handler import android.os.Looper +import android.view.SurfaceView +import android.widget.FrameLayout import android.widget.LinearLayout import android.widget.LinearLayout.LayoutParams import android.widget.TextView @@ -18,18 +28,23 @@ import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.atomic.AtomicReference import kotlin.test.BeforeTest import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertTrue import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.doAnswer import org.mockito.kotlin.mock +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.Robolectric.buildActivity import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode import org.robolectric.shadows.ShadowPixelCopy @Config(shadows = [ShadowPixelCopy::class], sdk = [30]) +@GraphicsMode(GraphicsMode.Mode.NATIVE) @RunWith(AndroidJUnit4::class) class PixelCopyStrategyTest { @@ -54,6 +69,18 @@ class PixelCopyStrategyTest { debugOverlayDrawable, ) } + + /** Executor mock that runs submitted tasks synchronously on the calling thread. */ + fun inlineExecutor(): ScheduledExecutorService { + return mock { + doAnswer { + (it.arguments[0] as Runnable).run() + null // submit(Runnable) returns Future; returning Unit breaks the cast + } + .whenever(mock) + .submit(any()) + } + } } private val fixture = Fixture() @@ -101,6 +128,125 @@ class PixelCopyStrategyTest { if (failure.get() != null) throw failure.get() } + + @Test + fun `capture does not flag hasSurfaceViews when option is disabled`() { + val activity = buildActivity(ActivityWithSurfaceView::class.java).setup() + shadowOf(Looper.getMainLooper()).idle() + + // Default: isCaptureSurfaceViews = false + val strategy = fixture.getSut(executor = fixture.inlineExecutor()) + strategy.capture(activity.get().findViewById(android.R.id.content)) + shadowOf(Looper.getMainLooper()).idle() + + assertFalse(strategy.hasSurfaceViews()) + assertTrue(strategy.lastCaptureSuccessful()) + verify(fixture.callback).onScreenshotRecorded(any()) + } + + @Test + fun `capture flags hasSurfaceViews when option is enabled and SurfaceView is present`() { + val activity = buildActivity(ActivityWithSurfaceView::class.java).setup() + shadowOf(Looper.getMainLooper()).idle() + + fixture.options.sessionReplay.isCaptureSurfaceViews = true + + val strategy = fixture.getSut(executor = fixture.inlineExecutor()) + strategy.capture(activity.get().findViewById(android.R.id.content)) + shadowOf(Looper.getMainLooper()).idle() + + assertTrue(strategy.hasSurfaceViews()) + } + + @Test + fun `capture completes when SurfaceView surface is not valid`() { + // In Robolectric the SurfaceView holder surface is not valid — this exercises the + // `surfaceView.holder.surface.isValid == false` branch: each SurfaceView skips its + // PixelCopy and onCaptureComplete still fires, eventually running the compositor and + // callback. + val activity = buildActivity(ActivityWithSurfaceView::class.java).setup() + shadowOf(Looper.getMainLooper()).idle() + fixture.options.sessionReplay.isCaptureSurfaceViews = true + + val strategy = fixture.getSut(executor = fixture.inlineExecutor()) + strategy.capture(activity.get().findViewById(android.R.id.content)) + shadowOf(Looper.getMainLooper()).idle() + + assertTrue(strategy.lastCaptureSuccessful()) + verify(fixture.callback).onScreenshotRecorded(any()) + } + + @Test + fun `compositeSurfaceViewInto draws source behind existing destination with DST_OVER`() { + // Destination ("Window capture"): 100x100, opaque red in the top half, + // fully transparent in the bottom half (the "hole" where the SurfaceView sits). + val dest = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) + val destCanvas = Canvas(dest) + destCanvas.drawColor(Color.RED) + val clearPaint = Paint().apply { xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR) } + destCanvas.drawRect(0f, 50f, 100f, 100f, clearPaint) + + // Source ("SurfaceView capture"): 100x50, solid blue — matches the hole. + val source = Bitmap.createBitmap(100, 50, Bitmap.Config.ARGB_8888) + source.eraseColor(Color.BLUE) + + val dstOverPaint = Paint().apply { xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_OVER) } + compositeSurfaceViewInto( + destCanvas = destCanvas, + destPaint = dstOverPaint, + tmpSrc = Rect(), + tmpDst = RectF(), + sourceBitmap = source, + sourceX = 0, + sourceY = 50, + windowX = 0, + windowY = 0, + scaleFactorX = 1f, + scaleFactorY = 1f, + ) + + // Top region: still red (DST_OVER must not overwrite existing opaque pixels). + assertEquals(Color.RED, dest.getPixel(50, 10)) + assertEquals(Color.RED, dest.getPixel(50, 49)) + // Bottom region: now blue (source filled the transparent hole). + assertEquals(Color.BLUE, dest.getPixel(50, 50)) + assertEquals(Color.BLUE, dest.getPixel(99, 99)) + } + + @Test + fun `compositeSurfaceViewInto respects scale factors and window offset`() { + // Destination is 50x50 (scaled recording), fully transparent. + val dest = Bitmap.createBitmap(50, 50, Bitmap.Config.ARGB_8888) + val destCanvas = Canvas(dest) + + // Source is 40x40, solid green; its on-screen location is (20, 20). + val source = Bitmap.createBitmap(40, 40, Bitmap.Config.ARGB_8888) + source.eraseColor(Color.GREEN) + + val dstOverPaint = Paint().apply { xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_OVER) } + compositeSurfaceViewInto( + destCanvas = destCanvas, + destPaint = dstOverPaint, + tmpSrc = Rect(), + tmpDst = RectF(), + sourceBitmap = source, + sourceX = 20, + sourceY = 20, + windowX = 10, // window is at (10, 10) + windowY = 10, + scaleFactorX = 0.5f, // 0.5x scale → destination coords halve + scaleFactorY = 0.5f, + ) + + // Expected destination rect: ((20-10)*0.5, (20-10)*0.5) = (5, 5), size 40*0.5 = 20x20 + // → occupies pixels [5..25) × [5..25). Check inside, on the edge, and just outside. + assertEquals(Color.GREEN, dest.getPixel(5, 5)) + assertEquals(Color.GREEN, dest.getPixel(15, 15)) + assertEquals(Color.GREEN, dest.getPixel(24, 24)) + // Just outside the rect — still transparent. + assertEquals(0, dest.getPixel(4, 4)) + assertEquals(0, dest.getPixel(25, 25)) + } } private class SimpleActivity : Activity() { @@ -123,3 +269,28 @@ private class SimpleActivity : Activity() { setContentView(linearLayout) } } + +private class ActivityWithSurfaceView : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val root = + FrameLayout(this).apply { + setBackgroundColor(android.R.color.white) + layoutParams = + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT, + ) + } + root.addView( + TextView(this).apply { + text = "Overlay" + layoutParams = FrameLayout.LayoutParams(200, 50) + } + ) + root.addView( + SurfaceView(this).apply { layoutParams = FrameLayout.LayoutParams(200, 200) } + ) + setContentView(root) + } +} diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/util/ViewsTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/util/ViewsTest.kt index 530c124af4f..066cedaabb1 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/util/ViewsTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/util/ViewsTest.kt @@ -1,15 +1,35 @@ package io.sentry.android.replay.util +import android.app.Activity +import android.os.Bundle +import android.view.SurfaceView import android.view.View +import android.widget.FrameLayout +import android.widget.FrameLayout.LayoutParams +import android.widget.TextView import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.NoOpLogger +import io.sentry.SentryReplayOptions +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode +import kotlin.test.BeforeTest import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue import org.junit.runner.RunWith +import org.robolectric.Robolectric.buildActivity @RunWith(AndroidJUnit4::class) class ViewsTest { + + @BeforeTest + fun setup() { + // Required so Robolectric reports the activity window as visible; otherwise + // View.isVisibleToUser() returns false and SurfaceView nodes are skipped. + System.setProperty("robolectric.areWindowsMarkedVisible", "true") + } + @Test fun `hasSize returns true for positive values`() { val view = View(ApplicationProvider.getApplicationContext()) @@ -33,4 +53,72 @@ class ViewsTest { view.bottom = -1 assertFalse(view.hasSize()) } + + @Test + fun `traverse collects visible SurfaceView nodes when a list is supplied`() { + val activity = buildActivity(SurfaceViewActivity::class.java).setup().get() + val root = activity.findViewById(android.R.id.content).getChildAt(0) as FrameLayout + val rootNode = ViewHierarchyNode.fromView(root, null, 0, SentryReplayOptions(false, null)) + val collected = mutableListOf() + + root.traverse(rootNode, SentryReplayOptions(false, null), NoOpLogger.getInstance(), collected) + + assertEquals(2, collected.size) + } + + @Test + fun `traverse does not collect SurfaceView nodes when list parameter is null`() { + val activity = buildActivity(SurfaceViewActivity::class.java).setup().get() + val root = activity.findViewById(android.R.id.content).getChildAt(0) as FrameLayout + val rootNode = ViewHierarchyNode.fromView(root, null, 0, SentryReplayOptions(false, null)) + + // Default parameter (null) — equivalent to the pre-feature call site behavior. + root.traverse(rootNode, SentryReplayOptions(false, null), NoOpLogger.getInstance()) + + // No assertion on a collection; the goal is that this overload still works and never NPEs. + assertTrue(true) + } + + @Test + fun `traverse skips invisible SurfaceViews`() { + val activity = buildActivity(SurfaceViewActivity::class.java).setup().get() + val root = activity.findViewById(android.R.id.content).getChildAt(0) as FrameLayout + // Hide one of the two SurfaceViews. + var hidden = 0 + for (i in 0 until root.childCount) { + val child = root.getChildAt(i) + if (child is SurfaceView) { + child.visibility = View.GONE + hidden++ + break + } + } + assertEquals(1, hidden, "test setup: expected to find a SurfaceView to hide") + + val rootNode = ViewHierarchyNode.fromView(root, null, 0, SentryReplayOptions(false, null)) + val collected = mutableListOf() + + root.traverse(rootNode, SentryReplayOptions(false, null), NoOpLogger.getInstance(), collected) + + assertEquals(1, collected.size) + } +} + +private class SurfaceViewActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val root = + FrameLayout(this).apply { + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + } + root.addView(SurfaceView(this).apply { layoutParams = LayoutParams(100, 100) }) + root.addView(TextView(this).apply { text = "label" }) + root.addView( + FrameLayout(this).apply { + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) + addView(SurfaceView(context).apply { layoutParams = LayoutParams(50, 50) }) + } + ) + setContentView(root) + } } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNodeTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNodeTest.kt new file mode 100644 index 00000000000..8aaaa29942c --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNodeTest.kt @@ -0,0 +1,43 @@ +package io.sentry.android.replay.viewhierarchy + +import android.view.SurfaceView +import android.view.View +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.SentryReplayOptions +import kotlin.test.Test +import kotlin.test.assertSame +import kotlin.test.assertTrue +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ViewHierarchyNodeTest { + + private val options = SentryReplayOptions(false, null) + + @Test + fun `fromView returns SurfaceViewHierarchyNode for a SurfaceView`() { + val surfaceView = SurfaceView(ApplicationProvider.getApplicationContext()) + + val node = ViewHierarchyNode.fromView(surfaceView, null, 0, options) + + assertTrue( + node is ViewHierarchyNode.SurfaceViewHierarchyNode, + "expected SurfaceViewHierarchyNode but got ${node::class.simpleName}", + ) + assertTrue(node.isImportantForContentCapture) + assertSame(surfaceView, node.surfaceViewRef.get()) + } + + @Test + fun `fromView returns GenericViewHierarchyNode for a plain View`() { + val view = View(ApplicationProvider.getApplicationContext()) + + val node = ViewHierarchyNode.fromView(view, null, 0, options) + + assertTrue( + node is ViewHierarchyNode.GenericViewHierarchyNode, + "expected GenericViewHierarchyNode but got ${node::class.simpleName}", + ) + } +} From cb424d1cc9ad1f1c5a1cce322c380655ab2ed609 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 24 Apr 2026 00:03:12 +0200 Subject: [PATCH 03/13] formatting --- .../io/sentry/android/replay/screenshot/PixelCopyStrategy.kt | 4 ++-- .../sentry/android/replay/screenshot/PixelCopyStrategyTest.kt | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/PixelCopyStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/PixelCopyStrategy.kt index 8ccfe5f31b4..612b4be7a99 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/PixelCopyStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/PixelCopyStrategy.kt @@ -288,8 +288,8 @@ internal class PixelCopyStrategy( * leaves where SurfaceViews are. * * Extracted for testability — the compositing is pure drawing logic that can be driven with - * hand-built bitmaps, while the surrounding [PixelCopyStrategy.captureSurfaceViews] flow depends - * on a real SurfaceView producer that Robolectric cannot provide. + * hand-built bitmaps, while the surrounding [PixelCopyStrategy.captureSurfaceViews] flow depends on + * a real SurfaceView producer that Robolectric cannot provide. */ internal fun compositeSurfaceViewInto( destCanvas: Canvas, diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/screenshot/PixelCopyStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/screenshot/PixelCopyStrategyTest.kt index df3495a0d75..edfb1714aca 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/screenshot/PixelCopyStrategyTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/screenshot/PixelCopyStrategyTest.kt @@ -288,9 +288,7 @@ private class ActivityWithSurfaceView : Activity() { layoutParams = FrameLayout.LayoutParams(200, 50) } ) - root.addView( - SurfaceView(this).apply { layoutParams = FrameLayout.LayoutParams(200, 200) } - ) + root.addView(SurfaceView(this).apply { layoutParams = FrameLayout.LayoutParams(200, 200) }) setContentView(root) } } From 2664a775d59da95c675e00b8a50004222ddb97c4 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 24 Apr 2026 00:13:44 +0200 Subject: [PATCH 04/13] api dump --- .../java/io/sentry/android/core/ScreenshotEventProcessor.java | 2 +- .../src/main/java/io/sentry/android/replay/util/Views.kt | 1 - sentry/api/sentry.api | 2 ++ 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java index 86b13309354..bbef7846cd9 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java @@ -201,7 +201,7 @@ private boolean isMaskingEnabled() { final ViewHierarchyNode rootNode = ViewHierarchyNode.Companion.fromView(rootView, null, 0, options.getScreenshot()); - ViewsKt.traverse(rootView, rootNode, options.getScreenshot(), options.getLogger()); + ViewsKt.traverse(rootView, rootNode, options.getScreenshot(), options.getLogger(), null); return rootNode; } catch (Throwable e) { options.getLogger().log(SentryLevel.ERROR, "Failed to build view hierarchy", e); diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt index 7d32ea84a37..cacd2b1c217 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt @@ -34,7 +34,6 @@ import java.lang.NullPointerException * @param logger Logger for error reporting during Compose traversal */ @SuppressLint("UseKtx") -@JvmOverloads internal fun View.traverse( parentNode: ViewHierarchyNode, options: SentryMaskingOptions, diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index b9cbb2ae1b2..addd5c04648 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -4039,12 +4039,14 @@ public final class io/sentry/SentryReplayOptions : io/sentry/SentryMaskingOption public fun getSessionDuration ()J public fun getSessionSampleRate ()Ljava/lang/Double; public fun getSessionSegmentDuration ()J + public fun isCaptureSurfaceViews ()Z public fun isDebug ()Z public fun isNetworkCaptureBodies ()Z public fun isSessionReplayEnabled ()Z public fun isSessionReplayForErrorsEnabled ()Z public fun isTrackConfiguration ()Z public fun setBeforeErrorSampling (Lio/sentry/SentryReplayOptions$BeforeErrorSamplingCallback;)V + public fun setCaptureSurfaceViews (Z)V public fun setDebug (Z)V public fun setMaskAllImages (Z)V public fun setMaskAllText (Z)V From 29da076a756d2dca85de1f2a4eedc085e792ecff Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 24 Apr 2026 10:26:53 +0200 Subject: [PATCH 05/13] docs(changelog): Move SurfaceView entry to Unreleased Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a309840cbf..d23ccd02d43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,14 @@ # Changelog -## 8.40.0 +## Unreleased ### Features - Session Replay: experimental support for capturing `SurfaceView` content (e.g. Unity, video players, maps) ([#5333](https://github.com/getsentry/sentry-java/pull/5333)) - To enable, set `options.sessionReplay.isCaptureSurfaceViews = true` +## 8.40.0 + ### Fixes - Fix `NoSuchMethodError` for `LayoutCoordinates.localBoundingBoxOf$default` on Compose touch dispatch with AGP 8.13 and `minSdk < 24` ([#5302](https://github.com/getsentry/sentry-java/pull/5302)) From 347643d1236aa8267fd62a746d48045a9a3aa592 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 24 Apr 2026 12:55:18 +0200 Subject: [PATCH 06/13] feat(replay): Wire capture-surface-views option through ManifestMetadataReader Allow enabling the experimental SurfaceView capture in Session Replay via the manifest meta-data `io.sentry.session-replay.capture-surface-views`, so users relying on auto-init don't need to switch to manual SentryAndroid.init just to flip the flag. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 1 + .../android/core/ManifestMetadataReader.java | 11 ++++++++ .../core/ManifestMetadataReaderTest.kt | 25 +++++++++++++++++++ 3 files changed, 37 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d23ccd02d43..a46f155d202 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Session Replay: experimental support for capturing `SurfaceView` content (e.g. Unity, video players, maps) ([#5333](https://github.com/getsentry/sentry-java/pull/5333)) - To enable, set `options.sessionReplay.isCaptureSurfaceViews = true` + - Or via manifest: `` ## 8.40.0 diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java index 6d90bb5ca8e..7dd6f1c1488 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java @@ -120,6 +120,8 @@ final class ManifestMetadataReader { static final String REPLAYS_DEBUG = "io.sentry.session-replay.debug"; static final String REPLAYS_SCREENSHOT_STRATEGY = "io.sentry.session-replay.screenshot-strategy"; + static final String REPLAYS_CAPTURE_SURFACE_VIEWS = + "io.sentry.session-replay.capture-surface-views"; static final String REPLAYS_NETWORK_DETAIL_ALLOW_URLS = "io.sentry.session-replay.network-detail-allow-urls"; @@ -547,6 +549,15 @@ static void applyMetadata( } } + options + .getSessionReplay() + .setCaptureSurfaceViews( + readBool( + metadata, + logger, + REPLAYS_CAPTURE_SURFACE_VIEWS, + options.getSessionReplay().isCaptureSurfaceViews())); + // Network Details Configuration if (options.getSessionReplay().getNetworkDetailAllowUrls().isEmpty()) { final @Nullable List allowUrls = diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt index 81b73d5dea7..52cb085b1ee 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt @@ -2022,6 +2022,31 @@ class ManifestMetadataReaderTest { ) } + @Test + fun `applyMetadata reads capture-surface-views to options`() { + // Arrange + val bundle = bundleOf(ManifestMetadataReader.REPLAYS_CAPTURE_SURFACE_VIEWS to true) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertTrue(fixture.options.sessionReplay.isCaptureSurfaceViews) + } + + @Test + fun `applyMetadata reads capture-surface-views and keeps default if not found`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertFalse(fixture.options.sessionReplay.isCaptureSurfaceViews) + } + @Test fun `applyMetadata reads anrProfilingSampleRate to options`() { // Arrange From 26d99848669ffd096435cc3c013807a3015f56ec Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 24 Apr 2026 20:36:18 +0200 Subject: [PATCH 07/13] ref(replay): Inline captureSurfaceViewsEnabled local Co-Authored-By: Claude Opus 4.7 (1M context) --- .../io/sentry/android/replay/screenshot/PixelCopyStrategy.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/PixelCopyStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/PixelCopyStrategy.kt index 612b4be7a99..20c55e47a8c 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/PixelCopyStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/PixelCopyStrategy.kt @@ -98,9 +98,8 @@ internal class PixelCopyStrategy( // TODO: disableAllMasking here and dont traverse? val viewHierarchy = ViewHierarchyNode.fromView(root, null, 0, options.sessionReplay) - val captureSurfaceViewsEnabled = options.sessionReplay.isCaptureSurfaceViews val surfaceViewNodes = - if (captureSurfaceViewsEnabled) { + if (options.sessionReplay.isCaptureSurfaceViews) { mutableListOf() } else { null From b13aa1bc2dc4d50289453ec887f375379497bbe0 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 27 Apr 2026 17:53:09 +0200 Subject: [PATCH 08/13] ref(replay): Address review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop the dedicated hasSurfaceViews flag and ScreenshotStrategy hook; PixelCopyStrategy now signals \"capture again next tick\" via a markContentChanged callback that re-arms the recorder's existing contentChanged gate. One source of truth instead of two booleans. - Inline the trivial submitMaskingAndCallback helper at its single call site. - Bail early in the SurfaceView PixelCopy callback if the strategy has been closed mid-flight, mirroring the Window-capture callback. - Document on SentryReplayOptions.captureSurfaceViews and in CHANGELOG that masking granularity is at the SurfaceView level only — content rendered inside a SurfaceView is opaque to the View masking system. - Simplify ViewsTest: build the test view tree inline instead of via a custom Activity subclass, idle the looper after setContentView. - Drop ViewHierarchyNodeTest — its type-dispatch coverage is implicit in ViewsTest, which only counts non-zero results when SurfaceView instances are correctly mapped to SurfaceViewHierarchyNode. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 1 + .../android/replay/ScreenshotRecorder.kt | 3 +- .../replay/screenshot/PixelCopyStrategy.kt | 29 ++++----- .../replay/screenshot/ScreenshotStrategy.kt | 7 --- .../screenshot/PixelCopyStrategyTest.kt | 11 ++-- .../sentry/android/replay/util/ViewsTest.kt | 60 ++++++++----------- .../viewhierarchy/ViewHierarchyNodeTest.kt | 43 ------------- .../java/io/sentry/SentryReplayOptions.java | 6 ++ 8 files changed, 56 insertions(+), 104 deletions(-) delete mode 100644 sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNodeTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index a46f155d202..0078a7be2f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Session Replay: experimental support for capturing `SurfaceView` content (e.g. Unity, video players, maps) ([#5333](https://github.com/getsentry/sentry-java/pull/5333)) - To enable, set `options.sessionReplay.isCaptureSurfaceViews = true` - Or via manifest: `` + - **Warning:** masking granularity is at the SurfaceView level only — the SDK cannot mask individual elements rendered inside the SurfaceView (e.g. native Unity UI, map labels, video frames). Only enable for SurfaceViews whose content is safe to record. ## 8.40.0 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 c0b2b86fb98..ce987c24ce8 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 @@ -47,6 +47,7 @@ internal class ScreenshotRecorder( options, config, debugOverlayDrawable, + markContentChanged = { contentChanged.set(true) }, ) } @@ -70,7 +71,7 @@ internal class ScreenshotRecorder( ) } - if (!contentChanged.get() && !screenshotStrategy.hasSurfaceViews()) { + if (!contentChanged.get()) { screenshotStrategy.emitLastScreenshot() return } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/PixelCopyStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/PixelCopyStrategy.kt index 20c55e47a8c..b58f121763f 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/PixelCopyStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/PixelCopyStrategy.kt @@ -35,6 +35,9 @@ internal class PixelCopyStrategy( private val options: SentryOptions, private val config: ScreenshotRecorderConfig, private val debugOverlayDrawable: DebugOverlayDrawable, + // Lets the strategy re-arm the recorder's contentChanged gate so frames keep being captured + // when SurfaceViews are present (their redraws don't trigger ViewTreeObserver.OnDrawListener). + private val markContentChanged: () -> Unit = {}, ) : ScreenshotStrategy { private val executor = executorProvider.getExecutor() @@ -47,7 +50,6 @@ internal class PixelCopyStrategy( private val maskRenderer = MaskRenderer() private val contentChanged = AtomicBoolean(false) private val isClosed = AtomicBoolean(false) - private val hasSurfaceViews = AtomicBoolean(false) private val dstOverPaint by lazy(NONE) { Paint().apply { xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_OVER) } } private val screenshotCanvas by lazy(NONE) { Canvas(screenshot) } @@ -106,11 +108,16 @@ internal class PixelCopyStrategy( } root.traverse(viewHierarchy, options.sessionReplay, options.logger, surfaceViewNodes) - hasSurfaceViews.set(surfaceViewNodes?.isNotEmpty() == true) - if (surfaceViewNodes.isNullOrEmpty()) { - submitMaskingAndCallback(root, viewHierarchy) + executor.submit( + ReplayRunnable("screenshot_recorder.mask") { + applyMaskingAndNotify(root, viewHierarchy) + } + ) } else { + // Re-arm the recorder's contentChanged gate; SurfaceView redraws don't trigger + // ViewTreeObserver.OnDrawListener, so we'd otherwise emit the same frame forever. + markContentChanged() captureSurfaceViews(root, surfaceViewNodes, viewHierarchy) } }, @@ -122,12 +129,6 @@ internal class PixelCopyStrategy( } } - private fun submitMaskingAndCallback(root: View, viewHierarchy: ViewHierarchyNode) { - executor.submit( - ReplayRunnable("screenshot_recorder.mask") { applyMaskingAndNotify(root, viewHierarchy) } - ) - } - private fun applyMaskingAndNotify(root: View, viewHierarchy: ViewHierarchyNode) { if (isClosed.get() || screenshot.isRecycled) { options.logger.log(DEBUG, "PixelCopyStrategy is closed, skipping masking") @@ -188,6 +189,10 @@ internal class PixelCopyStrategy( surfaceView, svBitmap, { copyResult: Int -> + if (isClosed.get()) { + svBitmap.recycle() + return@request + } if (copyResult == PixelCopy.SUCCESS) { captures[index] = SurfaceViewCapture(svBitmap, capturedX, capturedY) } else { @@ -250,10 +255,6 @@ internal class PixelCopyStrategy( return lastCaptureSuccessful.get() } - override fun hasSurfaceViews(): Boolean { - return hasSurfaceViews.get() - } - override fun emitLastScreenshot() { if (lastCaptureSuccessful() && !screenshot.isRecycled) { screenshotRecorderCallback?.onScreenshotRecorded(screenshot) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/ScreenshotStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/ScreenshotStrategy.kt index e636982bf85..a7b2334ea77 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/ScreenshotStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/ScreenshotStrategy.kt @@ -12,11 +12,4 @@ internal interface ScreenshotStrategy { fun lastCaptureSuccessful(): Boolean fun emitLastScreenshot() - - /** - * Whether the last capture detected SurfaceViews that render independently of the View tree. When - * true, the recorder bypasses the contentChanged guard since SurfaceView redraws don't trigger - * ViewTreeObserver.OnDrawListener. - */ - fun hasSurfaceViews(): Boolean = false } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/screenshot/PixelCopyStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/screenshot/PixelCopyStrategyTest.kt index edfb1714aca..277ad941a14 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/screenshot/PixelCopyStrategyTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/screenshot/PixelCopyStrategyTest.kt @@ -25,6 +25,7 @@ import io.sentry.android.replay.ScreenshotRecorderConfig import io.sentry.android.replay.util.DebugOverlayDrawable import io.sentry.android.replay.util.MainLooperHandler import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference import kotlin.test.BeforeTest import kotlin.test.Test @@ -53,6 +54,7 @@ class PixelCopyStrategyTest { val callback = mock() val debugOverlayDrawable = mock() val config = ScreenshotRecorderConfig(100, 100, 1f, 1f, 1, 1000) + val contentChangedMarked = AtomicBoolean(false) fun getSut(executor: ScheduledExecutorService = mock()): PixelCopyStrategy { return PixelCopyStrategy( @@ -67,6 +69,7 @@ class PixelCopyStrategyTest { options, config, debugOverlayDrawable, + markContentChanged = { contentChangedMarked.set(true) }, ) } @@ -130,7 +133,7 @@ class PixelCopyStrategyTest { } @Test - fun `capture does not flag hasSurfaceViews when option is disabled`() { + fun `capture does not call markContentChanged when option is disabled`() { val activity = buildActivity(ActivityWithSurfaceView::class.java).setup() shadowOf(Looper.getMainLooper()).idle() @@ -139,13 +142,13 @@ class PixelCopyStrategyTest { strategy.capture(activity.get().findViewById(android.R.id.content)) shadowOf(Looper.getMainLooper()).idle() - assertFalse(strategy.hasSurfaceViews()) + assertFalse(fixture.contentChangedMarked.get()) assertTrue(strategy.lastCaptureSuccessful()) verify(fixture.callback).onScreenshotRecorded(any()) } @Test - fun `capture flags hasSurfaceViews when option is enabled and SurfaceView is present`() { + fun `capture re-arms contentChanged when option is enabled and SurfaceView is present`() { val activity = buildActivity(ActivityWithSurfaceView::class.java).setup() shadowOf(Looper.getMainLooper()).idle() @@ -155,7 +158,7 @@ class PixelCopyStrategyTest { strategy.capture(activity.get().findViewById(android.R.id.content)) shadowOf(Looper.getMainLooper()).idle() - assertTrue(strategy.hasSurfaceViews()) + assertTrue(fixture.contentChangedMarked.get()) } @Test diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/util/ViewsTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/util/ViewsTest.kt index 066cedaabb1..d1b4e3213e3 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/util/ViewsTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/util/ViewsTest.kt @@ -1,12 +1,12 @@ package io.sentry.android.replay.util import android.app.Activity -import android.os.Bundle import android.view.SurfaceView import android.view.View import android.widget.FrameLayout import android.widget.FrameLayout.LayoutParams import android.widget.TextView +import android.os.Looper import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.NoOpLogger @@ -19,6 +19,7 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue import org.junit.runner.RunWith import org.robolectric.Robolectric.buildActivity +import org.robolectric.Shadows.shadowOf @RunWith(AndroidJUnit4::class) class ViewsTest { @@ -56,44 +57,29 @@ class ViewsTest { @Test fun `traverse collects visible SurfaceView nodes when a list is supplied`() { - val activity = buildActivity(SurfaceViewActivity::class.java).setup().get() - val root = activity.findViewById(android.R.id.content).getChildAt(0) as FrameLayout + val (root, _) = buildSurfaceViewHierarchy() val rootNode = ViewHierarchyNode.fromView(root, null, 0, SentryReplayOptions(false, null)) val collected = mutableListOf() root.traverse(rootNode, SentryReplayOptions(false, null), NoOpLogger.getInstance(), collected) assertEquals(2, collected.size) + assertTrue(collected.all { it is ViewHierarchyNode.SurfaceViewHierarchyNode }) } @Test fun `traverse does not collect SurfaceView nodes when list parameter is null`() { - val activity = buildActivity(SurfaceViewActivity::class.java).setup().get() - val root = activity.findViewById(android.R.id.content).getChildAt(0) as FrameLayout + val (root, _) = buildSurfaceViewHierarchy() val rootNode = ViewHierarchyNode.fromView(root, null, 0, SentryReplayOptions(false, null)) // Default parameter (null) — equivalent to the pre-feature call site behavior. root.traverse(rootNode, SentryReplayOptions(false, null), NoOpLogger.getInstance()) - - // No assertion on a collection; the goal is that this overload still works and never NPEs. - assertTrue(true) } @Test fun `traverse skips invisible SurfaceViews`() { - val activity = buildActivity(SurfaceViewActivity::class.java).setup().get() - val root = activity.findViewById(android.R.id.content).getChildAt(0) as FrameLayout - // Hide one of the two SurfaceViews. - var hidden = 0 - for (i in 0 until root.childCount) { - val child = root.getChildAt(i) - if (child is SurfaceView) { - child.visibility = View.GONE - hidden++ - break - } - } - assertEquals(1, hidden, "test setup: expected to find a SurfaceView to hide") + val (root, surfaceViews) = buildSurfaceViewHierarchy() + surfaceViews.first().visibility = View.GONE val rootNode = ViewHierarchyNode.fromView(root, null, 0, SentryReplayOptions(false, null)) val collected = mutableListOf() @@ -102,23 +88,27 @@ class ViewsTest { assertEquals(1, collected.size) } -} -private class SurfaceViewActivity : Activity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) + /** + * Builds and attaches a small view tree: `FrameLayout(SurfaceView, TextView, FrameLayout( + * SurfaceView))`. Returns the root [FrameLayout] and the two [SurfaceView]s in tree order so + * tests can mutate visibility without re-walking the hierarchy. + */ + private fun buildSurfaceViewHierarchy(): Pair> { + val activity = buildActivity(Activity::class.java).setup().get() + val sv1 = SurfaceView(activity).apply { layoutParams = LayoutParams(100, 100) } + val sv2 = SurfaceView(activity).apply { layoutParams = LayoutParams(50, 50) } + val nested = FrameLayout(activity).apply { addView(sv2) } val root = - FrameLayout(this).apply { + FrameLayout(activity).apply { layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + addView(sv1) + addView(TextView(activity).apply { text = "label" }) + addView(nested) } - root.addView(SurfaceView(this).apply { layoutParams = LayoutParams(100, 100) }) - root.addView(TextView(this).apply { text = "label" }) - root.addView( - FrameLayout(this).apply { - layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) - addView(SurfaceView(context).apply { layoutParams = LayoutParams(50, 50) }) - } - ) - setContentView(root) + activity.setContentView(root) + // Flush the layout/attach pass so isAttachedToWindow / visibility computations are accurate. + shadowOf(Looper.getMainLooper()).idle() + return root to listOf(sv1, sv2) } } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNodeTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNodeTest.kt deleted file mode 100644 index 8aaaa29942c..00000000000 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNodeTest.kt +++ /dev/null @@ -1,43 +0,0 @@ -package io.sentry.android.replay.viewhierarchy - -import android.view.SurfaceView -import android.view.View -import androidx.test.core.app.ApplicationProvider -import androidx.test.ext.junit.runners.AndroidJUnit4 -import io.sentry.SentryReplayOptions -import kotlin.test.Test -import kotlin.test.assertSame -import kotlin.test.assertTrue -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class ViewHierarchyNodeTest { - - private val options = SentryReplayOptions(false, null) - - @Test - fun `fromView returns SurfaceViewHierarchyNode for a SurfaceView`() { - val surfaceView = SurfaceView(ApplicationProvider.getApplicationContext()) - - val node = ViewHierarchyNode.fromView(surfaceView, null, 0, options) - - assertTrue( - node is ViewHierarchyNode.SurfaceViewHierarchyNode, - "expected SurfaceViewHierarchyNode but got ${node::class.simpleName}", - ) - assertTrue(node.isImportantForContentCapture) - assertSame(surfaceView, node.surfaceViewRef.get()) - } - - @Test - fun `fromView returns GenericViewHierarchyNode for a plain View`() { - val view = View(ApplicationProvider.getApplicationContext()) - - val node = ViewHierarchyNode.fromView(view, null, 0, options) - - assertTrue( - node is ViewHierarchyNode.GenericViewHierarchyNode, - "expected GenericViewHierarchyNode but got ${node::class.simpleName}", - ) - } -} diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index ed57948f505..6eb4a58e1c2 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -151,6 +151,12 @@ public enum SentryReplayQuality { * recording. When enabled, each SurfaceView in the view hierarchy will be captured separately via * PixelCopy and composited onto the screenshot. Only applies when {@link #screenshotStrategy} is * {@link ScreenshotStrategyType#PIXEL_COPY}. Default is disabled. + * + *

Warning: the SDK cannot mask individual elements rendered inside a SurfaceView (e.g. + * native Unity UI, map labels, video frames) — masking granularity is at the SurfaceView level + * only. If the SurfaceView is configured to be masked, the entire region is redacted; otherwise + * its full pixel content is sent in the replay. Only enable this for SurfaceViews whose content + * is safe to record. */ @ApiStatus.Experimental private boolean captureSurfaceViews = false; From d667d6ac98aaf4c0321dfd2a9ad7e3bd089d0fc1 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 28 Apr 2026 11:55:40 +0200 Subject: [PATCH 09/13] tweaks --- .../test/java/io/sentry/android/replay/util/ViewsTest.kt | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/util/ViewsTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/util/ViewsTest.kt index d1b4e3213e3..2eaa8411cfe 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/util/ViewsTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/util/ViewsTest.kt @@ -1,12 +1,12 @@ package io.sentry.android.replay.util import android.app.Activity +import android.os.Looper import android.view.SurfaceView import android.view.View import android.widget.FrameLayout import android.widget.FrameLayout.LayoutParams import android.widget.TextView -import android.os.Looper import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.NoOpLogger @@ -64,7 +64,6 @@ class ViewsTest { root.traverse(rootNode, SentryReplayOptions(false, null), NoOpLogger.getInstance(), collected) assertEquals(2, collected.size) - assertTrue(collected.all { it is ViewHierarchyNode.SurfaceViewHierarchyNode }) } @Test @@ -72,8 +71,7 @@ class ViewsTest { val (root, _) = buildSurfaceViewHierarchy() val rootNode = ViewHierarchyNode.fromView(root, null, 0, SentryReplayOptions(false, null)) - // Default parameter (null) — equivalent to the pre-feature call site behavior. - root.traverse(rootNode, SentryReplayOptions(false, null), NoOpLogger.getInstance()) + root.traverse(rootNode, SentryReplayOptions(false, null), NoOpLogger.getInstance(), null) } @Test From e8af26604e4f624892c570a9e4dd0dbc1dce0d94 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 28 Apr 2026 13:30:20 +0200 Subject: [PATCH 10/13] fix(replay): Detect window size changes on activities with configChanges Activities that declare android:configChanges="orientation|screenSize|..." (e.g. Unity, fullscreen video players) keep the same root view across rotations, so onRootViewsChanged never fires and determineWindowSize was never re-invoked. The recording bitmap stayed at the pre-rotation size, the rotated window content rendered into wrong-dim bitmaps, and SurfaceView captures composited at stale coordinates. Attach an OnLayoutChangeListener to each tracked root so a same-root resize triggers determineWindowSize. The existing size-comparison guard (both width and height must differ) keeps IME/adjustResize relayouts from causing spurious reconfigurations. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../sentry/android/replay/WindowRecorder.kt | 48 ++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt index ead8e2645ab..52f91f9c0cb 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt @@ -17,6 +17,7 @@ import io.sentry.android.replay.util.hasSize import io.sentry.android.replay.util.removeOnPreDrawListenerSafe import io.sentry.util.AutoClosableReentrantLock import java.lang.ref.WeakReference +import java.util.WeakHashMap import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.atomic.AtomicBoolean @@ -33,6 +34,7 @@ internal class WindowRecorder( private val isRecording = AtomicBoolean(false) private val rootViews = ArrayList>() private var lastKnownWindowSize: Point = Point() + private val rootLayoutListeners = WeakHashMap() private val rootViewsLock = AutoClosableReentrantLock() private val capturerLock = AutoClosableReentrantLock() private val backgroundProcessingHandlerLock = AutoClosableReentrantLock() @@ -124,7 +126,9 @@ internal class WindowRecorder( rootViews.add(WeakReference(root)) capturer?.recorder?.bind(root) determineWindowSize(root) + attachLayoutListener(root) } else { + detachLayoutListener(root) capturer?.recorder?.unbind(root) rootViews.removeAll { it.get() == root } @@ -132,6 +136,7 @@ internal class WindowRecorder( if (newRoot != null && root != newRoot) { capturer?.recorder?.bind(newRoot) determineWindowSize(newRoot) + attachLayoutListener(newRoot) } else { Unit // synchronized block wants us to return something lol } @@ -139,6 +144,41 @@ internal class WindowRecorder( } } + /** + * Activities that handle their own configuration changes (e.g. Unity, video players via + * `android:configChanges="orientation|screenSize|..."`) keep the same root view across rotations, + * so [onRootViewsChanged] never fires and [determineWindowSize] would never re-detect the new + * dimensions. Watch the root for layout-time size changes to catch these cases. + */ + private fun attachLayoutListener(root: View) { + if (rootLayoutListeners.containsKey(root)) return + val listener = + View.OnLayoutChangeListener { + v, + left, + top, + right, + bottom, + oldLeft, + oldTop, + oldRight, + oldBottom -> + val width = right - left + val height = bottom - top + val oldWidth = oldRight - oldLeft + val oldHeight = oldBottom - oldTop + if (width != oldWidth || height != oldHeight) { + determineWindowSize(v) + } + } + rootLayoutListeners[root] = listener + root.addOnLayoutChangeListener(listener) + } + + private fun detachLayoutListener(root: View) { + rootLayoutListeners.remove(root)?.let { root.removeOnLayoutChangeListener(it) } + } + fun determineWindowSize(root: View) { if (root.hasSize()) { if (root.width != lastKnownWindowSize.x && root.height != lastKnownWindowSize.y) { @@ -222,7 +262,13 @@ internal class WindowRecorder( override fun reset() { lastKnownWindowSize.set(0, 0) rootViewsLock.acquire().use { - rootViews.forEach { capturer?.recorder?.unbind(it.get()) } + rootViews.forEach { + val root = it.get() + if (root != null) { + detachLayoutListener(root) + capturer?.recorder?.unbind(root) + } + } rootViews.clear() } } From 001f3ad1aceffff350977bc10054548a3f793cf1 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 28 Apr 2026 14:39:31 +0200 Subject: [PATCH 11/13] fix(replay): Avoid windowLocation race and bitmap leak in SurfaceView capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address two issues flagged by review: 1. windowLocation race — root.getLocationOnScreen(windowLocation) ran on the main thread, but compositeSurfaceViewsAndMask read windowLocation[0]/[1] later from the executor thread. If a new capture cycle started before the compositor ran, the field was overwritten and SurfaceView pixels would composite at the wrong offset. Snapshot into locals (windowX/windowY) at capture time and pass them through, matching the existing svLocation → capturedX/capturedY pattern. 2. Bitmap leak when isClosed in SurfaceView callback — when the strategy closed mid-capture, the path recycled the in-flight svBitmap but skipped onCaptureComplete(), so remaining never reached zero and any sibling bitmaps already stored in captures[] leaked until GC. Now still drive the completion latch on the closed path, and have the compositor's early-return path recycle leftover captures. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../replay/screenshot/PixelCopyStrategy.kt | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/PixelCopyStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/PixelCopyStrategy.kt index b58f121763f..513ddbb8194 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/PixelCopyStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/PixelCopyStrategy.kt @@ -157,14 +157,18 @@ internal class PixelCopyStrategy( surfaceViewNodes: List, viewHierarchy: ViewHierarchyNode, ) { + // Snapshot the window location into locals so the executor-side compositor reads stable + // values even if a new capture cycle starts and overwrites the field. root.getLocationOnScreen(windowLocation) + val windowX = windowLocation[0] + val windowY = windowLocation[1] val captures = arrayOfNulls(surfaceViewNodes.size) val remaining = AtomicInteger(surfaceViewNodes.size) fun onCaptureComplete() { if (remaining.decrementAndGet() == 0) { - compositeSurfaceViewsAndMask(root, captures, viewHierarchy) + compositeSurfaceViewsAndMask(root, captures, viewHierarchy, windowX, windowY) } } @@ -191,6 +195,9 @@ internal class PixelCopyStrategy( { copyResult: Int -> if (isClosed.get()) { svBitmap.recycle() + // still drive the completion latch so any prior captures get recycled by the + // composite step's early-return path. + onCaptureComplete() return@request } if (copyResult == PixelCopy.SUCCESS) { @@ -214,11 +221,14 @@ internal class PixelCopyStrategy( root: View, captures: Array, viewHierarchy: ViewHierarchyNode, + windowX: Int, + windowY: Int, ) { executor.submit( ReplayRunnable("screenshot_recorder.composite") { if (isClosed.get() || screenshot.isRecycled) { options.logger.log(DEBUG, "PixelCopyStrategy is closed, skipping compositing") + recycleCaptures(captures) return@ReplayRunnable } @@ -234,8 +244,8 @@ internal class PixelCopyStrategy( capture.bitmap, capture.x, capture.y, - windowLocation[0], - windowLocation[1], + windowX, + windowY, config.scaleFactorX, config.scaleFactorY, ) @@ -247,6 +257,14 @@ internal class PixelCopyStrategy( ) } + private fun recycleCaptures(captures: Array) { + for (capture in captures) { + if (capture != null && !capture.bitmap.isRecycled) { + capture.bitmap.recycle() + } + } + } + override fun onContentChanged() { contentChanged.set(true) } From d0b25dfb0e083e6bde8cfff2c110b3bd0b0a4d50 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 28 Apr 2026 15:00:45 +0200 Subject: [PATCH 12/13] fix(replay): Reconfig on single-dim resizes and recycle SurfaceView bitmap on throw MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review-bot findings: 1. determineWindowSize used && to compare new vs last-known dimensions, so single-dimension resizes (split-screen drag, partial multi-window adjustments, foldable transitions where only one dim shifts) were silently dropped — onWindowSizeChanged only fired when both width AND height differed. The new layout listener already detects single-dim changes with ||, but then delegated to a function that AND'd them away. Switch the existing checks to || so any size delta reconfigs the recorder, matching the listener's intent. 2. In captureSurfaceViews, if PixelCopy.request or getLocationOnScreen threw after svBitmap was allocated, the catch path logged the error but never recycled the bitmap, leaking it until GC. Track the bitmap in a nullable local that the catch block recycles, and clear it after PixelCopy.request returns successfully so ownership transfers to the async callback without double-recycling. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../io/sentry/android/replay/WindowRecorder.kt | 4 ++-- .../replay/screenshot/PixelCopyStrategy.kt | 16 +++++++++++----- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt index 52f91f9c0cb..628a1dbd28a 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt @@ -181,7 +181,7 @@ internal class WindowRecorder( fun determineWindowSize(root: View) { if (root.hasSize()) { - if (root.width != lastKnownWindowSize.x && root.height != lastKnownWindowSize.y) { + if (root.width != lastKnownWindowSize.x || root.height != lastKnownWindowSize.y) { lastKnownWindowSize.set(root.width, root.height) windowCallback.onWindowSizeChanged(root.width, root.height) } @@ -197,7 +197,7 @@ internal class WindowRecorder( } if (root.hasSize()) { root.removeOnPreDrawListenerSafe(this) - if (root.width != lastKnownWindowSize.x && root.height != lastKnownWindowSize.y) { + if (root.width != lastKnownWindowSize.x || root.height != lastKnownWindowSize.y) { lastKnownWindowSize.set(root.width, root.height) windowCallback.onWindowSizeChanged(root.width, root.height) } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/PixelCopyStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/PixelCopyStrategy.kt index 513ddbb8194..81dd7c5cee5 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/PixelCopyStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/PixelCopyStrategy.kt @@ -181,9 +181,11 @@ internal class PixelCopyStrategy( continue } + var svBitmap: Bitmap? = null try { - val svBitmap = + svBitmap = Bitmap.createBitmap(surfaceView.width, surfaceView.height, Bitmap.Config.ARGB_8888) + val bitmapToCapture = svBitmap surfaceView.getLocationOnScreen(svLocation) val capturedX = svLocation[0] @@ -191,27 +193,31 @@ internal class PixelCopyStrategy( PixelCopy.request( surfaceView, - svBitmap, + bitmapToCapture, { copyResult: Int -> if (isClosed.get()) { - svBitmap.recycle() + bitmapToCapture.recycle() // still drive the completion latch so any prior captures get recycled by the // composite step's early-return path. onCaptureComplete() return@request } if (copyResult == PixelCopy.SUCCESS) { - captures[index] = SurfaceViewCapture(svBitmap, capturedX, capturedY) + captures[index] = SurfaceViewCapture(bitmapToCapture, capturedX, capturedY) } else { - svBitmap.recycle() + bitmapToCapture.recycle() options.logger.log(INFO, "Failed to capture SurfaceView: %d", copyResult) } onCaptureComplete() }, mainLooperHandler.handler, ) + // Ownership transferred to the PixelCopy callback — clear local so catch doesn't + // double-recycle if the recycle paths above already ran. + svBitmap = null } catch (e: Throwable) { options.logger.log(WARNING, "Failed to capture SurfaceView", e) + svBitmap?.recycle() onCaptureComplete() } } From b48fc50f71fedcec6c50849890b60ffab3df8246 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 28 Apr 2026 15:35:08 +0200 Subject: [PATCH 13/13] fix(replay): Ignore layout changes on non-latest root in WindowRecorder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rootViews is a stack of windows (dialogs, popups, IME). The recorder binds to the topmost root, so a background activity resizing underneath a dialog must not reconfigure the recorder — we'd otherwise allocate a bitmap sized to the activity while still recording the dialog. The latest root's correct dimensions are already picked up via determineWindowSize in the onRootViewsChanged remove path when the overlaying window dismisses. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../main/java/io/sentry/android/replay/WindowRecorder.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt index 628a1dbd28a..19c61900889 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt @@ -167,9 +167,10 @@ internal class WindowRecorder( val height = bottom - top val oldWidth = oldRight - oldLeft val oldHeight = oldBottom - oldTop - if (width != oldWidth || height != oldHeight) { - determineWindowSize(v) - } + if (width == oldWidth && height == oldHeight) return@OnLayoutChangeListener + // ignore non-latest roots so a dialog stays sized for itself, not its background activity. + if (v != rootViews.lastOrNull()?.get()) return@OnLayoutChangeListener + determineWindowSize(v) } rootLayoutListeners[root] = listener root.addOnLayoutChangeListener(listener)