@@ -15,16 +15,18 @@ import android.graphics.NinePatch
1515import android.graphics.Paint
1616import android.graphics.Path
1717import android.graphics.Picture
18- import android.graphics.PixelFormat
1918import android.graphics.PorterDuff
2019import android.graphics.Rect
2120import android.graphics.RectF
2221import android.graphics.Region
2322import android.graphics.RenderNode
23+ import android.graphics.SurfaceTexture
2424import android.graphics.fonts.Font
2525import android.graphics.text.MeasuredText
26- import android.media.ImageReader
2726import android.os.Build
27+ import android.os.Handler
28+ import android.view.PixelCopy
29+ import android.view.Surface
2830import android.view.View
2931import androidx.annotation.RequiresApi
3032import io.sentry.SentryLevel
@@ -35,14 +37,12 @@ import io.sentry.android.replay.ScreenshotRecorderConfig
3537import io.sentry.android.replay.util.ReplayRunnable
3638import io.sentry.util.AutoClosableReentrantLock
3739import io.sentry.util.IntegrationUtils
38- import java.io.Closeable
3940import java.util.WeakHashMap
4041import java.util.concurrent.atomic.AtomicBoolean
4142import java.util.concurrent.atomic.AtomicReference
4243import kotlin.LazyThreadSafetyMode.NONE
43- import kotlin.use
4444
45- @SuppressLint(" UseKtx" )
45+ @SuppressLint(" NewApi " , " UseKtx" )
4646internal class CanvasStrategy (
4747 private val executor : ExecutorProvider ,
4848 private val screenshotRecorderCallback : ScreenshotRecorderCallback ? ,
@@ -51,73 +51,19 @@ internal class CanvasStrategy(
5151) : ScreenshotStrategy {
5252
5353 @Volatile private var screenshot: Bitmap ? = null
54-
55- // Lock to synchronize screenshot creation
54+ private var unprocessedPictureRef = AtomicReference <Picture >(null )
5655 private val screenshotLock = AutoClosableReentrantLock ()
5756 private val prescaledMatrix by
5857 lazy(NONE ) { Matrix ().apply { preScale(config.scaleFactorX, config.scaleFactorY) } }
5958 private val lastCaptureSuccessful = AtomicBoolean (false )
6059 private val textIgnoringCanvas = TextIgnoringDelegateCanvas ()
61-
6260 private val isClosed = AtomicBoolean (false )
6361
64- private val onImageAvailableListener: (holder: PictureReaderHolder ) -> Unit = { holder ->
65- if (isClosed.get()) {
66- options.logger.log(SentryLevel .ERROR , " CanvasStrategy already closed, skipping image" )
67- holder.close()
68- } else {
69- try {
70- val image = holder.reader.acquireLatestImage()
71- try {
72- if (image.planes.size > 0 ) {
73- val plane = image.planes[0 ]
74-
75- if (screenshot == null ) {
76- screenshotLock.acquire().use {
77- if (screenshot == null ) {
78- screenshot =
79- Bitmap .createBitmap(holder.width, holder.height, Bitmap .Config .ARGB_8888 )
80- }
81- }
82- }
83-
84- val bitmap = screenshot
85- if (bitmap != null ) {
86- val buffer = plane.buffer.rewind()
87- synchronized(bitmap) {
88- if (! bitmap.isRecycled) {
89- bitmap.copyPixelsFromBuffer(buffer)
90- lastCaptureSuccessful.set(true )
91- }
92- }
93- screenshotRecorderCallback?.onScreenshotRecorded(bitmap)
94- }
95- }
96- } finally {
97- try {
98- image.close()
99- } catch (_: Throwable ) {
100- // ignored
101- }
102- }
103- } catch (e: Throwable ) {
104- options.logger.log(SentryLevel .ERROR , " CanvasStrategy: image processing failed" , e)
105- } finally {
106- if (isClosed.get()) {
107- holder.close()
108- } else {
109- freePictureRef.set(holder)
110- }
111- }
62+ private val surfaceTexture =
63+ SurfaceTexture (false ).apply {
64+ setDefaultBufferSize(config.recordingWidth, config.recordingHeight)
11265 }
113- }
114-
115- private var freePictureRef =
116- AtomicReference (
117- PictureReaderHolder (config.recordingWidth, config.recordingHeight, onImageAvailableListener)
118- )
119-
120- private var unprocessedPictureRef = AtomicReference <PictureReaderHolder >(null )
66+ private val surface = Surface (surfaceTexture)
12167
12268 init {
12369 IntegrationUtils .addIntegrationToSdkVersion(" ReplayCanvasStrategy" )
@@ -132,54 +78,89 @@ internal class CanvasStrategy(
13278 )
13379 return @Runnable
13480 }
135- val holder = unprocessedPictureRef.getAndSet(null ) ? : return @Runnable
136-
81+ val picture = unprocessedPictureRef.getAndSet(null ) ? : return @Runnable
13782 try {
138- if (! holder.setup.getAndSet(true )) {
139- holder.reader.setOnImageAvailableListener(holder, executor.getBackgroundHandler())
140- }
141-
142- val surface = holder.reader.surface
143- val canvas = surface.lockHardwareCanvas()
83+ // It's safe to access the surface because the
84+ // surface release within close() is executed on the same background handler
85+ val surfaceCanvas = surface.lockHardwareCanvas()
14486 try {
145- canvas .drawColor(Color .BLACK , PorterDuff .Mode .CLEAR )
146- holder. picture.draw(canvas )
87+ surfaceCanvas .drawColor(Color .BLACK , PorterDuff .Mode .CLEAR )
88+ picture.draw(surfaceCanvas )
14789 } finally {
148- surface.unlockCanvasAndPost(canvas )
90+ surface.unlockCanvasAndPost(surfaceCanvas )
14991 }
150- } catch (t: Throwable ) {
92+
93+ if (screenshot == null ) {
94+ screenshotLock.acquire().use {
95+ if (screenshot == null ) {
96+ screenshot =
97+ Bitmap .createBitmap(
98+ config.recordingWidth,
99+ config.recordingHeight,
100+ Bitmap .Config .ARGB_8888 ,
101+ )
102+ }
103+ }
104+ }
105+
151106 if (isClosed.get()) {
152- holder.close()
153- } else {
154- freePictureRef.set(holder)
107+ options.logger.log(
108+ SentryLevel .DEBUG ,
109+ " Canvas Strategy already closed, skipping pixel copy request" ,
110+ )
111+ return @Runnable
155112 }
113+ PixelCopy .request(
114+ surface,
115+ screenshot!! ,
116+ { result ->
117+ if (isClosed.get()) {
118+ options.logger.log(
119+ SentryLevel .DEBUG ,
120+ " CanvasStrategy is closed, ignoring capture result" ,
121+ )
122+ return @request
123+ }
124+ if (result == PixelCopy .SUCCESS ) {
125+ lastCaptureSuccessful.set(true )
126+ val bitmap = screenshot
127+ if (bitmap != null && ! bitmap.isRecycled) {
128+ screenshotRecorderCallback?.onScreenshotRecorded(bitmap)
129+ }
130+ } else {
131+ options.logger.log(
132+ SentryLevel .ERROR ,
133+ " Canvas Strategy: PixelCopy failed with code $result " ,
134+ )
135+ lastCaptureSuccessful.set(false )
136+ }
137+ },
138+ executor.getBackgroundHandler(),
139+ )
140+ } catch (t: Throwable ) {
156141 options.logger.log(SentryLevel .ERROR , " Canvas Strategy: picture render failed" , t)
142+ lastCaptureSuccessful.set(false )
157143 }
158144 }
159145
160- @SuppressLint(" UnclosedTrace " )
146+ @SuppressLint(" NewApi " )
161147 override fun capture (root : View ) {
162148 if (isClosed.get()) {
163149 return
164150 }
165- val holder = freePictureRef.getAndSet(null )
166- if (holder == null ) {
167- options.logger.log(SentryLevel .DEBUG , " No free Picture available, skipping capture" )
168- lastCaptureSuccessful.set(false )
169- return
170- }
171151
172- val pictureCanvas = holder.picture.beginRecording(config.recordingWidth, config.recordingHeight)
173- textIgnoringCanvas.delegate = pictureCanvas
152+ val picture = Picture ()
153+ val canvas = picture.beginRecording(config.recordingWidth, config.recordingHeight)
154+ textIgnoringCanvas.delegate = canvas
174155 textIgnoringCanvas.setMatrix(prescaledMatrix)
175156 root.draw(textIgnoringCanvas)
176- holder. picture.endRecording()
157+ picture.endRecording()
177158
178- if (isClosed.get()) {
179- holder.close( )
180- } else {
181- unprocessedPictureRef.set(holder )
182- executor.getExecutor().submit (ReplayRunnable (" screenshot_recorder.canvas" , pictureRenderTask))
159+ if (! isClosed.get()) {
160+ unprocessedPictureRef.set(picture )
161+ executor
162+ .getBackgroundHandler( )
163+ .postSafely (ReplayRunnable (" screenshot_recorder.canvas" , pictureRenderTask))
183164 }
184165 }
185166
@@ -190,30 +171,18 @@ internal class CanvasStrategy(
190171 override fun close () {
191172 isClosed.set(true )
192173 executor
193- .getExecutor()
194- .submit(
195- ReplayRunnable (
196- " CanvasStrategy.close" ,
197- {
198- screenshot?.let {
199- synchronized(it) {
200- if (! it.isRecycled) {
201- it.recycle()
202- }
203- }
204- }
205- },
206- )
174+ .getBackgroundHandler()
175+ .postSafely(
176+ ReplayRunnable (" CanvasStrategy.close" ) {
177+ screenshot?.let { synchronized(it) { if (! it.isRecycled) it.recycle() } }
178+ surface.release()
179+ surfaceTexture.release()
180+ }
207181 )
208-
209- // the image can be free, unprocessed or in transit
210- freePictureRef.getAndSet(null )?.reader?.close()
211- unprocessedPictureRef.getAndSet(null )?.reader?.close()
182+ unprocessedPictureRef.getAndSet(null )
212183 }
213184
214- override fun lastCaptureSuccessful (): Boolean {
215- return lastCaptureSuccessful.get()
216- }
185+ override fun lastCaptureSuccessful (): Boolean = lastCaptureSuccessful.get()
217186
218187 override fun emitLastScreenshot () {
219188 if (lastCaptureSuccessful()) {
@@ -223,6 +192,18 @@ internal class CanvasStrategy(
223192 }
224193 }
225194 }
195+
196+ fun Handler.postSafely (runnable : ReplayRunnable ) {
197+ try {
198+ post(runnable)
199+ } catch (t: Throwable ) {
200+ options.logger.log(
201+ SentryLevel .ERROR ,
202+ " Canvas Strategy: failed to post runnable ${runnable.taskName} " ,
203+ t,
204+ )
205+ }
206+ }
226207}
227208
228209@SuppressLint(" UseKtx" )
@@ -1031,30 +1012,3 @@ private class TextIgnoringDelegateCanvas : Canvas() {
10311012 }
10321013 }
10331014}
1034-
1035- private class PictureReaderHolder (
1036- val width : Int ,
1037- val height : Int ,
1038- val listener : (holder: PictureReaderHolder ) -> Unit ,
1039- ) : ImageReader.OnImageAvailableListener, Closeable {
1040- val picture = Picture ()
1041-
1042- @SuppressLint(" InlinedApi" )
1043- val reader: ImageReader = ImageReader .newInstance(width, height, PixelFormat .RGBA_8888 , 1 )
1044-
1045- var setup = AtomicBoolean (false )
1046-
1047- override fun onImageAvailable (reader : ImageReader ? ) {
1048- if (reader != null ) {
1049- listener(this )
1050- }
1051- }
1052-
1053- override fun close () {
1054- try {
1055- reader.close()
1056- } catch (_: Throwable ) {
1057- // ignored
1058- }
1059- }
1060- }
0 commit comments