@@ -2,7 +2,13 @@ package io.sentry.android.replay.screenshot
22
33import android.annotation.SuppressLint
44import android.graphics.Bitmap
5+ import android.graphics.Canvas
56import 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
612import android.view.PixelCopy
713import android.view.View
814import io.sentry.SentryLevel.DEBUG
@@ -19,6 +25,7 @@ import io.sentry.android.replay.util.ReplayRunnable
1925import io.sentry.android.replay.util.traverse
2026import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode
2127import java.util.concurrent.atomic.AtomicBoolean
28+ import java.util.concurrent.atomic.AtomicInteger
2229import 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+ }
0 commit comments