Skip to content

Commit b6702b0

Browse files
romtsnmarkushi
andauthored
fix(replay): Fix visual artifacts for the Canvas strategy on some devices (#4861)
* fix(replay): Fix visual artifacts for the Canvas strategy on some devices * Wrap into try-catch * Update Changelog * Remove extra background executor, as it's not needed anymore * Revert "Remove extra background executor, as it's not needed anymore" This reverts commit 835d1aa. * Use same background handler everywhere * Ensure handler.post is caught gracefully * Clean up unprocessedPictureRef --------- Co-authored-by: Markus Hintersteiner <[email protected]>
1 parent 59fa06b commit b6702b0

File tree

2 files changed

+97
-142
lines changed

2 files changed

+97
-142
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
### Fixes
66

77
- Removed SentryExecutorService limit for delayed scheduled tasks ([#4846](https://github.com/getsentry/sentry-java/pull/4846))
8+
- Fix visual artifacts for the Canvas strategy on some devices ([#4861](https://github.com/getsentry/sentry-java/pull/4861))
89

910
### Improvements
1011

sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/CanvasStrategy.kt

Lines changed: 96 additions & 142 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,18 @@ import android.graphics.NinePatch
1515
import android.graphics.Paint
1616
import android.graphics.Path
1717
import android.graphics.Picture
18-
import android.graphics.PixelFormat
1918
import android.graphics.PorterDuff
2019
import android.graphics.Rect
2120
import android.graphics.RectF
2221
import android.graphics.Region
2322
import android.graphics.RenderNode
23+
import android.graphics.SurfaceTexture
2424
import android.graphics.fonts.Font
2525
import android.graphics.text.MeasuredText
26-
import android.media.ImageReader
2726
import android.os.Build
27+
import android.os.Handler
28+
import android.view.PixelCopy
29+
import android.view.Surface
2830
import android.view.View
2931
import androidx.annotation.RequiresApi
3032
import io.sentry.SentryLevel
@@ -35,14 +37,12 @@ import io.sentry.android.replay.ScreenshotRecorderConfig
3537
import io.sentry.android.replay.util.ReplayRunnable
3638
import io.sentry.util.AutoClosableReentrantLock
3739
import io.sentry.util.IntegrationUtils
38-
import java.io.Closeable
3940
import java.util.WeakHashMap
4041
import java.util.concurrent.atomic.AtomicBoolean
4142
import java.util.concurrent.atomic.AtomicReference
4243
import kotlin.LazyThreadSafetyMode.NONE
43-
import kotlin.use
4444

45-
@SuppressLint("UseKtx")
45+
@SuppressLint("NewApi", "UseKtx")
4646
internal 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

Comments
 (0)