@@ -59,6 +59,7 @@ 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 )
6263 private val dstOverPaint by
6364 lazy(NONE ) { Paint ().apply { xfermode = PorterDuffXfermode (PorterDuff .Mode .DST_OVER ) } }
6465 private val screenshotCanvas by lazy(NONE ) { Canvas (screenshot) }
@@ -77,8 +78,15 @@ internal class PixelCopyStrategy(
7778 return
7879 }
7980
81+ if (! frameInFlight.compareAndSet(false , true )) {
82+ options.logger.log(DEBUG , " PixelCopyStrategy capture is already in flight, skipping" )
83+ markContentChanged()
84+ return
85+ }
86+
8087 if (isClosed.get()) {
8188 options.logger.log(DEBUG , " PixelCopyStrategy is closed, not capturing screenshot" )
89+ finishFrame()
8290 return
8391 }
8492
@@ -90,51 +98,72 @@ internal class PixelCopyStrategy(
9098 { copyResult: Int ->
9199 if (isClosed.get()) {
92100 options.logger.log(DEBUG , " PixelCopyStrategy is closed, ignoring capture result" )
101+ finishFrame()
93102 return @request
94103 }
95104
96105 if (copyResult != PixelCopy .SUCCESS ) {
97106 options.logger.log(INFO , " Failed to capture replay recording: %d" , copyResult)
98107 unstableCaptures.set(0 )
99108 lastCaptureSuccessful.set(false )
109+ finishFrame()
100110 return @request
101111 }
102112
103113 val changedDuringCapture = contentChanged.get()
104114 if (changedDuringCapture && shouldSkipUnstableCapture()) {
115+ finishFrame()
105116 return @request
106117 }
107118
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,
119+ // Release the frame gate if anything below throws before we hand work off to the
120+ // executor — otherwise a single failure wedges captures forever.
121+ try {
122+ // TODO: disableAllMasking here and dont traverse?
123+ val viewHierarchy = ViewHierarchyNode .fromView(root, null , 0 , options.sessionReplay)
124+ val surfaceViewNodes =
125+ if (options.sessionReplay.isCaptureSurfaceViews) {
126+ mutableListOf<ViewHierarchyNode .SurfaceViewHierarchyNode >()
127+ } else {
128+ null
129+ }
130+ root.traverse(viewHierarchy, options.sessionReplay, options.logger, surfaceViewNodes)
131+
132+ if (surfaceViewNodes.isNullOrEmpty()) {
133+ val submitted =
134+ executor.submit(
135+ ReplayRunnable (" screenshot_recorder.mask" ) {
136+ try {
137+ applyMaskingAndNotify(
138+ root,
139+ viewHierarchy,
140+ resetUnstableCaptures = ! changedDuringCapture,
141+ )
142+ } finally {
143+ finishFrame()
144+ }
145+ }
125146 )
147+ if (submitted == null ) {
148+ finishFrame()
126149 }
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- )
150+ } else {
151+ // Re-arm the recorder's contentChanged gate; SurfaceView redraws don't trigger
152+ // ViewTreeObserver.OnDrawListener, so we'd otherwise emit the same frame forever.
153+ markContentChanged()
154+ captureSurfaceViews(
155+ root,
156+ surfaceViewNodes,
157+ viewHierarchy,
158+ resetUnstableCaptures = ! changedDuringCapture,
159+ )
160+ }
161+ } catch (e: RuntimeException ) {
162+ // OEM View subclasses have been observed throwing during hierarchy traversal
163+ // (e.g. Redmi's TextView NPE). Release the frame gate so a single bad frame
164+ // doesn't wedge the recorder. Errors (OOM, LinkageError) intentionally propagate.
165+ options.logger.log(WARNING , " Failed to process replay frame" , e)
166+ finishFrame()
138167 }
139168 },
140169 mainLooperHandler.handler,
@@ -143,6 +172,7 @@ internal class PixelCopyStrategy(
143172 options.logger.log(WARNING , " Failed to capture replay recording" , e)
144173 unstableCaptures.set(0 )
145174 lastCaptureSuccessful.set(false )
175+ finishFrame()
146176 }
147177 }
148178
@@ -272,37 +302,46 @@ internal class PixelCopyStrategy(
272302 windowY : Int ,
273303 resetUnstableCaptures : Boolean ,
274304 ) {
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- }
305+ val submitted =
306+ executor.submit(
307+ ReplayRunnable (" screenshot_recorder.composite" ) {
308+ try {
309+ if (isClosed.get() || screenshot.isRecycled) {
310+ options.logger.log(DEBUG , " PixelCopyStrategy is closed, skipping compositing" )
311+ recycleCaptures(captures)
312+ return @ReplayRunnable
313+ }
282314
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- }
315+ for (capture in captures) {
316+ if (capture == null ) continue
317+ if (capture.bitmap.isRecycled) continue
318+
319+ compositeSurfaceViewInto(
320+ screenshotCanvas,
321+ dstOverPaint,
322+ tmpSrcRect,
323+ tmpDstRect,
324+ capture.bitmap,
325+ capture.x,
326+ capture.y,
327+ windowX,
328+ windowY,
329+ config.scaleFactorX,
330+ config.scaleFactorY,
331+ )
332+ capture.bitmap.recycle()
333+ }
302334
303- applyMaskingAndNotify(root, viewHierarchy, resetUnstableCaptures)
304- }
305- )
335+ applyMaskingAndNotify(root, viewHierarchy, resetUnstableCaptures)
336+ } finally {
337+ finishFrame()
338+ }
339+ }
340+ )
341+ if (submitted == null ) {
342+ recycleCaptures(captures)
343+ finishFrame()
344+ }
306345 }
307346
308347 private fun recycleCaptures (captures : Array <SurfaceViewCapture ?>) {
@@ -322,15 +361,57 @@ internal class PixelCopyStrategy(
322361 }
323362
324363 override fun emitLastScreenshot () {
325- if (lastCaptureSuccessful() && ! screenshot.isRecycled) {
326- screenshotRecorderCallback?.onScreenshotRecorded(screenshot)
364+ if (! frameInFlight.compareAndSet(false , true )) {
365+ return
366+ }
367+ if (! lastCaptureSuccessful() || screenshot.isRecycled) {
368+ finishFrame()
369+ return
370+ }
371+ // Submit to the executor so the downstream consumer's bitmap read (JPEG compress) runs inline
372+ // on the worker thread while the gate is held, same as the masked capture path.
373+ val submitted =
374+ executor.submit(
375+ ReplayRunnable (" PixelCopyStrategy.emit" ) {
376+ try {
377+ screenshotRecorderCallback?.onScreenshotRecorded(screenshot)
378+ } finally {
379+ finishFrame()
380+ }
381+ }
382+ )
383+ if (submitted == null ) {
384+ finishFrame()
327385 }
328386 }
329387
330388 override fun close () {
331389 isClosed.set(true )
332390 unstableCaptures.set(0 )
333- executor.submit(
391+ cleanUpIfIdle()
392+ }
393+
394+ private fun finishFrame () {
395+ frameInFlight.set(false )
396+ if (isClosed.get()) {
397+ cleanUpIfIdle()
398+ }
399+ }
400+
401+ /* *
402+ * Schedules cleanup only for the caller that owns the gate. Whoever wins [frameInFlight]'s CAS
403+ * (close when no frame is running, or the finishFrame of the last in-flight frame after close)
404+ * runs cleanup exactly once; a racing capture that took the gate loses the CAS and backs off, so
405+ * we never recycle the shared screenshot while that capture is still using it.
406+ */
407+ private fun cleanUpIfIdle () {
408+ if (frameInFlight.compareAndSet(false , true )) {
409+ scheduleCleanup()
410+ }
411+ }
412+
413+ private fun scheduleCleanup () {
414+ val cleanup =
334415 ReplayRunnable (
335416 " PixelCopyStrategy.close" ,
336417 {
@@ -344,7 +425,12 @@ internal class PixelCopyStrategy(
344425 maskRenderer.close()
345426 },
346427 )
347- )
428+ // ReplayExecutorService.submit returns null only on genuine rejection (post-shutdown);
429+ // inline execution on the worker thread returns a completed future. Fall back to running
430+ // cleanup here so the bitmap + mask renderer are freed even when the executor is dead.
431+ if (executor.submit(cleanup) == null ) {
432+ cleanup.run ()
433+ }
348434 }
349435}
350436
0 commit comments