-
-
Notifications
You must be signed in to change notification settings - Fork 462
fix(replay): Fix visual artifacts for the Canvas strategy on some devices #4861
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
5dd2b60
ae3dda4
8ad5eb0
835d1aa
3ee6ab6
2bd3de4
3eb8f20
03a7871
c470a4b
1c9adf7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,16 +15,17 @@ import android.graphics.NinePatch | |
| import android.graphics.Paint | ||
| import android.graphics.Path | ||
| import android.graphics.Picture | ||
| import android.graphics.PixelFormat | ||
| import android.graphics.PorterDuff | ||
| import android.graphics.Rect | ||
| import android.graphics.RectF | ||
| import android.graphics.Region | ||
| import android.graphics.RenderNode | ||
| import android.graphics.SurfaceTexture | ||
| import android.graphics.fonts.Font | ||
| import android.graphics.text.MeasuredText | ||
| import android.media.ImageReader | ||
| import android.os.Build | ||
| import android.view.PixelCopy | ||
| import android.view.Surface | ||
| import android.view.View | ||
| import androidx.annotation.RequiresApi | ||
| import io.sentry.SentryLevel | ||
|
|
@@ -35,14 +36,12 @@ import io.sentry.android.replay.ScreenshotRecorderConfig | |
| import io.sentry.android.replay.util.ReplayRunnable | ||
| import io.sentry.util.AutoClosableReentrantLock | ||
| import io.sentry.util.IntegrationUtils | ||
| import java.io.Closeable | ||
| import java.util.WeakHashMap | ||
| import java.util.concurrent.atomic.AtomicBoolean | ||
| import java.util.concurrent.atomic.AtomicReference | ||
| import kotlin.LazyThreadSafetyMode.NONE | ||
| import kotlin.use | ||
|
|
||
| @SuppressLint("UseKtx") | ||
| @SuppressLint("NewApi", "UseKtx") | ||
| internal class CanvasStrategy( | ||
| private val executor: ExecutorProvider, | ||
| private val screenshotRecorderCallback: ScreenshotRecorderCallback?, | ||
|
|
@@ -51,73 +50,19 @@ internal class CanvasStrategy( | |
| ) : ScreenshotStrategy { | ||
|
|
||
| @Volatile private var screenshot: Bitmap? = null | ||
|
|
||
| // Lock to synchronize screenshot creation | ||
| private var unprocessedPictureRef = AtomicReference<Picture>(null) | ||
| private val screenshotLock = AutoClosableReentrantLock() | ||
| private val prescaledMatrix by | ||
| lazy(NONE) { Matrix().apply { preScale(config.scaleFactorX, config.scaleFactorY) } } | ||
| private val lastCaptureSuccessful = AtomicBoolean(false) | ||
| private val textIgnoringCanvas = TextIgnoringDelegateCanvas() | ||
|
|
||
| private val isClosed = AtomicBoolean(false) | ||
|
|
||
| private val onImageAvailableListener: (holder: PictureReaderHolder) -> Unit = { holder -> | ||
| if (isClosed.get()) { | ||
| options.logger.log(SentryLevel.ERROR, "CanvasStrategy already closed, skipping image") | ||
| holder.close() | ||
| } else { | ||
| try { | ||
| val image = holder.reader.acquireLatestImage() | ||
| try { | ||
| if (image.planes.size > 0) { | ||
| val plane = image.planes[0] | ||
|
|
||
| if (screenshot == null) { | ||
| screenshotLock.acquire().use { | ||
| if (screenshot == null) { | ||
| screenshot = | ||
| Bitmap.createBitmap(holder.width, holder.height, Bitmap.Config.ARGB_8888) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| val bitmap = screenshot | ||
| if (bitmap != null) { | ||
| val buffer = plane.buffer.rewind() | ||
| synchronized(bitmap) { | ||
| if (!bitmap.isRecycled) { | ||
| bitmap.copyPixelsFromBuffer(buffer) | ||
| lastCaptureSuccessful.set(true) | ||
| } | ||
| } | ||
| screenshotRecorderCallback?.onScreenshotRecorded(bitmap) | ||
| } | ||
| } | ||
| } finally { | ||
| try { | ||
| image.close() | ||
| } catch (_: Throwable) { | ||
| // ignored | ||
| } | ||
| } | ||
| } catch (e: Throwable) { | ||
| options.logger.log(SentryLevel.ERROR, "CanvasStrategy: image processing failed", e) | ||
| } finally { | ||
| if (isClosed.get()) { | ||
| holder.close() | ||
| } else { | ||
| freePictureRef.set(holder) | ||
| } | ||
| } | ||
| private val surfaceTexture = | ||
| SurfaceTexture(false).apply { | ||
| setDefaultBufferSize(config.recordingWidth, config.recordingHeight) | ||
| } | ||
| } | ||
|
|
||
| private var freePictureRef = | ||
| AtomicReference( | ||
| PictureReaderHolder(config.recordingWidth, config.recordingHeight, onImageAvailableListener) | ||
| ) | ||
|
|
||
| private var unprocessedPictureRef = AtomicReference<PictureReaderHolder>(null) | ||
| private val surface = Surface(surfaceTexture) | ||
|
|
||
| init { | ||
| IntegrationUtils.addIntegrationToSdkVersion("ReplayCanvasStrategy") | ||
|
|
@@ -132,54 +77,72 @@ internal class CanvasStrategy( | |
| ) | ||
| return@Runnable | ||
| } | ||
| val holder = unprocessedPictureRef.getAndSet(null) ?: return@Runnable | ||
| val picture = unprocessedPictureRef.getAndSet(null) ?: return@Runnable | ||
|
|
||
| try { | ||
| if (!holder.setup.getAndSet(true)) { | ||
| holder.reader.setOnImageAvailableListener(holder, executor.getBackgroundHandler()) | ||
| } | ||
|
|
||
| val surface = holder.reader.surface | ||
| val canvas = surface.lockHardwareCanvas() | ||
| // Draw picture to the Surface for PixelCopy | ||
| val surfaceCanvas = surface.lockHardwareCanvas() | ||
| try { | ||
| canvas.drawColor(Color.BLACK, PorterDuff.Mode.CLEAR) | ||
| holder.picture.draw(canvas) | ||
| surfaceCanvas.drawColor(Color.BLACK, PorterDuff.Mode.CLEAR) | ||
| picture.draw(surfaceCanvas) | ||
| } finally { | ||
| surface.unlockCanvasAndPost(canvas) | ||
| surface.unlockCanvasAndPost(surfaceCanvas) | ||
| } | ||
| } catch (t: Throwable) { | ||
| if (isClosed.get()) { | ||
| holder.close() | ||
| } else { | ||
| freePictureRef.set(holder) | ||
|
|
||
| if (screenshot == null) { | ||
| screenshotLock.acquire().use { | ||
| if (screenshot == null) { | ||
| screenshot = Bitmap.createBitmap(picture.width, picture.height, Bitmap.Config.ARGB_8888) | ||
| } | ||
| } | ||
| } | ||
| options.logger.log(SentryLevel.ERROR, "Canvas Strategy: picture render failed", t) | ||
|
|
||
| // Trigger PixelCopy capture | ||
| PixelCopy.request( | ||
| surface, | ||
| screenshot!!, | ||
| { result -> | ||
| if (result == PixelCopy.SUCCESS) { | ||
| lastCaptureSuccessful.set(true) | ||
| val bitmap = screenshot | ||
| if (bitmap != null && !bitmap.isRecycled) { | ||
| screenshotRecorderCallback?.onScreenshotRecorded(bitmap) | ||
| } | ||
|
Comment on lines
116
to
129
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: Unsynchronized access to 🔍 Detailed AnalysisThe 💡 Suggested FixProtect all accesses to the 🤖 Prompt for AI AgentDid we get this right? 👍 / 👎 to inform future reviews. |
||
| } else { | ||
| options.logger.log( | ||
| SentryLevel.ERROR, | ||
| "Canvas Strategy: PixelCopy failed with code $result", | ||
| ) | ||
| lastCaptureSuccessful.set(false) | ||
| } | ||
| }, | ||
| executor.getBackgroundHandler(), | ||
| ) | ||
markushi marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } catch (t: Throwable) { | ||
| options.logger.log(SentryLevel.ERROR, "Canvas Strategy: picture render failed") | ||
| lastCaptureSuccessful.set(false) | ||
markushi marked this conversation as resolved.
Show resolved
Hide resolved
markushi marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
| } | ||
|
|
||
| @SuppressLint("UnclosedTrace") | ||
| @SuppressLint("NewApi") | ||
| override fun capture(root: View) { | ||
| if (isClosed.get()) { | ||
| return | ||
| } | ||
| val holder = freePictureRef.getAndSet(null) | ||
| if (holder == null) { | ||
| options.logger.log(SentryLevel.DEBUG, "No free Picture available, skipping capture") | ||
| lastCaptureSuccessful.set(false) | ||
| return | ||
| } | ||
|
|
||
| val pictureCanvas = holder.picture.beginRecording(config.recordingWidth, config.recordingHeight) | ||
| textIgnoringCanvas.delegate = pictureCanvas | ||
| val picture = Picture() | ||
| val canvas = picture.beginRecording(config.recordingWidth, config.recordingHeight) | ||
| textIgnoringCanvas.delegate = canvas | ||
| textIgnoringCanvas.setMatrix(prescaledMatrix) | ||
| root.draw(textIgnoringCanvas) | ||
| holder.picture.endRecording() | ||
|
|
||
| if (isClosed.get()) { | ||
| holder.close() | ||
| } else { | ||
| unprocessedPictureRef.set(holder) | ||
| executor.getExecutor().submit(ReplayRunnable("screenshot_recorder.canvas", pictureRenderTask)) | ||
| picture.endRecording() | ||
|
|
||
| if (!isClosed.get()) { | ||
| unprocessedPictureRef.set(picture) | ||
| // use the same handler for PixelCopy and pictureRenderTask | ||
| executor | ||
| .getBackgroundHandler() | ||
| .post(ReplayRunnable("screenshot_recorder.canvas", pictureRenderTask)) | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -192,28 +155,15 @@ internal class CanvasStrategy( | |
| executor | ||
| .getExecutor() | ||
| .submit( | ||
| ReplayRunnable( | ||
| "CanvasStrategy.close", | ||
| { | ||
| screenshot?.let { | ||
| synchronized(it) { | ||
| if (!it.isRecycled) { | ||
| it.recycle() | ||
| } | ||
| } | ||
| } | ||
| }, | ||
| ) | ||
| ReplayRunnable("CanvasStrategy.close") { | ||
| screenshot?.let { synchronized(it) { if (!it.isRecycled) it.recycle() } } | ||
| surface.release() | ||
| surfaceTexture.release() | ||
| } | ||
| ) | ||
|
|
||
| // the image can be free, unprocessed or in transit | ||
| freePictureRef.getAndSet(null)?.reader?.close() | ||
| unprocessedPictureRef.getAndSet(null)?.reader?.close() | ||
| } | ||
|
|
||
| override fun lastCaptureSuccessful(): Boolean { | ||
| return lastCaptureSuccessful.get() | ||
| } | ||
| override fun lastCaptureSuccessful(): Boolean = lastCaptureSuccessful.get() | ||
|
|
||
| override fun emitLastScreenshot() { | ||
| if (lastCaptureSuccessful()) { | ||
|
|
@@ -1031,30 +981,3 @@ private class TextIgnoringDelegateCanvas : Canvas() { | |
| } | ||
| } | ||
| } | ||
|
|
||
| private class PictureReaderHolder( | ||
| val width: Int, | ||
| val height: Int, | ||
| val listener: (holder: PictureReaderHolder) -> Unit, | ||
| ) : ImageReader.OnImageAvailableListener, Closeable { | ||
| val picture = Picture() | ||
|
|
||
| @SuppressLint("InlinedApi") | ||
| val reader: ImageReader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 1) | ||
|
|
||
| var setup = AtomicBoolean(false) | ||
|
|
||
| override fun onImageAvailable(reader: ImageReader?) { | ||
| if (reader != null) { | ||
| listener(this) | ||
| } | ||
| } | ||
|
|
||
| override fun close() { | ||
| try { | ||
| reader.close() | ||
| } catch (_: Throwable) { | ||
| // ignored | ||
| } | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.