diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6dabfab7294..a46f155d202 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,13 @@
# Changelog
+## 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`
+ - Or via manifest: ``
+
## 8.40.0
### Fixes
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/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-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
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..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
@@ -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,21 @@ 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 surfaceViewNodes =
+ if (options.sessionReplay.isCaptureSurfaceViews) {
+ 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 +122,126 @@ 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()
+ // 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
+ }
+
+ 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
+
+ compositeSurfaceViewInto(
+ screenshotCanvas,
+ dstOverPaint,
+ tmpSrcRect,
+ tmpDstRect,
+ capture.bitmap,
+ capture.x,
+ capture.y,
+ windowLocation[0],
+ windowLocation[1],
+ config.scaleFactorX,
+ config.scaleFactorY,
+ )
+ capture.bitmap.recycle()
+ }
+
+ applyMaskingAndNotify(root, viewHierarchy)
+ }
+ )
+ }
+
override fun onContentChanged() {
contentChanged.set(true)
}
@@ -123,6 +250,10 @@ internal class PixelCopyStrategy(
return lastCaptureSuccessful.get()
}
+ override fun hasSurfaceViews(): Boolean {
+ return hasSurfaceViews.get()
+ }
+
override fun emitLastScreenshot() {
if (lastCaptureSuccessful() && !screenshot.isRecycled) {
screenshotRecorderCallback?.onScreenshotRecorded(screenshot)
@@ -148,3 +279,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/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..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
@@ -38,6 +38,7 @@ internal fun View.traverse(
parentNode: ViewHierarchyNode,
options: SentryMaskingOptions,
logger: ILogger,
+ surfaceViewNodes: MutableList? = null,
) {
if (this !is ViewGroup) {
return
@@ -59,7 +60,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-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..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
@@ -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,26 @@ 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}",
+ )
+ }
+}
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
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.
*