diff --git a/CHANGELOG.md b/CHANGELOG.md
index 10ab3f0448..3bd989eafd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,22 @@
- Note that keys containing spaces are not supported.
- Add experimental Sentry Android Distribution module for integrating with Sentry Build Distribution to check for and install updates ([#4804](https://github.com/getsentry/sentry-java/pull/4804))
- Allow passing a different `Handler` to `SystemEventsBreadcrumbsIntegration` and `AndroidConnectionStatusProvider` so their callbacks are deliver to that handler ([#4808](https://github.com/getsentry/sentry-java/pull/4808))
+- Session Replay: Add new _experimental_ Canvas Capture Strategy ([#4777](https://github.com/getsentry/sentry-java/pull/4777))
+ - A new screenshot capture strategy that uses Android's Canvas API for more accurate and reliable text and image masking
+ - Any `.drawText()` or `.drawBitmap()` calls are replaced by rectangles, ensuring no text or images are present in the resulting output
+ - Note: If this strategy is used, all text and images will be masked, regardless of any masking configuration
+ - To enable this feature, set the `screenshotStrategy`, either via code:
+ ```kotlin
+ SentryAndroid.init(context) { options ->
+ options.sessionReplay.screenshotStrategy = ScreenshotStrategyType.CANVAS
+ }
+ ```
+ or AndroidManifest.xml:
+ ```xml
+
+
+
+ ```
### Fixes
diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java
index 3de708ce34..a71ec1cb76 100644
--- a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java
+++ b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java
@@ -6,6 +6,7 @@
import io.sentry.ILogger;
import io.sentry.InitPriority;
import io.sentry.ProfileLifecycle;
+import io.sentry.ScreenshotStrategyType;
import io.sentry.SentryFeedbackOptions;
import io.sentry.SentryIntegrationPackageStorage;
import io.sentry.SentryLevel;
@@ -111,6 +112,7 @@ final class ManifestMetadataReader {
static final String REPLAYS_MASK_ALL_IMAGES = "io.sentry.session-replay.mask-all-images";
static final String REPLAYS_DEBUG = "io.sentry.session-replay.debug";
+ static final String REPLAYS_SCREENSHOT_STRATEGY = "io.sentry.session-replay.screenshot-strategy";
static final String FORCE_INIT = "io.sentry.force-init";
@@ -476,6 +478,16 @@ static void applyMetadata(
options.getSessionReplay().setDebug(readBool(metadata, logger, REPLAYS_DEBUG, false));
+ final @Nullable String screenshotStrategyRaw =
+ readString(metadata, logger, REPLAYS_SCREENSHOT_STRATEGY, null);
+ if (screenshotStrategyRaw != null) {
+ if ("canvas".equals(screenshotStrategyRaw.toLowerCase(Locale.ROOT))) {
+ options.getSessionReplay().setScreenshotStrategy(ScreenshotStrategyType.CANVAS);
+ } else {
+ // always default to PIXEL_COPY
+ options.getSessionReplay().setScreenshotStrategy(ScreenshotStrategyType.PIXEL_COPY);
+ }
+ }
options.setIgnoredErrors(readList(metadata, logger, IGNORED_ERRORS));
final @Nullable List includes = readList(metadata, logger, IN_APP_INCLUDES);
diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt
index 5ad197d829..3c94f0abf2 100644
--- a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt
+++ b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt
@@ -1835,4 +1835,51 @@ class ManifestMetadataReaderTest {
// Assert
assertFalse(fixture.options.feedbackOptions.isShowBranding)
}
+
+ @Test
+ fun `applyMetadata reads screenshot strategy canvas to options`() {
+ // Arrange
+ val bundle = bundleOf(ManifestMetadataReader.REPLAYS_SCREENSHOT_STRATEGY to "canvas")
+ val context = fixture.getContext(metaData = bundle)
+
+ // Act
+ ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider)
+
+ // Assert
+ assertEquals(
+ io.sentry.ScreenshotStrategyType.CANVAS,
+ fixture.options.sessionReplay.screenshotStrategy,
+ )
+ }
+
+ @Test
+ fun `applyMetadata reads screenshot strategy and defaults to PIXEL_COPY for unknown value`() {
+ // Arrange
+ val bundle = bundleOf(ManifestMetadataReader.REPLAYS_SCREENSHOT_STRATEGY to "unknown")
+ val context = fixture.getContext(metaData = bundle)
+
+ // Act
+ ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider)
+
+ // Assert
+ assertEquals(
+ io.sentry.ScreenshotStrategyType.PIXEL_COPY,
+ fixture.options.sessionReplay.screenshotStrategy,
+ )
+ }
+
+ @Test
+ fun `applyMetadata reads screenshot strategy and keeps default if not found`() {
+ // Arrange
+ val context = fixture.getContext()
+
+ // Act
+ ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider)
+
+ // Assert
+ assertEquals(
+ io.sentry.ScreenshotStrategyType.PIXEL_COPY,
+ fixture.options.sessionReplay.screenshotStrategy,
+ )
+ }
}
diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt
index 3bc995fad2..98b4a3f916 100644
--- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt
+++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt
@@ -4,35 +4,22 @@ import android.annotation.SuppressLint
import android.annotation.TargetApi
import android.content.Context
import android.graphics.Bitmap
-import android.graphics.Canvas
-import android.graphics.Color
-import android.graphics.Matrix
-import android.graphics.Paint
-import android.graphics.Rect
-import android.graphics.RectF
-import android.view.PixelCopy
import android.view.View
import android.view.ViewTreeObserver
+import io.sentry.ScreenshotStrategyType
import io.sentry.SentryLevel.DEBUG
-import io.sentry.SentryLevel.INFO
import io.sentry.SentryLevel.WARNING
import io.sentry.SentryOptions
import io.sentry.SentryReplayOptions
+import io.sentry.android.replay.screenshot.CanvasStrategy
+import io.sentry.android.replay.screenshot.PixelCopyStrategy
+import io.sentry.android.replay.screenshot.ScreenshotStrategy
import io.sentry.android.replay.util.DebugOverlayDrawable
-import io.sentry.android.replay.util.MainLooperHandler
import io.sentry.android.replay.util.addOnDrawListenerSafe
-import io.sentry.android.replay.util.getVisibleRects
import io.sentry.android.replay.util.removeOnDrawListenerSafe
-import io.sentry.android.replay.util.submitSafely
-import io.sentry.android.replay.util.traverse
-import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode
-import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode
-import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode
import java.io.File
import java.lang.ref.WeakReference
-import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.atomic.AtomicBoolean
-import kotlin.LazyThreadSafetyMode.NONE
import kotlin.math.roundToInt
@SuppressLint("UseKtx")
@@ -40,24 +27,28 @@ import kotlin.math.roundToInt
internal class ScreenshotRecorder(
val config: ScreenshotRecorderConfig,
val options: SentryOptions,
- private val mainLooperHandler: MainLooperHandler,
- private val recorder: ScheduledExecutorService,
- private val screenshotRecorderCallback: ScreenshotRecorderCallback?,
+ val executorProvider: ExecutorProvider,
+ screenshotRecorderCallback: ScreenshotRecorderCallback?,
) : ViewTreeObserver.OnDrawListener {
private var rootView: WeakReference? = null
- private val maskingPaint by lazy(NONE) { Paint() }
- private val singlePixelBitmap: Bitmap by
- lazy(NONE) { Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) }
- private val screenshot =
- Bitmap.createBitmap(config.recordingWidth, config.recordingHeight, Bitmap.Config.ARGB_8888)
- private val singlePixelBitmapCanvas: Canvas by lazy(NONE) { Canvas(singlePixelBitmap) }
- private val prescaledMatrix by
- lazy(NONE) { Matrix().apply { preScale(config.scaleFactorX, config.scaleFactorY) } }
- private val contentChanged = AtomicBoolean(false)
private val isCapturing = AtomicBoolean(true)
- private val lastCaptureSuccessful = AtomicBoolean(false)
private val debugOverlayDrawable = DebugOverlayDrawable()
+ private val contentChanged = AtomicBoolean(false)
+
+ private val screenshotStrategy: ScreenshotStrategy =
+ when (options.sessionReplay.screenshotStrategy) {
+ ScreenshotStrategyType.CANVAS ->
+ CanvasStrategy(executorProvider, screenshotRecorderCallback, options, config)
+ ScreenshotStrategyType.PIXEL_COPY ->
+ PixelCopyStrategy(
+ executorProvider,
+ screenshotRecorderCallback,
+ options,
+ config,
+ debugOverlayDrawable,
+ )
+ }
fun capture() {
if (options.sessionReplay.isDebug) {
@@ -75,12 +66,12 @@ internal class ScreenshotRecorder(
DEBUG,
"Capturing screenshot, contentChanged: %s, lastCaptureSuccessful: %s",
contentChanged.get(),
- lastCaptureSuccessful.get(),
+ screenshotStrategy.lastCaptureSuccessful(),
)
}
- if (!contentChanged.get() && lastCaptureSuccessful.get()) {
- screenshotRecorderCallback?.onScreenshotRecorded(screenshot)
+ if (!contentChanged.get()) {
+ screenshotStrategy.emitLastScreenshot()
return
}
@@ -98,93 +89,9 @@ internal class ScreenshotRecorder(
try {
contentChanged.set(false)
- PixelCopy.request(
- window,
- screenshot,
- { copyResult: Int ->
- if (copyResult != PixelCopy.SUCCESS) {
- options.logger.log(INFO, "Failed to capture replay recording: %d", copyResult)
- lastCaptureSuccessful.set(false)
- return@request
- }
-
- // TODO: handle animations with heuristics (e.g. if we fall under this condition 2 times
- // in a row, we should capture)
- if (contentChanged.get()) {
- options.logger.log(INFO, "Failed to determine view hierarchy, not capturing")
- lastCaptureSuccessful.set(false)
- return@request
- }
-
- // TODO: disableAllMasking here and dont traverse?
- val viewHierarchy = ViewHierarchyNode.fromView(root, null, 0, options)
- root.traverse(viewHierarchy, options)
-
- recorder.submitSafely(options, "screenshot_recorder.mask") {
- val debugMasks = mutableListOf()
-
- val canvas = Canvas(screenshot)
- canvas.setMatrix(prescaledMatrix)
- viewHierarchy.traverse { node ->
- if (node.shouldMask && (node.width > 0 && node.height > 0)) {
- node.visibleRect ?: return@traverse false
-
- // TODO: investigate why it returns true on RN when it shouldn't
- // if (viewHierarchy.isObscured(node)) {
- // return@traverse true
- // }
-
- val (visibleRects, color) =
- when (node) {
- is ImageViewHierarchyNode -> {
- listOf(node.visibleRect) to screenshot.dominantColorForRect(node.visibleRect)
- }
-
- is TextViewHierarchyNode -> {
- val textColor =
- node.layout?.dominantTextColor ?: node.dominantColor ?: Color.BLACK
- node.layout.getVisibleRects(
- node.visibleRect,
- node.paddingLeft,
- node.paddingTop,
- ) to textColor
- }
-
- else -> {
- listOf(node.visibleRect) to Color.BLACK
- }
- }
-
- maskingPaint.setColor(color)
- visibleRects.forEach { rect ->
- canvas.drawRoundRect(RectF(rect), 10f, 10f, maskingPaint)
- }
- if (options.replayController.isDebugMaskingOverlayEnabled()) {
- debugMasks.addAll(visibleRects)
- }
- }
- return@traverse true
- }
-
- if (options.replayController.isDebugMaskingOverlayEnabled()) {
- mainLooperHandler.post {
- if (debugOverlayDrawable.callback == null) {
- root.overlay.add(debugOverlayDrawable)
- }
- debugOverlayDrawable.updateMasks(debugMasks)
- root.postInvalidate()
- }
- }
- screenshotRecorderCallback?.onScreenshotRecorded(screenshot)
- lastCaptureSuccessful.set(true)
- contentChanged.set(false)
- }
- },
- mainLooperHandler.handler,
- )
+ screenshotStrategy.capture(root)
} catch (e: Throwable) {
options.logger.log(WARNING, "Failed to capture replay recording", e)
- lastCaptureSuccessful.set(false)
}
}
@@ -199,6 +106,7 @@ internal class ScreenshotRecorder(
}
contentChanged.set(true)
+ screenshotStrategy.onContentChanged()
}
fun bind(root: View) {
@@ -212,6 +120,7 @@ internal class ScreenshotRecorder(
// invalidate the flag to capture the first frame after new window is attached
contentChanged.set(true)
+ screenshotStrategy.onContentChanged()
}
fun unbind(root: View?) {
@@ -235,29 +144,9 @@ internal class ScreenshotRecorder(
fun close() {
unbind(rootView?.get())
rootView?.clear()
- if (!screenshot.isRecycled) {
- screenshot.recycle()
- }
+ screenshotStrategy.close()
isCapturing.set(false)
}
-
- private fun Bitmap.dominantColorForRect(rect: Rect): Int {
- // TODO: maybe this ceremony can be just simplified to
- // TODO: multiplying the visibleRect by the prescaledMatrix
- val visibleRect = Rect(rect)
- val visibleRectF = RectF(visibleRect)
-
- // since we take screenshot with lower scale, we also
- // have to apply the same scale to the visibleRect to get the
- // correct screenshot part to determine the dominant color
- prescaledMatrix.mapRect(visibleRectF)
- // round it back to integer values, because drawBitmap below accepts Rect only
- visibleRectF.round(visibleRect)
- // draw part of the screenshot (visibleRect) to a single pixel bitmap
- singlePixelBitmapCanvas.drawBitmap(this, visibleRect, Rect(0, 0, 1, 1), null)
- // get the pixel color (= dominant color)
- return singlePixelBitmap.getPixel(0, 0)
- }
}
public data class ScreenshotRecorderConfig(
diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt
index eec316e5c3..654fb43ab1 100644
--- a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt
+++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt
@@ -2,6 +2,8 @@ package io.sentry.android.replay
import android.annotation.TargetApi
import android.graphics.Point
+import android.os.Handler
+import android.os.HandlerThread
import android.view.View
import android.view.ViewTreeObserver
import io.sentry.SentryLevel.DEBUG
@@ -24,18 +26,20 @@ internal class WindowRecorder(
private val windowCallback: WindowCallback,
private val mainLooperHandler: MainLooperHandler,
private val replayExecutor: ScheduledExecutorService,
-) : Recorder, OnRootViewsChangedListener {
- internal companion object {
- private const val TAG = "WindowRecorder"
- }
+) : Recorder, OnRootViewsChangedListener, ExecutorProvider {
private val isRecording = AtomicBoolean(false)
private val rootViews = ArrayList>()
private var lastKnownWindowSize: Point = Point()
private val rootViewsLock = AutoClosableReentrantLock()
private val capturerLock = AutoClosableReentrantLock()
+ private val backgroundProcessingHandlerLock = AutoClosableReentrantLock()
+
@Volatile private var capturer: Capturer? = null
+ @Volatile private var backgroundProcessingHandlerThread: HandlerThread? = null
+ @Volatile private var backgroundProcessingHandler: Handler? = null
+
private class Capturer(
private val options: SentryOptions,
private val mainLooperHandler: MainLooperHandler,
@@ -182,14 +186,7 @@ internal class WindowRecorder(
}
capturer?.config = config
- capturer?.recorder =
- ScreenshotRecorder(
- config,
- options,
- mainLooperHandler,
- replayExecutor,
- screenshotRecorderCallback,
- )
+ capturer?.recorder = ScreenshotRecorder(config, options, this, screenshotRecorderCallback)
val newRoot = rootViews.lastOrNull()?.get()
if (newRoot != null) {
@@ -237,6 +234,40 @@ internal class WindowRecorder(
override fun close() {
reset()
mainLooperHandler.removeCallbacks(capturer)
+ backgroundProcessingHandlerLock.acquire().use {
+ backgroundProcessingHandler?.removeCallbacksAndMessages(null)
+ backgroundProcessingHandlerThread?.quitSafely()
+ }
stop()
}
+
+ override fun getExecutor(): ScheduledExecutorService = replayExecutor
+
+ override fun getMainLooperHandler(): MainLooperHandler = mainLooperHandler
+
+ override fun getBackgroundHandler(): Handler {
+ // only start the background thread if it's actually needed, as it's only used by Canvas Capture
+ // Strategy
+ if (backgroundProcessingHandler == null) {
+ backgroundProcessingHandlerLock.acquire().use {
+ if (backgroundProcessingHandler == null) {
+ backgroundProcessingHandlerThread = HandlerThread("SentryReplayBackgroundProcessing")
+ backgroundProcessingHandlerThread?.start()
+ backgroundProcessingHandler = Handler(backgroundProcessingHandlerThread!!.looper)
+ }
+ }
+ }
+ return backgroundProcessingHandler!!
+ }
+}
+
+internal interface ExecutorProvider {
+ /** Returns an executor suitable for background tasks. */
+ fun getExecutor(): ScheduledExecutorService
+
+ /** Returns a handler associated with the main thread looper. */
+ fun getMainLooperHandler(): MainLooperHandler
+
+ /** Returns a handler associated with a background thread looper. */
+ fun getBackgroundHandler(): Handler
}
diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/CanvasStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/CanvasStrategy.kt
new file mode 100644
index 0000000000..5d19aeaa75
--- /dev/null
+++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/CanvasStrategy.kt
@@ -0,0 +1,1045 @@
+@file:Suppress("DEPRECATION")
+
+package io.sentry.android.replay.screenshot
+
+import android.annotation.SuppressLint
+import android.graphics.Bitmap
+import android.graphics.BitmapShader
+import android.graphics.BlendMode
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.DrawFilter
+import android.graphics.Matrix
+import android.graphics.Mesh
+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.fonts.Font
+import android.graphics.text.MeasuredText
+import android.media.ImageReader
+import android.os.Build
+import android.view.View
+import androidx.annotation.RequiresApi
+import io.sentry.SentryLevel
+import io.sentry.SentryOptions
+import io.sentry.android.replay.ExecutorProvider
+import io.sentry.android.replay.ScreenshotRecorderCallback
+import io.sentry.android.replay.ScreenshotRecorderConfig
+import io.sentry.android.replay.util.submitSafely
+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")
+internal class CanvasStrategy(
+ private val executor: ExecutorProvider,
+ private val screenshotRecorderCallback: ScreenshotRecorderCallback?,
+ private val options: SentryOptions,
+ private val config: ScreenshotRecorderConfig,
+) : ScreenshotStrategy {
+
+ @Volatile private var screenshot: Bitmap? = 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]
+
+ screenshotLock.acquire().use {
+ if (screenshot == null) {
+ screenshot =
+ Bitmap.createBitmap(holder.width, holder.height, Bitmap.Config.ARGB_8888)
+ }
+ val bitmap = screenshot
+ if (bitmap == null || bitmap.isRecycled) {
+ return@use
+ }
+
+ val buffer = plane.buffer.rewind()
+ 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 var freePictureRef =
+ AtomicReference(
+ PictureReaderHolder(config.recordingWidth, config.recordingHeight, onImageAvailableListener)
+ )
+
+ private var unprocessedPictureRef = AtomicReference(null)
+
+ init {
+ IntegrationUtils.addIntegrationToSdkVersion("ReplayCanvasStrategy")
+ }
+
+ @SuppressLint("NewApi")
+ private val pictureRenderTask = Runnable {
+ if (isClosed.get()) {
+ options.logger.log(
+ SentryLevel.DEBUG,
+ "Canvas Strategy already closed, skipping picture render",
+ )
+ return@Runnable
+ }
+ val holder = 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()
+ try {
+ canvas.drawColor(Color.BLACK, PorterDuff.Mode.CLEAR)
+ holder.picture.draw(canvas)
+ } finally {
+ surface.unlockCanvasAndPost(canvas)
+ }
+ } catch (t: Throwable) {
+ if (isClosed.get()) {
+ holder.close()
+ } else {
+ freePictureRef.set(holder)
+ }
+ options.logger.log(SentryLevel.ERROR, "Canvas Strategy: picture render failed", t)
+ }
+ }
+
+ @SuppressLint("UnclosedTrace")
+ 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
+ textIgnoringCanvas.setMatrix(prescaledMatrix)
+ root.draw(textIgnoringCanvas)
+ holder.picture.endRecording()
+
+ if (isClosed.get()) {
+ holder.close()
+ } else {
+ unprocessedPictureRef.set(holder)
+ executor.getExecutor().submitSafely(options, "screenshot_recorder.canvas", pictureRenderTask)
+ }
+ }
+
+ override fun onContentChanged() {
+ // ignored
+ }
+
+ override fun close() {
+ isClosed.set(true)
+ screenshotLock.acquire().use {
+ screenshot?.apply {
+ if (!isRecycled) {
+ recycle()
+ }
+ }
+ screenshot = null
+ }
+ // 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 emitLastScreenshot() {
+ if (lastCaptureSuccessful()) {
+ val bitmap = screenshot
+ if (bitmap != null && !bitmap.isRecycled) {
+ screenshotRecorderCallback?.onScreenshotRecorded(bitmap)
+ }
+ }
+ }
+}
+
+@SuppressLint("UseKtx")
+private class TextIgnoringDelegateCanvas : Canvas() {
+
+ lateinit var delegate: Canvas
+ private val solidPaint = Paint()
+ private val textPaint = Paint()
+ private val tmpRect = Rect()
+
+ val singlePixelBitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)
+ val singlePixelCanvas = Canvas(singlePixelBitmap)
+
+ val singlePixelBitmapBounds = Rect(0, 0, 1, 1)
+
+ private val bitmapColorCache = WeakHashMap>()
+
+ override fun isHardwareAccelerated(): Boolean {
+ return false
+ }
+
+ override fun setBitmap(bitmap: Bitmap?) {
+ delegate.setBitmap(bitmap)
+ }
+
+ @RequiresApi(Build.VERSION_CODES.Q)
+ override fun enableZ() {
+ delegate.enableZ()
+ }
+
+ @RequiresApi(Build.VERSION_CODES.Q)
+ override fun disableZ() {
+ delegate.disableZ()
+ }
+
+ override fun isOpaque(): Boolean {
+ return delegate.isOpaque()
+ }
+
+ override fun getWidth(): Int {
+ return delegate.width
+ }
+
+ override fun getHeight(): Int {
+ return delegate.height
+ }
+
+ override fun getDensity(): Int {
+ return delegate.density
+ }
+
+ override fun setDensity(density: Int) {
+ delegate.setDensity(density)
+ }
+
+ override fun getMaximumBitmapWidth(): Int {
+ return delegate.maximumBitmapWidth
+ }
+
+ override fun getMaximumBitmapHeight(): Int {
+ return delegate.maximumBitmapHeight
+ }
+
+ override fun save(): Int {
+ val result = delegate.save()
+ return result
+ }
+
+ @Suppress("unused")
+ fun save(saveFlags: Int): Int {
+ return save()
+ }
+
+ @Deprecated("Deprecated in Java")
+ override fun saveLayer(bounds: RectF?, paint: Paint?, saveFlags: Int): Int {
+ val shader = removeBitmapShader(paint)
+ val result = delegate.saveLayer(bounds, paint, saveFlags)
+ shader.let { paint?.shader = it }
+ return result
+ }
+
+ override fun saveLayer(bounds: RectF?, paint: Paint?): Int {
+ val shader = removeBitmapShader(paint)
+ val result = delegate.saveLayer(bounds, paint)
+ shader.let { paint?.shader = it }
+ return result
+ }
+
+ @Deprecated("Deprecated in Java")
+ override fun saveLayer(
+ left: Float,
+ top: Float,
+ right: Float,
+ bottom: Float,
+ paint: Paint?,
+ saveFlags: Int,
+ ): Int {
+ val shader = removeBitmapShader(paint)
+ val result = delegate.saveLayer(left, top, right, bottom, paint, saveFlags)
+ shader.let { paint?.shader = it }
+ return result
+ }
+
+ override fun saveLayer(left: Float, top: Float, right: Float, bottom: Float, paint: Paint?): Int {
+ val shader = removeBitmapShader(paint)
+ val result = delegate.saveLayer(left, top, right, bottom, paint)
+ shader.let { paint?.shader = it }
+ return result
+ }
+
+ @Deprecated("Deprecated in Java")
+ override fun saveLayerAlpha(bounds: RectF?, alpha: Int, saveFlags: Int): Int {
+ return delegate.saveLayerAlpha(bounds, alpha, saveFlags)
+ }
+
+ override fun saveLayerAlpha(bounds: RectF?, alpha: Int): Int {
+ return delegate.saveLayerAlpha(bounds, alpha)
+ }
+
+ @Deprecated("Deprecated in Java")
+ override fun saveLayerAlpha(
+ left: Float,
+ top: Float,
+ right: Float,
+ bottom: Float,
+ alpha: Int,
+ saveFlags: Int,
+ ): Int {
+ return delegate.saveLayerAlpha(left, top, right, bottom, alpha, saveFlags)
+ }
+
+ override fun saveLayerAlpha(
+ left: Float,
+ top: Float,
+ right: Float,
+ bottom: Float,
+ alpha: Int,
+ ): Int {
+ return delegate.saveLayerAlpha(left, top, right, bottom, alpha)
+ }
+
+ override fun restore() {
+ delegate.restore()
+ }
+
+ override fun getSaveCount(): Int {
+ return delegate.saveCount
+ }
+
+ override fun restoreToCount(saveCount: Int) {
+ delegate.restoreToCount(saveCount)
+ }
+
+ override fun translate(dx: Float, dy: Float) {
+ delegate.translate(dx, dy)
+ }
+
+ override fun scale(sx: Float, sy: Float) {
+ delegate.scale(sx, sy)
+ }
+
+ override fun rotate(degrees: Float) {
+ delegate.rotate(degrees)
+ }
+
+ override fun skew(sx: Float, sy: Float) {
+ delegate.skew(sx, sy)
+ }
+
+ override fun concat(matrix: Matrix?) {
+ delegate.concat(matrix)
+ }
+
+ override fun setMatrix(matrix: Matrix?) {
+ delegate.setMatrix(matrix)
+ }
+
+ @Deprecated("Deprecated in Java")
+ override fun getMatrix(ctm: Matrix) {
+ delegate.getMatrix(ctm)
+ }
+
+ @Deprecated("Deprecated in Java")
+ override fun clipRect(rect: RectF, op: Region.Op): Boolean {
+ return delegate.clipRect(rect, op)
+ }
+
+ @Deprecated("Deprecated in Java")
+ override fun clipRect(rect: Rect, op: Region.Op): Boolean {
+ return delegate.clipRect(rect, op)
+ }
+
+ override fun clipRect(rect: RectF): Boolean {
+ return delegate.clipRect(rect)
+ }
+
+ override fun clipRect(rect: Rect): Boolean {
+ return delegate.clipRect(rect)
+ }
+
+ @Deprecated("Deprecated in Java")
+ override fun clipRect(
+ left: Float,
+ top: Float,
+ right: Float,
+ bottom: Float,
+ op: Region.Op,
+ ): Boolean {
+ return delegate.clipRect(left, top, right, bottom, op)
+ }
+
+ override fun clipRect(left: Float, top: Float, right: Float, bottom: Float): Boolean {
+ return delegate.clipRect(left, top, right, bottom)
+ }
+
+ override fun clipRect(left: Int, top: Int, right: Int, bottom: Int): Boolean {
+ return delegate.clipRect(left, top, right, bottom)
+ }
+
+ @RequiresApi(Build.VERSION_CODES.O)
+ override fun clipOutRect(rect: RectF): Boolean {
+ return delegate.clipOutRect(rect)
+ }
+
+ @RequiresApi(Build.VERSION_CODES.O)
+ override fun clipOutRect(rect: Rect): Boolean {
+ return delegate.clipOutRect(rect)
+ }
+
+ @RequiresApi(Build.VERSION_CODES.O)
+ override fun clipOutRect(left: Float, top: Float, right: Float, bottom: Float): Boolean {
+ return delegate.clipOutRect(left, top, right, bottom)
+ }
+
+ @RequiresApi(Build.VERSION_CODES.O)
+ override fun clipOutRect(left: Int, top: Int, right: Int, bottom: Int): Boolean {
+ return delegate.clipOutRect(left, top, right, bottom)
+ }
+
+ @Deprecated("Deprecated in Java")
+ override fun clipPath(path: Path, op: Region.Op): Boolean {
+ return delegate.clipPath(path, op)
+ }
+
+ override fun clipPath(path: Path): Boolean {
+ return delegate.clipPath(path)
+ }
+
+ @RequiresApi(Build.VERSION_CODES.O)
+ override fun clipOutPath(path: Path): Boolean {
+ return delegate.clipOutPath(path)
+ }
+
+ override fun getDrawFilter(): DrawFilter? {
+ return delegate.drawFilter
+ }
+
+ override fun setDrawFilter(filter: DrawFilter?) {
+ delegate.setDrawFilter(filter)
+ }
+
+ @Deprecated("Deprecated in Java")
+ override fun quickReject(rect: RectF, type: EdgeType): Boolean {
+ return delegate.quickReject(rect, type)
+ }
+
+ @RequiresApi(Build.VERSION_CODES.R)
+ override fun quickReject(rect: RectF): Boolean {
+ return delegate.quickReject(rect)
+ }
+
+ @Deprecated("Deprecated in Java")
+ override fun quickReject(path: Path, type: EdgeType): Boolean {
+ return delegate.quickReject(path, type)
+ }
+
+ @RequiresApi(Build.VERSION_CODES.R)
+ override fun quickReject(path: Path): Boolean {
+ return delegate.quickReject(path)
+ }
+
+ @Deprecated("Deprecated in Java")
+ override fun quickReject(
+ left: Float,
+ top: Float,
+ right: Float,
+ bottom: Float,
+ type: EdgeType,
+ ): Boolean {
+ return delegate.quickReject(left, top, right, bottom, type)
+ }
+
+ @RequiresApi(Build.VERSION_CODES.R)
+ override fun quickReject(left: Float, top: Float, right: Float, bottom: Float): Boolean {
+ return delegate.quickReject(left, top, right, bottom)
+ }
+
+ override fun getClipBounds(bounds: Rect): Boolean {
+ return delegate.getClipBounds(bounds)
+ }
+
+ override fun drawPicture(picture: Picture) {
+ solidPaint.colorFilter = null
+ solidPaint.color = Color.BLACK
+ delegate.drawRect(0f, 0f, picture.width.toFloat(), picture.height.toFloat(), solidPaint)
+ }
+
+ override fun drawPicture(picture: Picture, dst: RectF) {
+ solidPaint.colorFilter = null
+ solidPaint.color = Color.BLACK
+ delegate.drawRect(dst, solidPaint)
+ }
+
+ override fun drawPicture(picture: Picture, dst: Rect) {
+ solidPaint.colorFilter = null
+ solidPaint.color = Color.BLACK
+ delegate.drawRect(dst, solidPaint)
+ }
+
+ override fun drawArc(
+ oval: RectF,
+ startAngle: Float,
+ sweepAngle: Float,
+ useCenter: Boolean,
+ paint: Paint,
+ ) {
+ val shader = removeBitmapShader(paint)
+ delegate.drawArc(oval, startAngle, sweepAngle, useCenter, paint)
+ shader.let { paint.shader = it }
+ }
+
+ override fun drawArc(
+ left: Float,
+ top: Float,
+ right: Float,
+ bottom: Float,
+ startAngle: Float,
+ sweepAngle: Float,
+ useCenter: Boolean,
+ paint: Paint,
+ ) {
+ val shader = removeBitmapShader(paint)
+ delegate.drawArc(left, top, right, bottom, startAngle, sweepAngle, useCenter, paint)
+ shader.let { paint.shader = it }
+ }
+
+ override fun drawARGB(a: Int, r: Int, g: Int, b: Int) {
+ delegate.drawARGB(a, r, g, b)
+ }
+
+ @RequiresApi(Build.VERSION_CODES.O)
+ override fun drawBitmap(bitmap: Bitmap, left: Float, top: Float, paint: Paint?) {
+ val sampledColor = sampleBitmapColor(bitmap, paint, null)
+ solidPaint.setColor(sampledColor)
+ solidPaint.colorFilter = null
+ delegate.drawRect(left, top, left + bitmap.width, top + bitmap.height, solidPaint)
+ }
+
+ @RequiresApi(Build.VERSION_CODES.O)
+ override fun drawBitmap(bitmap: Bitmap, src: Rect?, dst: RectF, paint: Paint?) {
+ val sampledColor = sampleBitmapColor(bitmap, paint, src)
+ solidPaint.setColor(sampledColor)
+ solidPaint.colorFilter = null
+ delegate.drawRect(dst, solidPaint)
+ }
+
+ @RequiresApi(Build.VERSION_CODES.O)
+ override fun drawBitmap(bitmap: Bitmap, src: Rect?, dst: Rect, paint: Paint?) {
+ val sampledColor = sampleBitmapColor(bitmap, paint, src)
+ solidPaint.setColor(sampledColor)
+ solidPaint.colorFilter = null
+ delegate.drawRect(dst, solidPaint)
+ }
+
+ @Deprecated("Deprecated in Java")
+ override fun drawBitmap(
+ colors: IntArray,
+ offset: Int,
+ stride: Int,
+ x: Float,
+ y: Float,
+ width: Int,
+ height: Int,
+ hasAlpha: Boolean,
+ paint: Paint?,
+ ) {
+ // not supported
+ }
+
+ @Deprecated("Deprecated in Java")
+ override fun drawBitmap(
+ colors: IntArray,
+ offset: Int,
+ stride: Int,
+ x: Int,
+ y: Int,
+ width: Int,
+ height: Int,
+ hasAlpha: Boolean,
+ paint: Paint?,
+ ) {
+ // not supported
+ }
+
+ @RequiresApi(Build.VERSION_CODES.O)
+ override fun drawBitmap(bitmap: Bitmap, matrix: Matrix, paint: Paint?) {
+ val sampledColor = sampleBitmapColor(bitmap, paint, null)
+ solidPaint.setColor(sampledColor)
+ solidPaint.colorFilter = null
+
+ val count = delegate.save()
+ delegate.setMatrix(matrix)
+ delegate.drawRect(0f, 0f, bitmap.width.toFloat(), bitmap.height.toFloat(), solidPaint)
+ delegate.restoreToCount(count)
+ }
+
+ override fun drawBitmapMesh(
+ bitmap: Bitmap,
+ meshWidth: Int,
+ meshHeight: Int,
+ verts: FloatArray,
+ vertOffset: Int,
+ colors: IntArray?,
+ colorOffset: Int,
+ paint: Paint?,
+ ) {
+ // not supported
+ }
+
+ override fun drawCircle(cx: Float, cy: Float, radius: Float, paint: Paint) {
+ val shader = removeBitmapShader(paint)
+ delegate.drawCircle(cx, cy, radius, paint)
+ shader.let { paint.shader = it }
+ }
+
+ override fun drawColor(color: Int) {
+ delegate.drawColor(color)
+ }
+
+ @RequiresApi(Build.VERSION_CODES.Q)
+ override fun drawColor(color: Long) {
+ delegate.drawColor(color)
+ }
+
+ override fun drawColor(color: Int, mode: PorterDuff.Mode) {
+ delegate.drawColor(color, mode)
+ }
+
+ @RequiresApi(Build.VERSION_CODES.Q)
+ override fun drawColor(color: Int, mode: BlendMode) {
+ delegate.drawColor(color, mode)
+ }
+
+ @RequiresApi(Build.VERSION_CODES.Q)
+ override fun drawColor(color: Long, mode: BlendMode) {
+ delegate.drawColor(color, mode)
+ }
+
+ override fun drawLine(startX: Float, startY: Float, stopX: Float, stopY: Float, paint: Paint) {
+ val shader = removeBitmapShader(paint)
+ delegate.drawLine(startX, startY, stopX, stopY, paint)
+ shader.let { paint.shader = it }
+ }
+
+ override fun drawLines(pts: FloatArray, offset: Int, count: Int, paint: Paint) {
+ val shader = removeBitmapShader(paint)
+ delegate.drawLines(pts, offset, count, paint)
+ shader.let { paint.shader = it }
+ }
+
+ override fun drawLines(pts: FloatArray, paint: Paint) {
+ val shader = removeBitmapShader(paint)
+ delegate.drawLines(pts, paint)
+ shader.let { paint.shader = it }
+ }
+
+ override fun drawOval(oval: RectF, paint: Paint) {
+ val shader = removeBitmapShader(paint)
+ delegate.drawOval(oval, paint)
+ shader.let { paint.shader = it }
+ }
+
+ override fun drawOval(left: Float, top: Float, right: Float, bottom: Float, paint: Paint) {
+ val shader = removeBitmapShader(paint)
+ delegate.drawOval(left, top, right, bottom, paint)
+ shader.let { paint.shader = it }
+ }
+
+ override fun drawPaint(paint: Paint) {
+ val shader = removeBitmapShader(paint)
+ delegate.drawPaint(paint)
+ shader.let { paint.shader = it }
+ }
+
+ @RequiresApi(Build.VERSION_CODES.S)
+ override fun drawPatch(patch: NinePatch, dst: Rect, paint: Paint?) {
+ val shader = removeBitmapShader(paint)
+ delegate.drawPatch(patch, dst, paint)
+ shader.let { paint?.shader = it }
+ }
+
+ @RequiresApi(Build.VERSION_CODES.S)
+ override fun drawPatch(patch: NinePatch, dst: RectF, paint: Paint?) {
+ val shader = removeBitmapShader(paint)
+ delegate.drawPatch(patch, dst, paint)
+ shader.let { paint?.shader = it }
+ }
+
+ override fun drawPath(path: Path, paint: Paint) {
+ val shader = removeBitmapShader(paint)
+ delegate.drawPath(path, paint)
+ shader.let { paint.shader = it }
+ }
+
+ override fun drawPoint(x: Float, y: Float, paint: Paint) {
+ val shader = removeBitmapShader(paint)
+ delegate.drawPoint(x, y, paint)
+ shader.let { paint.shader = it }
+ }
+
+ override fun drawPoints(pts: FloatArray?, offset: Int, count: Int, paint: Paint) {
+ val shader = removeBitmapShader(paint)
+ delegate.drawPoints(pts, offset, count, paint)
+ shader.let { paint.shader = it }
+ }
+
+ override fun drawPoints(pts: FloatArray, paint: Paint) {
+ val shader = removeBitmapShader(paint)
+ delegate.drawPoints(pts, paint)
+ shader.let { paint.shader = it }
+ }
+
+ override fun drawRect(rect: RectF, paint: Paint) {
+ val shader = removeBitmapShader(paint)
+ delegate.drawRect(rect, paint)
+ shader.let { paint.shader = it }
+ }
+
+ override fun drawRect(r: Rect, paint: Paint) {
+ val shader = removeBitmapShader(paint)
+ delegate.drawRect(r, paint)
+ shader.let { paint.shader = it }
+ }
+
+ override fun drawRect(left: Float, top: Float, right: Float, bottom: Float, paint: Paint) {
+ val shader = removeBitmapShader(paint)
+ delegate.drawRect(left, top, right, bottom, paint)
+ shader.let { paint.shader = it }
+ }
+
+ override fun drawRGB(r: Int, g: Int, b: Int) {
+ delegate.drawRGB(r, g, b)
+ }
+
+ override fun drawRoundRect(rect: RectF, rx: Float, ry: Float, paint: Paint) {
+ val shader = removeBitmapShader(paint)
+ delegate.drawRoundRect(rect, rx, ry, paint)
+ shader.let { paint.shader = it }
+ }
+
+ override fun drawRoundRect(
+ left: Float,
+ top: Float,
+ right: Float,
+ bottom: Float,
+ rx: Float,
+ ry: Float,
+ paint: Paint,
+ ) {
+ val shader = removeBitmapShader(paint)
+ delegate.drawRoundRect(left, top, right, bottom, rx, ry, paint)
+ shader.let { paint.shader = it }
+ }
+
+ @RequiresApi(Build.VERSION_CODES.Q)
+ override fun drawDoubleRoundRect(
+ outer: RectF,
+ outerRx: Float,
+ outerRy: Float,
+ inner: RectF,
+ innerRx: Float,
+ innerRy: Float,
+ paint: Paint,
+ ) {
+ val shader = removeBitmapShader(paint)
+ delegate.drawDoubleRoundRect(outer, outerRx, outerRy, inner, innerRx, innerRy, paint)
+ shader.let { paint.shader = it }
+ }
+
+ @RequiresApi(Build.VERSION_CODES.Q)
+ override fun drawDoubleRoundRect(
+ outer: RectF,
+ outerRadii: FloatArray,
+ inner: RectF,
+ innerRadii: FloatArray,
+ paint: Paint,
+ ) {
+ val shader = removeBitmapShader(paint)
+ delegate.drawDoubleRoundRect(outer, outerRadii, inner, innerRadii, paint)
+ shader.let { paint.shader = it }
+ }
+
+ override fun drawGlyphs(
+ glyphIds: IntArray,
+ glyphIdOffset: Int,
+ positions: FloatArray,
+ positionOffset: Int,
+ glyphCount: Int,
+ font: Font,
+ paint: Paint,
+ ) {
+ // not supported
+ }
+
+ override fun drawVertices(
+ mode: VertexMode,
+ vertexCount: Int,
+ verts: FloatArray,
+ vertOffset: Int,
+ texs: FloatArray?,
+ texOffset: Int,
+ colors: IntArray?,
+ colorOffset: Int,
+ indices: ShortArray?,
+ indexOffset: Int,
+ indexCount: Int,
+ paint: Paint,
+ ) {
+ // not supported
+ }
+
+ override fun drawRenderNode(renderNode: RenderNode) {
+ // not supported
+ }
+
+ override fun drawMesh(mesh: Mesh, blendMode: BlendMode?, paint: Paint) {
+ // not supported
+ }
+
+ @Deprecated("Deprecated in Java")
+ override fun drawPosText(text: CharArray, index: Int, count: Int, pos: FloatArray, paint: Paint) {
+ // not supported
+ }
+
+ @Deprecated("Deprecated in Java")
+ override fun drawPosText(text: String, pos: FloatArray, paint: Paint) {
+ // not supported
+ }
+
+ override fun drawText(text: CharArray, index: Int, count: Int, x: Float, y: Float, paint: Paint) {
+ paint.getTextBounds(text, index, count, tmpRect)
+ drawMaskedText(paint, x, y)
+ }
+
+ override fun drawText(text: String, x: Float, y: Float, paint: Paint) {
+ paint.getTextBounds(text, 0, text.length, tmpRect)
+ drawMaskedText(paint, x, y)
+ }
+
+ override fun drawText(text: String, start: Int, end: Int, x: Float, y: Float, paint: Paint) {
+ paint.getTextBounds(text, start, end, tmpRect)
+ drawMaskedText(paint, x, y)
+ }
+
+ override fun drawText(
+ text: CharSequence,
+ start: Int,
+ end: Int,
+ x: Float,
+ y: Float,
+ paint: Paint,
+ ) {
+ paint.getTextBounds(text.toString(), 0, text.length, tmpRect)
+ drawMaskedText(paint, x, y)
+ }
+
+ override fun drawTextOnPath(
+ text: CharArray,
+ index: Int,
+ count: Int,
+ path: Path,
+ hOffset: Float,
+ vOffset: Float,
+ paint: Paint,
+ ) {
+ // not supported
+ }
+
+ override fun drawTextOnPath(
+ text: String,
+ path: Path,
+ hOffset: Float,
+ vOffset: Float,
+ paint: Paint,
+ ) {
+ // not supported
+ }
+
+ override fun drawTextRun(
+ text: CharArray,
+ index: Int,
+ count: Int,
+ contextIndex: Int,
+ contextCount: Int,
+ x: Float,
+ y: Float,
+ isRtl: Boolean,
+ paint: Paint,
+ ) {
+ paint.getTextBounds(text, 0, index + count, tmpRect)
+ drawMaskedText(paint, x, y)
+ }
+
+ override fun drawTextRun(
+ text: CharSequence,
+ start: Int,
+ end: Int,
+ contextStart: Int,
+ contextEnd: Int,
+ x: Float,
+ y: Float,
+ isRtl: Boolean,
+ paint: Paint,
+ ) {
+ paint.getTextBounds(text.toString(), start, end, tmpRect)
+ drawMaskedText(paint, x, y)
+ }
+
+ override fun drawTextRun(
+ text: MeasuredText,
+ start: Int,
+ end: Int,
+ contextStart: Int,
+ contextEnd: Int,
+ x: Float,
+ y: Float,
+ isRtl: Boolean,
+ paint: Paint,
+ ) {
+ paint.getTextBounds(text.toString(), start, end, tmpRect)
+ drawMaskedText(paint, x, y)
+ }
+
+ @RequiresApi(Build.VERSION_CODES.O)
+ private fun sampleBitmapColor(bitmap: Bitmap, paint: Paint?, src: Rect?): Int {
+ if (bitmap.isRecycled) {
+ return Color.BLACK
+ }
+
+ val cache = bitmapColorCache[bitmap]
+ if (cache != null && cache.first == bitmap.generationId) {
+ return cache.second
+ } else {
+ val color =
+ if (
+ bitmap.config == Bitmap.Config.HARDWARE && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
+ ) {
+ // bitmap.asShared() ensures that the bitmap, even if it is hardware bitmap,
+ // can be drawn onto the single pixel software canvas
+ val shader = removeBitmapShader(paint)
+ singlePixelCanvas.drawBitmap(bitmap.asShared(), src, singlePixelBitmapBounds, paint)
+ shader?.let { paint?.shader = it }
+ singlePixelBitmap.getPixel(0, 0)
+ } else if (bitmap.config != Bitmap.Config.HARDWARE) {
+ // fallback for older android versions
+ val shader = removeBitmapShader(paint)
+ singlePixelCanvas.drawBitmap(bitmap, src, singlePixelBitmapBounds, paint)
+ shader?.let { paint?.shader = it }
+ singlePixelBitmap.getPixel(0, 0)
+ } else {
+ // fallback for older android versions
+ Color.BLACK
+ }
+ bitmapColorCache[bitmap] = Pair(bitmap.generationId, color)
+ return color
+ }
+ }
+
+ private fun drawMaskedText(paint: Paint, x: Float, y: Float) {
+ textPaint.colorFilter = paint.colorFilter
+ val color = paint.color
+ textPaint.color = Color.argb(100, Color.red(color), Color.green(color), Color.blue(color))
+ drawRoundRect(
+ tmpRect.left.toFloat() + x,
+ tmpRect.top.toFloat() + y,
+ tmpRect.right.toFloat() + x,
+ tmpRect.bottom.toFloat() + y,
+ 10f,
+ 10f,
+ textPaint,
+ )
+ }
+
+ /** Removes the bitmap shader from a paint, returning it so it can be restored later. */
+ private fun removeBitmapShader(paint: Paint?): BitmapShader? {
+ return if (paint == null) {
+ null
+ } else {
+ val shader = paint.shader
+ if (shader is BitmapShader) {
+ paint.shader = null
+ shader
+ } else {
+ null
+ }
+ }
+ }
+}
+
+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
+ }
+ }
+}
diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/PixelCopyStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/PixelCopyStrategy.kt
new file mode 100644
index 0000000000..3528366e36
--- /dev/null
+++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/PixelCopyStrategy.kt
@@ -0,0 +1,190 @@
+package io.sentry.android.replay.screenshot
+
+import android.annotation.SuppressLint
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Matrix
+import android.graphics.Paint
+import android.graphics.Rect
+import android.graphics.RectF
+import android.view.PixelCopy
+import android.view.View
+import io.sentry.SentryLevel.DEBUG
+import io.sentry.SentryLevel.INFO
+import io.sentry.SentryLevel.WARNING
+import io.sentry.SentryOptions
+import io.sentry.android.replay.ExecutorProvider
+import io.sentry.android.replay.ScreenshotRecorderCallback
+import io.sentry.android.replay.ScreenshotRecorderConfig
+import io.sentry.android.replay.phoneWindow
+import io.sentry.android.replay.util.DebugOverlayDrawable
+import io.sentry.android.replay.util.getVisibleRects
+import io.sentry.android.replay.util.submitSafely
+import io.sentry.android.replay.util.traverse
+import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode
+import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode
+import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode
+import java.util.concurrent.atomic.AtomicBoolean
+import kotlin.LazyThreadSafetyMode.NONE
+
+@SuppressLint("UseKtx")
+internal class PixelCopyStrategy(
+ executorProvider: ExecutorProvider,
+ private val screenshotRecorderCallback: ScreenshotRecorderCallback?,
+ private val options: SentryOptions,
+ private val config: ScreenshotRecorderConfig,
+ private val debugOverlayDrawable: DebugOverlayDrawable,
+) : ScreenshotStrategy {
+
+ private val executor = executorProvider.getExecutor()
+ private val mainLooperHandler = executorProvider.getMainLooperHandler()
+ private val singlePixelBitmap: Bitmap by
+ lazy(NONE) { Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) }
+ private val screenshot =
+ Bitmap.createBitmap(config.recordingWidth, config.recordingHeight, Bitmap.Config.ARGB_8888)
+ private val singlePixelBitmapCanvas: Canvas by lazy(NONE) { Canvas(singlePixelBitmap) }
+ private val prescaledMatrix by
+ lazy(NONE) { Matrix().apply { preScale(config.scaleFactorX, config.scaleFactorY) } }
+ private val lastCaptureSuccessful = AtomicBoolean(false)
+ private val maskingPaint by lazy(NONE) { Paint() }
+ private val contentChanged = AtomicBoolean(false)
+
+ @SuppressLint("NewApi")
+ override fun capture(root: View) {
+ val window = root.phoneWindow
+ if (window == null) {
+ options.logger.log(DEBUG, "Window is invalid, not capturing screenshot")
+ return
+ }
+
+ try {
+ contentChanged.set(false)
+ PixelCopy.request(
+ window,
+ screenshot,
+ { copyResult: Int ->
+ if (copyResult != PixelCopy.SUCCESS) {
+ options.logger.log(INFO, "Failed to capture replay recording: %d", copyResult)
+ lastCaptureSuccessful.set(false)
+ return@request
+ }
+
+ // TODO: handle animations with heuristics (e.g. if we fall under this condition 2 times
+ // in a row, we should capture)
+ if (contentChanged.get()) {
+ options.logger.log(INFO, "Failed to determine view hierarchy, not capturing")
+ lastCaptureSuccessful.set(false)
+ return@request
+ }
+
+ // TODO: disableAllMasking here and dont traverse?
+ val viewHierarchy = ViewHierarchyNode.fromView(root, null, 0, options)
+ root.traverse(viewHierarchy, options)
+
+ executor.submitSafely(options, "screenshot_recorder.mask") {
+ val debugMasks = mutableListOf()
+
+ val canvas = Canvas(screenshot)
+ canvas.setMatrix(prescaledMatrix)
+ viewHierarchy.traverse { node ->
+ if (node.shouldMask && (node.width > 0 && node.height > 0)) {
+ node.visibleRect ?: return@traverse false
+
+ // TODO: investigate why it returns true on RN when it shouldn't
+ // if (viewHierarchy.isObscured(node)) {
+ // return@traverse true
+ // }
+
+ val (visibleRects, color) =
+ when (node) {
+ is ImageViewHierarchyNode -> {
+ listOf(node.visibleRect) to screenshot.dominantColorForRect(node.visibleRect)
+ }
+
+ is TextViewHierarchyNode -> {
+ val textColor =
+ node.layout?.dominantTextColor ?: node.dominantColor ?: Color.BLACK
+ node.layout.getVisibleRects(
+ node.visibleRect,
+ node.paddingLeft,
+ node.paddingTop,
+ ) to textColor
+ }
+
+ else -> {
+ listOf(node.visibleRect) to Color.BLACK
+ }
+ }
+
+ maskingPaint.setColor(color)
+ visibleRects.forEach { rect ->
+ canvas.drawRoundRect(RectF(rect), 10f, 10f, maskingPaint)
+ }
+ if (options.replayController.isDebugMaskingOverlayEnabled()) {
+ debugMasks.addAll(visibleRects)
+ }
+ }
+ return@traverse true
+ }
+
+ if (options.replayController.isDebugMaskingOverlayEnabled()) {
+ mainLooperHandler.post {
+ if (debugOverlayDrawable.callback == null) {
+ root.overlay.add(debugOverlayDrawable)
+ }
+ debugOverlayDrawable.updateMasks(debugMasks)
+ root.postInvalidate()
+ }
+ }
+ screenshotRecorderCallback?.onScreenshotRecorded(screenshot)
+ lastCaptureSuccessful.set(true)
+ contentChanged.set(false)
+ }
+ },
+ mainLooperHandler.handler,
+ )
+ } catch (e: Throwable) {
+ options.logger.log(WARNING, "Failed to capture replay recording", e)
+ lastCaptureSuccessful.set(false)
+ }
+ }
+
+ override fun onContentChanged() {
+ contentChanged.set(true)
+ }
+
+ override fun lastCaptureSuccessful(): Boolean {
+ return lastCaptureSuccessful.get()
+ }
+
+ override fun emitLastScreenshot() {
+ if (lastCaptureSuccessful() && !screenshot.isRecycled) {
+ screenshotRecorderCallback?.onScreenshotRecorded(screenshot)
+ }
+ }
+
+ override fun close() {
+ if (!screenshot.isRecycled) {
+ screenshot.recycle()
+ }
+ }
+
+ private fun Bitmap.dominantColorForRect(rect: Rect): Int {
+ // TODO: maybe this ceremony can be just simplified to
+ // TODO: multiplying the visibleRect by the prescaledMatrix
+ val visibleRect = Rect(rect)
+ val visibleRectF = RectF(visibleRect)
+
+ // since we take screenshot with lower scale, we also
+ // have to apply the same scale to the visibleRect to get the
+ // correct screenshot part to determine the dominant color
+ prescaledMatrix.mapRect(visibleRectF)
+ // round it back to integer values, because drawBitmap below accepts Rect only
+ visibleRectF.round(visibleRect)
+ // draw part of the screenshot (visibleRect) to a single pixel bitmap
+ singlePixelBitmapCanvas.drawBitmap(this, visibleRect, Rect(0, 0, 1, 1), null)
+ // get the pixel color (= dominant color)
+ return singlePixelBitmap.getPixel(0, 0)
+ }
+}
diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/ScreenshotStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/ScreenshotStrategy.kt
new file mode 100644
index 0000000000..a7b2334ea7
--- /dev/null
+++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/ScreenshotStrategy.kt
@@ -0,0 +1,15 @@
+package io.sentry.android.replay.screenshot
+
+import android.view.View
+
+internal interface ScreenshotStrategy {
+ fun capture(root: View)
+
+ fun onContentChanged()
+
+ fun close()
+
+ fun lastCaptureSuccessful(): Boolean
+
+ fun emitLastScreenshot()
+}
diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ScreenshotRecorderTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ScreenshotRecorderTest.kt
new file mode 100644
index 0000000000..0a5c73f8a5
--- /dev/null
+++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ScreenshotRecorderTest.kt
@@ -0,0 +1,88 @@
+package io.sentry.android.replay
+
+import android.os.Handler
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import io.sentry.ScreenshotStrategyType
+import io.sentry.SentryOptions
+import io.sentry.android.replay.ReplaySmokeTest.Fixture
+import io.sentry.android.replay.screenshot.CanvasStrategy
+import io.sentry.android.replay.screenshot.PixelCopyStrategy
+import io.sentry.android.replay.screenshot.ScreenshotStrategy
+import io.sentry.android.replay.util.MainLooperHandler
+import java.util.concurrent.ScheduledExecutorService
+import kotlin.test.Test
+import kotlin.test.assertTrue
+import org.junit.runner.RunWith
+import org.mockito.kotlin.mock
+
+@RunWith(AndroidJUnit4::class)
+class ScreenshotRecorderTest {
+
+ internal class Fixture() {
+
+ fun getSut(config: (options: SentryOptions) -> Unit = {}): ScreenshotRecorder {
+ val options = SentryOptions()
+ config(options)
+ return ScreenshotRecorder(
+ ScreenshotRecorderConfig(100, 100, 1f, 1f, 1, 1000),
+ options,
+ object : ExecutorProvider {
+ override fun getExecutor(): ScheduledExecutorService = mock()
+
+ override fun getMainLooperHandler(): MainLooperHandler = mock()
+
+ override fun getBackgroundHandler(): Handler = mock()
+ },
+ null,
+ )
+ }
+ }
+
+ private val fixture = Fixture()
+
+ @Test
+ fun `when config uses PIXEL_COPY strategy, ScreenshotRecorder creates PixelCopyStrategy`() {
+ val recorder =
+ fixture.getSut { options ->
+ options.sessionReplay.screenshotStrategy = ScreenshotStrategyType.PIXEL_COPY
+ }
+
+ val strategy = getStrategy(recorder)
+
+ assertTrue(
+ strategy is PixelCopyStrategy,
+ "Expected PixelCopyStrategy but got ${strategy::class.simpleName}",
+ )
+ }
+
+ @Test
+ fun `when config uses CANVAS strategy, ScreenshotRecorder creates CanvasStrategy`() {
+ val recorder =
+ fixture.getSut { options ->
+ options.sessionReplay.screenshotStrategy = ScreenshotStrategyType.CANVAS
+ }
+ val strategy = getStrategy(recorder)
+
+ assertTrue(
+ strategy is CanvasStrategy,
+ "Expected CanvasStrategy but got ${strategy::class.simpleName}",
+ )
+ }
+
+ @Test
+ fun `when config uses default strategy, ScreenshotRecorder creates PixelCopyStrategy`() {
+ val recorder = fixture.getSut()
+ val strategy = getStrategy(recorder)
+
+ assertTrue(
+ strategy is PixelCopyStrategy,
+ "Expected PixelCopyStrategy as default but got ${strategy::class.simpleName}",
+ )
+ }
+
+ private fun getStrategy(recorder: ScreenshotRecorder): ScreenshotStrategy {
+ val strategyField = ScreenshotRecorder::class.java.getDeclaredField("screenshotStrategy")
+ strategyField.isAccessible = true
+ return strategyField.get(recorder) as ScreenshotStrategy
+ }
+}
diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml
index c4f90bcc7c..afc6db9029 100644
--- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml
+++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml
@@ -186,6 +186,7 @@
+
diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api
index ad213521e0..c591aa9f5f 100644
--- a/sentry/api/sentry.api
+++ b/sentry/api/sentry.api
@@ -2526,6 +2526,13 @@ public final class io/sentry/ScopesStorageFactory {
public static fun create (Lio/sentry/util/LoadClass;Lio/sentry/ILogger;)Lio/sentry/IScopesStorage;
}
+public final class io/sentry/ScreenshotStrategyType : java/lang/Enum {
+ public static final field CANVAS Lio/sentry/ScreenshotStrategyType;
+ public static final field PIXEL_COPY Lio/sentry/ScreenshotStrategyType;
+ public static fun valueOf (Ljava/lang/String;)Lio/sentry/ScreenshotStrategyType;
+ public static fun values ()[Lio/sentry/ScreenshotStrategyType;
+}
+
public final class io/sentry/SendCachedEnvelopeFireAndForgetIntegration : io/sentry/IConnectionStatusProvider$IConnectionStatusObserver, io/sentry/Integration, java/io/Closeable {
public fun (Lio/sentry/SendCachedEnvelopeFireAndForgetIntegration$SendFireAndForgetFactory;)V
public fun close ()V
@@ -3752,6 +3759,7 @@ public final class io/sentry/SentryReplayOptions {
public fun getMaskViewContainerClass ()Ljava/lang/String;
public fun getOnErrorSampleRate ()Ljava/lang/Double;
public fun getQuality ()Lio/sentry/SentryReplayOptions$SentryReplayQuality;
+ public fun getScreenshotStrategy ()Lio/sentry/ScreenshotStrategyType;
public fun getSdkVersion ()Lio/sentry/protocol/SdkVersion;
public fun getSessionDuration ()J
public fun getSessionSampleRate ()Ljava/lang/Double;
@@ -3768,6 +3776,7 @@ public final class io/sentry/SentryReplayOptions {
public fun setMaskViewContainerClass (Ljava/lang/String;)V
public fun setOnErrorSampleRate (Ljava/lang/Double;)V
public fun setQuality (Lio/sentry/SentryReplayOptions$SentryReplayQuality;)V
+ public fun setScreenshotStrategy (Lio/sentry/ScreenshotStrategyType;)V
public fun setSdkVersion (Lio/sentry/protocol/SdkVersion;)V
public fun setSessionSampleRate (Ljava/lang/Double;)V
public fun setTrackConfiguration (Z)V
diff --git a/sentry/src/main/java/io/sentry/ScreenshotStrategyType.java b/sentry/src/main/java/io/sentry/ScreenshotStrategyType.java
new file mode 100644
index 0000000000..ba5c3d1e3f
--- /dev/null
+++ b/sentry/src/main/java/io/sentry/ScreenshotStrategyType.java
@@ -0,0 +1,12 @@
+package io.sentry;
+
+import org.jetbrains.annotations.ApiStatus;
+
+/** Enum representing the available screenshot strategies for replay recording. */
+@ApiStatus.Experimental
+public enum ScreenshotStrategyType {
+ /** Uses Canvas-based rendering for capturing screenshots */
+ CANVAS,
+ /** Uses PixelCopy API for capturing screenshots */
+ PIXEL_COPY,
+}
diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java
index 3cddf4705a..80d9292ab5 100644
--- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java
+++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java
@@ -139,6 +139,16 @@ public enum SentryReplayQuality {
*/
private boolean debug = false;
+ /**
+ * The screenshot strategy to use for capturing screenshots during replay recording. Defaults to
+ * {@link ScreenshotStrategyType#PIXEL_COPY}. If set to {@link ScreenshotStrategyType#CANVAS}, the
+ * SDK will use the Canvas API to capture screenshots, which will always mask all Texts and
+ * Bitmaps drawn on the screen, causing {@link #addMaskViewClass} and {@link #addUnmaskViewClass}
+ * to be ignored.
+ */
+ @ApiStatus.Experimental
+ private @NotNull ScreenshotStrategyType screenshotStrategy = ScreenshotStrategyType.PIXEL_COPY;
+
public SentryReplayOptions(final boolean empty, final @Nullable SdkVersion sdkVersion) {
if (!empty) {
setMaskAllText(true);
@@ -339,4 +349,24 @@ public boolean isDebug() {
public void setDebug(final boolean debug) {
this.debug = debug;
}
+
+ /**
+ * Gets the screenshot strategy used for capturing screenshots during replay recording.
+ *
+ * @return the screenshot strategy
+ */
+ @ApiStatus.Experimental
+ public @NotNull ScreenshotStrategyType getScreenshotStrategy() {
+ return screenshotStrategy;
+ }
+
+ /**
+ * Sets the screenshot strategy to use for capturing screenshots during replay recording.
+ *
+ * @param screenshotStrategy the screenshot strategy to use
+ */
+ @ApiStatus.Experimental
+ public void setScreenshotStrategy(final @NotNull ScreenshotStrategyType screenshotStrategy) {
+ this.screenshotStrategy = screenshotStrategy;
+ }
}
diff --git a/sentry/src/test/java/io/sentry/SentryReplayOptionsTest.kt b/sentry/src/test/java/io/sentry/SentryReplayOptionsTest.kt
index f0e7b9c1fc..a1eb024595 100644
--- a/sentry/src/test/java/io/sentry/SentryReplayOptionsTest.kt
+++ b/sentry/src/test/java/io/sentry/SentryReplayOptionsTest.kt
@@ -34,4 +34,24 @@ class SentryReplayOptionsTest {
assertEquals(100_000, replayOptions.quality.bitRate)
assertEquals(1.0f, replayOptions.quality.sizeScale)
}
+
+ @Test
+ fun testDefaultScreenshotStrategy() {
+ val options = SentryReplayOptions(false, null)
+ assertEquals(ScreenshotStrategyType.PIXEL_COPY, options.getScreenshotStrategy())
+ }
+
+ @Test
+ fun testSetScreenshotStrategyToCanvas() {
+ val options = SentryReplayOptions(false, null)
+ options.screenshotStrategy = ScreenshotStrategyType.CANVAS
+ assertEquals(ScreenshotStrategyType.CANVAS, options.getScreenshotStrategy())
+ }
+
+ @Test
+ fun testSetScreenshotStrategyToPixelCopy() {
+ val options = SentryReplayOptions(false, null)
+ options.screenshotStrategy = ScreenshotStrategyType.PIXEL_COPY
+ assertEquals(ScreenshotStrategyType.PIXEL_COPY, options.getScreenshotStrategy())
+ }
}