@@ -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,22 @@ 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 captureSurfaceViewsEnabled = options.sessionReplay.isCaptureSurfaceViews
102+ val surfaceViewNodes =
103+ if (captureSurfaceViewsEnabled) {
104+ mutableListOf< ViewHierarchyNode . SurfaceViewHierarchyNode >()
105+ } else {
106+ null
107+ }
108+ root.traverse(viewHierarchy, options.sessionReplay, options.logger, surfaceViewNodes)
92109
93- val debugMasks = maskRenderer.renderMasks(screenshot, viewHierarchy, prescaledMatrix )
110+ hasSurfaceViews.set(surfaceViewNodes?.isNotEmpty() == true )
94111
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- )
112+ if (surfaceViewNodes.isNullOrEmpty()) {
113+ submitMaskingAndCallback(root, viewHierarchy)
114+ } else {
115+ captureSurfaceViews(root, surfaceViewNodes, viewHierarchy)
116+ }
109117 },
110118 mainLooperHandler.handler,
111119 )
@@ -115,6 +123,123 @@ internal class PixelCopyStrategy(
115123 }
116124 }
117125
126+ private fun submitMaskingAndCallback (root : View , viewHierarchy : ViewHierarchyNode ) {
127+ executor.submit(
128+ ReplayRunnable (" screenshot_recorder.mask" ) { applyMaskingAndNotify(root, viewHierarchy) }
129+ )
130+ }
131+
132+ private fun applyMaskingAndNotify (root : View , viewHierarchy : ViewHierarchyNode ) {
133+ if (isClosed.get() || screenshot.isRecycled) {
134+ options.logger.log(DEBUG , " PixelCopyStrategy is closed, skipping masking" )
135+ return
136+ }
137+
138+ val debugMasks = maskRenderer.renderMasks(screenshot, viewHierarchy, prescaledMatrix)
139+
140+ if (options.replayController.isDebugMaskingOverlayEnabled()) {
141+ mainLooperHandler.post {
142+ if (debugOverlayDrawable.callback == null ) {
143+ root.overlay.add(debugOverlayDrawable)
144+ }
145+ debugOverlayDrawable.updateMasks(debugMasks)
146+ root.postInvalidate()
147+ }
148+ }
149+ screenshotRecorderCallback?.onScreenshotRecorded(screenshot)
150+ lastCaptureSuccessful.set(true )
151+ contentChanged.set(false )
152+ }
153+
154+ @SuppressLint(" NewApi" )
155+ private fun captureSurfaceViews (
156+ root : View ,
157+ surfaceViewNodes : List <ViewHierarchyNode .SurfaceViewHierarchyNode >,
158+ viewHierarchy : ViewHierarchyNode ,
159+ ) {
160+ root.getLocationOnScreen(windowLocation)
161+
162+ val captures = arrayOfNulls<SurfaceViewCapture >(surfaceViewNodes.size)
163+ val remaining = AtomicInteger (surfaceViewNodes.size)
164+
165+ fun onCaptureComplete () {
166+ if (remaining.decrementAndGet() == 0 ) {
167+ compositeSurfaceViewsAndMask(root, captures, viewHierarchy)
168+ }
169+ }
170+
171+ for ((index, node) in surfaceViewNodes.withIndex()) {
172+ val surfaceView = node.surfaceViewRef.get()
173+ if (surfaceView == null || ! surfaceView.holder.surface.isValid) {
174+ onCaptureComplete()
175+ continue
176+ }
177+
178+ try {
179+ val svBitmap =
180+ Bitmap .createBitmap(surfaceView.width, surfaceView.height, Bitmap .Config .ARGB_8888 )
181+
182+ surfaceView.getLocationOnScreen(svLocation)
183+ val capturedX = svLocation[0 ]
184+ val capturedY = svLocation[1 ]
185+
186+ PixelCopy .request(
187+ surfaceView,
188+ svBitmap,
189+ { copyResult: Int ->
190+ if (copyResult == PixelCopy .SUCCESS ) {
191+ captures[index] = SurfaceViewCapture (svBitmap, capturedX, capturedY)
192+ } else {
193+ svBitmap.recycle()
194+ options.logger.log(INFO , " Failed to capture SurfaceView: %d" , copyResult)
195+ }
196+ onCaptureComplete()
197+ },
198+ mainLooperHandler.handler,
199+ )
200+ } catch (e: Throwable ) {
201+ options.logger.log(WARNING , " Failed to capture SurfaceView" , e)
202+ onCaptureComplete()
203+ }
204+ }
205+ }
206+
207+ private fun compositeSurfaceViewsAndMask (
208+ root : View ,
209+ captures : Array <SurfaceViewCapture ?>,
210+ viewHierarchy : ViewHierarchyNode ,
211+ ) {
212+ executor.submit(
213+ ReplayRunnable (" screenshot_recorder.composite" ) {
214+ if (isClosed.get() || screenshot.isRecycled) {
215+ options.logger.log(DEBUG , " PixelCopyStrategy is closed, skipping compositing" )
216+ return @ReplayRunnable
217+ }
218+
219+ for (capture in captures) {
220+ if (capture == null ) continue
221+ if (capture.bitmap.isRecycled) continue
222+
223+ val left = (capture.x - windowLocation[0 ]) * config.scaleFactorX
224+ val top = (capture.y - windowLocation[1 ]) * config.scaleFactorY
225+ tmpSrcRect.set(0 , 0 , capture.bitmap.width, capture.bitmap.height)
226+ tmpDstRect.set(
227+ left,
228+ top,
229+ left + capture.bitmap.width * config.scaleFactorX,
230+ top + capture.bitmap.height * config.scaleFactorY,
231+ )
232+
233+ // DST_OVER draws the SurfaceView content behind the existing Window content
234+ screenshotCanvas.drawBitmap(capture.bitmap, tmpSrcRect, tmpDstRect, dstOverPaint)
235+ capture.bitmap.recycle()
236+ }
237+
238+ applyMaskingAndNotify(root, viewHierarchy)
239+ }
240+ )
241+ }
242+
118243 override fun onContentChanged () {
119244 contentChanged.set(true )
120245 }
@@ -123,6 +248,10 @@ internal class PixelCopyStrategy(
123248 return lastCaptureSuccessful.get()
124249 }
125250
251+ override fun hasSurfaceViews (): Boolean {
252+ return hasSurfaceViews.get()
253+ }
254+
126255 override fun emitLastScreenshot () {
127256 if (lastCaptureSuccessful() && ! screenshot.isRecycled) {
128257 screenshotRecorderCallback?.onScreenshotRecorded(screenshot)
0 commit comments