@@ -59,6 +59,8 @@ internal class PixelCopyStrategy(
5959 private val contentChanged = AtomicBoolean (false )
6060 private val unstableCaptures = AtomicInteger (0 )
6161 private val isClosed = AtomicBoolean (false )
62+ private val frameInFlight = AtomicBoolean (false )
63+ private val cleanupScheduled = AtomicBoolean (false )
6264 private val dstOverPaint by
6365 lazy(NONE ) { Paint ().apply { xfermode = PorterDuffXfermode (PorterDuff .Mode .DST_OVER ) } }
6466 private val screenshotCanvas by lazy(NONE ) { Canvas (screenshot) }
@@ -77,8 +79,15 @@ internal class PixelCopyStrategy(
7779 return
7880 }
7981
82+ if (! frameInFlight.compareAndSet(false , true )) {
83+ options.logger.log(DEBUG , " PixelCopyStrategy capture is already in flight, skipping" )
84+ markContentChanged()
85+ return
86+ }
87+
8088 if (isClosed.get()) {
8189 options.logger.log(DEBUG , " PixelCopyStrategy is closed, not capturing screenshot" )
90+ finishFrame()
8291 return
8392 }
8493
@@ -90,51 +99,74 @@ internal class PixelCopyStrategy(
9099 { copyResult: Int ->
91100 if (isClosed.get()) {
92101 options.logger.log(DEBUG , " PixelCopyStrategy is closed, ignoring capture result" )
102+ finishFrame()
93103 return @request
94104 }
95105
96106 if (copyResult != PixelCopy .SUCCESS ) {
97107 options.logger.log(INFO , " Failed to capture replay recording: %d" , copyResult)
98108 unstableCaptures.set(0 )
99109 lastCaptureSuccessful.set(false )
110+ finishFrame()
100111 return @request
101112 }
102113
103114 val changedDuringCapture = contentChanged.get()
104115 if (changedDuringCapture && shouldSkipUnstableCapture()) {
116+ finishFrame()
105117 return @request
106118 }
107119
108- // TODO: disableAllMasking here and dont traverse?
109- val viewHierarchy = ViewHierarchyNode .fromView(root, null , 0 , options.sessionReplay)
110- val surfaceViewNodes =
111- if (options.sessionReplay.isCaptureSurfaceViews) {
112- mutableListOf<ViewHierarchyNode .SurfaceViewHierarchyNode >()
113- } else {
114- null
115- }
116- root.traverse(viewHierarchy, options.sessionReplay, options.logger, surfaceViewNodes)
117-
118- if (surfaceViewNodes.isNullOrEmpty()) {
119- executor.submit(
120- ReplayRunnable (" screenshot_recorder.mask" ) {
121- applyMaskingAndNotify(
122- root,
123- viewHierarchy,
124- resetUnstableCaptures = ! changedDuringCapture,
120+ // Ensure the frame gate is always released if anything below throws before we hand
121+ // work off to the executor — otherwise a single failure wedges captures forever.
122+ var handedOff = false
123+ try {
124+ // TODO: disableAllMasking here and dont traverse?
125+ val viewHierarchy = ViewHierarchyNode .fromView(root, null , 0 , options.sessionReplay)
126+ val surfaceViewNodes =
127+ if (options.sessionReplay.isCaptureSurfaceViews) {
128+ mutableListOf<ViewHierarchyNode .SurfaceViewHierarchyNode >()
129+ } else {
130+ null
131+ }
132+ root.traverse(viewHierarchy, options.sessionReplay, options.logger, surfaceViewNodes)
133+
134+ if (surfaceViewNodes.isNullOrEmpty()) {
135+ val submitted =
136+ executor.submit(
137+ ReplayRunnable (" screenshot_recorder.mask" ) {
138+ try {
139+ applyMaskingAndNotify(
140+ root,
141+ viewHierarchy,
142+ resetUnstableCaptures = ! changedDuringCapture,
143+ )
144+ } finally {
145+ finishFrame()
146+ }
147+ }
125148 )
149+ if (submitted != null ) {
150+ handedOff = true
126151 }
127- )
128- } else {
129- // Re-arm the recorder's contentChanged gate; SurfaceView redraws don't trigger
130- // ViewTreeObserver.OnDrawListener, so we'd otherwise emit the same frame forever.
131- markContentChanged()
132- captureSurfaceViews(
133- root,
134- surfaceViewNodes,
135- viewHierarchy,
136- resetUnstableCaptures = ! changedDuringCapture,
137- )
152+ } else {
153+ // Re-arm the recorder's contentChanged gate; SurfaceView redraws don't trigger
154+ // ViewTreeObserver.OnDrawListener, so we'd otherwise emit the same frame forever.
155+ markContentChanged()
156+ captureSurfaceViews(
157+ root,
158+ surfaceViewNodes,
159+ viewHierarchy,
160+ resetUnstableCaptures = ! changedDuringCapture,
161+ )
162+ handedOff = true
163+ }
164+ } catch (e: Throwable ) {
165+ options.logger.log(WARNING , " Failed to process replay frame" , e)
166+ } finally {
167+ if (! handedOff) {
168+ finishFrame()
169+ }
138170 }
139171 },
140172 mainLooperHandler.handler,
@@ -143,6 +175,7 @@ internal class PixelCopyStrategy(
143175 options.logger.log(WARNING , " Failed to capture replay recording" , e)
144176 unstableCaptures.set(0 )
145177 lastCaptureSuccessful.set(false )
178+ finishFrame()
146179 }
147180 }
148181
@@ -272,37 +305,46 @@ internal class PixelCopyStrategy(
272305 windowY : Int ,
273306 resetUnstableCaptures : Boolean ,
274307 ) {
275- executor.submit(
276- ReplayRunnable (" screenshot_recorder.composite" ) {
277- if (isClosed.get() || screenshot.isRecycled) {
278- options.logger.log(DEBUG , " PixelCopyStrategy is closed, skipping compositing" )
279- recycleCaptures(captures)
280- return @ReplayRunnable
281- }
308+ val submitted =
309+ executor.submit(
310+ ReplayRunnable (" screenshot_recorder.composite" ) {
311+ try {
312+ if (isClosed.get() || screenshot.isRecycled) {
313+ options.logger.log(DEBUG , " PixelCopyStrategy is closed, skipping compositing" )
314+ recycleCaptures(captures)
315+ return @ReplayRunnable
316+ }
282317
283- for (capture in captures) {
284- if (capture == null ) continue
285- if (capture.bitmap.isRecycled) continue
286-
287- compositeSurfaceViewInto(
288- screenshotCanvas,
289- dstOverPaint,
290- tmpSrcRect,
291- tmpDstRect,
292- capture.bitmap,
293- capture.x,
294- capture.y,
295- windowX,
296- windowY,
297- config.scaleFactorX,
298- config.scaleFactorY,
299- )
300- capture.bitmap.recycle()
301- }
318+ for (capture in captures) {
319+ if (capture == null ) continue
320+ if (capture.bitmap.isRecycled) continue
321+
322+ compositeSurfaceViewInto(
323+ screenshotCanvas,
324+ dstOverPaint,
325+ tmpSrcRect,
326+ tmpDstRect,
327+ capture.bitmap,
328+ capture.x,
329+ capture.y,
330+ windowX,
331+ windowY,
332+ config.scaleFactorX,
333+ config.scaleFactorY,
334+ )
335+ capture.bitmap.recycle()
336+ }
302337
303- applyMaskingAndNotify(root, viewHierarchy, resetUnstableCaptures)
304- }
305- )
338+ applyMaskingAndNotify(root, viewHierarchy, resetUnstableCaptures)
339+ } finally {
340+ finishFrame()
341+ }
342+ }
343+ )
344+ if (submitted == null ) {
345+ recycleCaptures(captures)
346+ finishFrame()
347+ }
306348 }
307349
308350 private fun recycleCaptures (captures : Array <SurfaceViewCapture ?>) {
@@ -322,15 +364,31 @@ internal class PixelCopyStrategy(
322364 }
323365
324366 override fun emitLastScreenshot () {
325- if (lastCaptureSuccessful() && ! screenshot.isRecycled) {
367+ if (! frameInFlight.get() && lastCaptureSuccessful() && ! screenshot.isRecycled) {
326368 screenshotRecorderCallback?.onScreenshotRecorded(screenshot)
327369 }
328370 }
329371
330372 override fun close () {
331373 isClosed.set(true )
332374 unstableCaptures.set(0 )
333- executor.submit(
375+ if (! frameInFlight.get()) {
376+ scheduleCleanup()
377+ }
378+ }
379+
380+ private fun finishFrame () {
381+ frameInFlight.set(false )
382+ if (isClosed.get()) {
383+ scheduleCleanup()
384+ }
385+ }
386+
387+ private fun scheduleCleanup () {
388+ if (! cleanupScheduled.compareAndSet(false , true )) {
389+ return
390+ }
391+ val cleanup =
334392 ReplayRunnable (
335393 " PixelCopyStrategy.close" ,
336394 {
@@ -344,7 +402,11 @@ internal class PixelCopyStrategy(
344402 maskRenderer.close()
345403 },
346404 )
347- )
405+ // close() typically runs after ReplayIntegration has shut down the executor, so submit may
406+ // return null. Fall back to running cleanup inline so the bitmap + mask renderer are freed.
407+ if (executor.submit(cleanup) == null ) {
408+ cleanup.run ()
409+ }
348410 }
349411}
350412
0 commit comments