Skip to content

Commit cbc7d7c

Browse files
authored
Merge 26d9984 into 6b019b7
2 parents 6b019b7 + 26d9984 commit cbc7d7c

14 files changed

Lines changed: 629 additions & 26 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
### Features
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))
8+
- To enable, set `options.sessionReplay.isCaptureSurfaceViews = true`
9+
- Or via manifest: `<meta-data android:name="io.sentry.session-replay.capture-surface-views" android:value="true" />`
10+
311
## 8.40.0
412

513
### Fixes

sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,8 @@ final class ManifestMetadataReader {
120120

121121
static final String REPLAYS_DEBUG = "io.sentry.session-replay.debug";
122122
static final String REPLAYS_SCREENSHOT_STRATEGY = "io.sentry.session-replay.screenshot-strategy";
123+
static final String REPLAYS_CAPTURE_SURFACE_VIEWS =
124+
"io.sentry.session-replay.capture-surface-views";
123125

124126
static final String REPLAYS_NETWORK_DETAIL_ALLOW_URLS =
125127
"io.sentry.session-replay.network-detail-allow-urls";
@@ -547,6 +549,15 @@ static void applyMetadata(
547549
}
548550
}
549551

552+
options
553+
.getSessionReplay()
554+
.setCaptureSurfaceViews(
555+
readBool(
556+
metadata,
557+
logger,
558+
REPLAYS_CAPTURE_SURFACE_VIEWS,
559+
options.getSessionReplay().isCaptureSurfaceViews()));
560+
550561
// Network Details Configuration
551562
if (options.getSessionReplay().getNetworkDetailAllowUrls().isEmpty()) {
552563
final @Nullable List<String> allowUrls =

sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ private boolean isMaskingEnabled() {
201201

202202
final ViewHierarchyNode rootNode =
203203
ViewHierarchyNode.Companion.fromView(rootView, null, 0, options.getScreenshot());
204-
ViewsKt.traverse(rootView, rootNode, options.getScreenshot(), options.getLogger());
204+
ViewsKt.traverse(rootView, rootNode, options.getScreenshot(), options.getLogger(), null);
205205
return rootNode;
206206
} catch (Throwable e) {
207207
options.getLogger().log(SentryLevel.ERROR, "Failed to build view hierarchy", e);

sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2022,6 +2022,31 @@ class ManifestMetadataReaderTest {
20222022
)
20232023
}
20242024

2025+
@Test
2026+
fun `applyMetadata reads capture-surface-views to options`() {
2027+
// Arrange
2028+
val bundle = bundleOf(ManifestMetadataReader.REPLAYS_CAPTURE_SURFACE_VIEWS to true)
2029+
val context = fixture.getContext(metaData = bundle)
2030+
2031+
// Act
2032+
ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider)
2033+
2034+
// Assert
2035+
assertTrue(fixture.options.sessionReplay.isCaptureSurfaceViews)
2036+
}
2037+
2038+
@Test
2039+
fun `applyMetadata reads capture-surface-views and keeps default if not found`() {
2040+
// Arrange
2041+
val context = fixture.getContext()
2042+
2043+
// Act
2044+
ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider)
2045+
2046+
// Assert
2047+
assertFalse(fixture.options.sessionReplay.isCaptureSurfaceViews)
2048+
}
2049+
20252050
@Test
20262051
fun `applyMetadata reads anrProfilingSampleRate to options`() {
20272052
// Arrange

sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ internal class ScreenshotRecorder(
7070
)
7171
}
7272

73-
if (!contentChanged.get()) {
73+
if (!contentChanged.get() && !screenshotStrategy.hasSurfaceViews()) {
7474
screenshotStrategy.emitLastScreenshot()
7575
return
7676
}

sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/PixelCopyStrategy.kt

Lines changed: 189 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,13 @@ package io.sentry.android.replay.screenshot
22

33
import android.annotation.SuppressLint
44
import android.graphics.Bitmap
5+
import android.graphics.Canvas
56
import android.graphics.Matrix
7+
import android.graphics.Paint
8+
import android.graphics.PorterDuff
9+
import android.graphics.PorterDuffXfermode
10+
import android.graphics.Rect
11+
import android.graphics.RectF
612
import android.view.PixelCopy
713
import android.view.View
814
import io.sentry.SentryLevel.DEBUG
@@ -19,6 +25,7 @@ import io.sentry.android.replay.util.ReplayRunnable
1925
import io.sentry.android.replay.util.traverse
2026
import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode
2127
import java.util.concurrent.atomic.AtomicBoolean
28+
import java.util.concurrent.atomic.AtomicInteger
2229
import kotlin.LazyThreadSafetyMode.NONE
2330

2431
@SuppressLint("UseKtx")
@@ -40,6 +47,16 @@ internal class PixelCopyStrategy(
4047
private val maskRenderer = MaskRenderer()
4148
private val contentChanged = AtomicBoolean(false)
4249
private val isClosed = AtomicBoolean(false)
50+
private val hasSurfaceViews = AtomicBoolean(false)
51+
private val dstOverPaint by
52+
lazy(NONE) { Paint().apply { xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_OVER) } }
53+
private val screenshotCanvas by lazy(NONE) { Canvas(screenshot) }
54+
private val tmpSrcRect = Rect()
55+
private val tmpDstRect = RectF()
56+
private val windowLocation = IntArray(2)
57+
private val svLocation = IntArray(2)
58+
59+
private class SurfaceViewCapture(val bitmap: Bitmap, val x: Int, val y: Int)
4360

4461
@SuppressLint("NewApi")
4562
override fun capture(root: View) {
@@ -81,31 +98,21 @@ internal class PixelCopyStrategy(
8198

8299
// TODO: disableAllMasking here and dont traverse?
83100
val viewHierarchy = ViewHierarchyNode.fromView(root, null, 0, options.sessionReplay)
84-
root.traverse(viewHierarchy, options.sessionReplay, options.logger)
85-
86-
executor.submit(
87-
ReplayRunnable("screenshot_recorder.mask") {
88-
if (isClosed.get() || screenshot.isRecycled) {
89-
options.logger.log(DEBUG, "PixelCopyStrategy is closed, skipping masking")
90-
return@ReplayRunnable
91-
}
101+
val surfaceViewNodes =
102+
if (options.sessionReplay.isCaptureSurfaceViews) {
103+
mutableListOf<ViewHierarchyNode.SurfaceViewHierarchyNode>()
104+
} else {
105+
null
106+
}
107+
root.traverse(viewHierarchy, options.sessionReplay, options.logger, surfaceViewNodes)
92108

93-
val debugMasks = maskRenderer.renderMasks(screenshot, viewHierarchy, prescaledMatrix)
109+
hasSurfaceViews.set(surfaceViewNodes?.isNotEmpty() == true)
94110

95-
if (options.replayController.isDebugMaskingOverlayEnabled()) {
96-
mainLooperHandler.post {
97-
if (debugOverlayDrawable.callback == null) {
98-
root.overlay.add(debugOverlayDrawable)
99-
}
100-
debugOverlayDrawable.updateMasks(debugMasks)
101-
root.postInvalidate()
102-
}
103-
}
104-
screenshotRecorderCallback?.onScreenshotRecorded(screenshot)
105-
lastCaptureSuccessful.set(true)
106-
contentChanged.set(false)
107-
}
108-
)
111+
if (surfaceViewNodes.isNullOrEmpty()) {
112+
submitMaskingAndCallback(root, viewHierarchy)
113+
} else {
114+
captureSurfaceViews(root, surfaceViewNodes, viewHierarchy)
115+
}
109116
},
110117
mainLooperHandler.handler,
111118
)
@@ -115,6 +122,126 @@ internal class PixelCopyStrategy(
115122
}
116123
}
117124

125+
private fun submitMaskingAndCallback(root: View, viewHierarchy: ViewHierarchyNode) {
126+
executor.submit(
127+
ReplayRunnable("screenshot_recorder.mask") { applyMaskingAndNotify(root, viewHierarchy) }
128+
)
129+
}
130+
131+
private fun applyMaskingAndNotify(root: View, viewHierarchy: ViewHierarchyNode) {
132+
if (isClosed.get() || screenshot.isRecycled) {
133+
options.logger.log(DEBUG, "PixelCopyStrategy is closed, skipping masking")
134+
return
135+
}
136+
137+
val debugMasks = maskRenderer.renderMasks(screenshot, viewHierarchy, prescaledMatrix)
138+
139+
if (options.replayController.isDebugMaskingOverlayEnabled()) {
140+
mainLooperHandler.post {
141+
if (debugOverlayDrawable.callback == null) {
142+
root.overlay.add(debugOverlayDrawable)
143+
}
144+
debugOverlayDrawable.updateMasks(debugMasks)
145+
root.postInvalidate()
146+
}
147+
}
148+
screenshotRecorderCallback?.onScreenshotRecorded(screenshot)
149+
lastCaptureSuccessful.set(true)
150+
contentChanged.set(false)
151+
}
152+
153+
@SuppressLint("NewApi")
154+
private fun captureSurfaceViews(
155+
root: View,
156+
surfaceViewNodes: List<ViewHierarchyNode.SurfaceViewHierarchyNode>,
157+
viewHierarchy: ViewHierarchyNode,
158+
) {
159+
root.getLocationOnScreen(windowLocation)
160+
161+
val captures = arrayOfNulls<SurfaceViewCapture>(surfaceViewNodes.size)
162+
val remaining = AtomicInteger(surfaceViewNodes.size)
163+
164+
fun onCaptureComplete() {
165+
if (remaining.decrementAndGet() == 0) {
166+
compositeSurfaceViewsAndMask(root, captures, viewHierarchy)
167+
}
168+
}
169+
170+
for ((index, node) in surfaceViewNodes.withIndex()) {
171+
val surfaceView = node.surfaceViewRef.get()
172+
// holder.surface can be null before the surface is created — guard against NPE.
173+
val surface = surfaceView?.holder?.surface
174+
if (surfaceView == null || surface == null || !surface.isValid) {
175+
onCaptureComplete()
176+
continue
177+
}
178+
179+
try {
180+
val svBitmap =
181+
Bitmap.createBitmap(surfaceView.width, surfaceView.height, Bitmap.Config.ARGB_8888)
182+
183+
surfaceView.getLocationOnScreen(svLocation)
184+
val capturedX = svLocation[0]
185+
val capturedY = svLocation[1]
186+
187+
PixelCopy.request(
188+
surfaceView,
189+
svBitmap,
190+
{ copyResult: Int ->
191+
if (copyResult == PixelCopy.SUCCESS) {
192+
captures[index] = SurfaceViewCapture(svBitmap, capturedX, capturedY)
193+
} else {
194+
svBitmap.recycle()
195+
options.logger.log(INFO, "Failed to capture SurfaceView: %d", copyResult)
196+
}
197+
onCaptureComplete()
198+
},
199+
mainLooperHandler.handler,
200+
)
201+
} catch (e: Throwable) {
202+
options.logger.log(WARNING, "Failed to capture SurfaceView", e)
203+
onCaptureComplete()
204+
}
205+
}
206+
}
207+
208+
private fun compositeSurfaceViewsAndMask(
209+
root: View,
210+
captures: Array<SurfaceViewCapture?>,
211+
viewHierarchy: ViewHierarchyNode,
212+
) {
213+
executor.submit(
214+
ReplayRunnable("screenshot_recorder.composite") {
215+
if (isClosed.get() || screenshot.isRecycled) {
216+
options.logger.log(DEBUG, "PixelCopyStrategy is closed, skipping compositing")
217+
return@ReplayRunnable
218+
}
219+
220+
for (capture in captures) {
221+
if (capture == null) continue
222+
if (capture.bitmap.isRecycled) continue
223+
224+
compositeSurfaceViewInto(
225+
screenshotCanvas,
226+
dstOverPaint,
227+
tmpSrcRect,
228+
tmpDstRect,
229+
capture.bitmap,
230+
capture.x,
231+
capture.y,
232+
windowLocation[0],
233+
windowLocation[1],
234+
config.scaleFactorX,
235+
config.scaleFactorY,
236+
)
237+
capture.bitmap.recycle()
238+
}
239+
240+
applyMaskingAndNotify(root, viewHierarchy)
241+
}
242+
)
243+
}
244+
118245
override fun onContentChanged() {
119246
contentChanged.set(true)
120247
}
@@ -123,6 +250,10 @@ internal class PixelCopyStrategy(
123250
return lastCaptureSuccessful.get()
124251
}
125252

253+
override fun hasSurfaceViews(): Boolean {
254+
return hasSurfaceViews.get()
255+
}
256+
126257
override fun emitLastScreenshot() {
127258
if (lastCaptureSuccessful() && !screenshot.isRecycled) {
128259
screenshotRecorderCallback?.onScreenshotRecorded(screenshot)
@@ -148,3 +279,38 @@ internal class PixelCopyStrategy(
148279
)
149280
}
150281
}
282+
283+
/**
284+
* Composites [sourceBitmap] (a SurfaceView capture) onto [destCanvas] (wrapping the recording
285+
* screenshot) using [destPaint] (expected to have DST_OVER xfermode), so the SurfaceView content
286+
* draws _behind_ existing Window content — filling the transparent holes the Window PixelCopy
287+
* leaves where SurfaceViews are.
288+
*
289+
* Extracted for testability — the compositing is pure drawing logic that can be driven with
290+
* hand-built bitmaps, while the surrounding [PixelCopyStrategy.captureSurfaceViews] flow depends on
291+
* a real SurfaceView producer that Robolectric cannot provide.
292+
*/
293+
internal fun compositeSurfaceViewInto(
294+
destCanvas: Canvas,
295+
destPaint: Paint,
296+
tmpSrc: Rect,
297+
tmpDst: RectF,
298+
sourceBitmap: Bitmap,
299+
sourceX: Int,
300+
sourceY: Int,
301+
windowX: Int,
302+
windowY: Int,
303+
scaleFactorX: Float,
304+
scaleFactorY: Float,
305+
) {
306+
val left = (sourceX - windowX) * scaleFactorX
307+
val top = (sourceY - windowY) * scaleFactorY
308+
tmpSrc.set(0, 0, sourceBitmap.width, sourceBitmap.height)
309+
tmpDst.set(
310+
left,
311+
top,
312+
left + sourceBitmap.width * scaleFactorX,
313+
top + sourceBitmap.height * scaleFactorY,
314+
)
315+
destCanvas.drawBitmap(sourceBitmap, tmpSrc, tmpDst, destPaint)
316+
}

sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/ScreenshotStrategy.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,11 @@ internal interface ScreenshotStrategy {
1212
fun lastCaptureSuccessful(): Boolean
1313

1414
fun emitLastScreenshot()
15+
16+
/**
17+
* Whether the last capture detected SurfaceViews that render independently of the View tree. When
18+
* true, the recorder bypasses the contentChanged guard since SurfaceView redraws don't trigger
19+
* ViewTreeObserver.OnDrawListener.
20+
*/
21+
fun hasSurfaceViews(): Boolean = false
1522
}

sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ internal fun View.traverse(
3838
parentNode: ViewHierarchyNode,
3939
options: SentryMaskingOptions,
4040
logger: ILogger,
41+
surfaceViewNodes: MutableList<ViewHierarchyNode.SurfaceViewHierarchyNode>? = null,
4142
) {
4243
if (this !is ViewGroup) {
4344
return
@@ -59,7 +60,14 @@ internal fun View.traverse(
5960
if (child != null) {
6061
val childNode = ViewHierarchyNode.fromView(child, parentNode, indexOfChild(child), options)
6162
childNodes.add(childNode)
62-
child.traverse(childNode, options, logger)
63+
if (
64+
surfaceViewNodes != null &&
65+
childNode is ViewHierarchyNode.SurfaceViewHierarchyNode &&
66+
childNode.isVisible
67+
) {
68+
surfaceViewNodes.add(childNode)
69+
}
70+
child.traverse(childNode, options, logger, surfaceViewNodes)
6371
}
6472
}
6573
parentNode.children = childNodes

0 commit comments

Comments
 (0)