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. *