diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cb73a5552..6cc1473d3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ ## Unreleased +### Features + +- 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 text masking + - Any `.drawText()` calls are replaced with rectangles to ensure no text is not captured + ```kotlin + SentryAndroid.init(context) { options -> + options.sessionReplay.screenshotStrategy = ScreenshotStrategyType.CANVAS + } + ``` + ### Fixes - Use logger from options for JVM profiler ([#4771](https://github.com/getsentry/sentry-java/pull/4771)) 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 2d866e6a6d..a2e0d8c644 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 5731c4e4f5..21baa080be 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, @@ -177,14 +181,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) { @@ -232,6 +229,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..11f8625040 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/CanvasStrategy.kt @@ -0,0 +1,896 @@ +@file:Suppress("DEPRECATION") + +package io.sentry.android.replay.screenshot + +import android.annotation.SuppressLint +import android.graphics.Bitmap +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.view.View +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.IntegrationUtils +import java.util.WeakHashMap +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference +import kotlin.LazyThreadSafetyMode.NONE + +@SuppressLint("UseKtx") +internal class CanvasStrategy( + private val executor: ExecutorProvider, + private val screenshotRecorderCallback: ScreenshotRecorderCallback?, + private val options: SentryOptions, + private val config: ScreenshotRecorderConfig, +) : ScreenshotStrategy { + + private var screenshot: Bitmap? = null + 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") + } else { + try { + val image = holder.reader.acquireLatestImage() + try { + if (image.planes.size > 0) { + val plane = image.planes[0] + + val buffer = plane.buffer.rewind() + val pixelStride = plane.pixelStride + val rowStride = plane.rowStride + val rowPadding = rowStride - pixelStride * holder.width + + val bitmap = + Bitmap.createBitmap( + holder.width + rowPadding / pixelStride, + holder.height, + Bitmap.Config.ARGB_8888, + ) + + bitmap.copyPixelsFromBuffer(buffer) + + screenshot = bitmap + lastCaptureSuccessful.set(true) + screenshotRecorderCallback?.onScreenshotRecorded(bitmap) + } + } finally { + image.close() + } + } catch (e: Throwable) { + options.logger.log(SentryLevel.ERROR, "CanvasStrategy: image processing failed", e) + } finally { + 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) + if (holder == null) { + return@Runnable + } + + try { + if (!holder.setup.getAndSet(true)) { + holder.reader.setOnImageAvailableListener(holder, executor.getBackgroundHandler()) + } + + val surface = holder.reader.surface + val canvas = surface.lockHardwareCanvas() + canvas.drawColor(Color.BLACK, PorterDuff.Mode.CLEAR) + holder.picture.draw(canvas) + surface.unlockCanvasAndPost(canvas) + } catch (t: Throwable) { + freePictureRef.set(holder) + options.logger.log(SentryLevel.ERROR, "Canvas Strategy: picture render failed", t) + } + } + + @SuppressLint("UnclosedTrace") + override fun capture(root: View) { + + 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() + + unprocessedPictureRef.set(holder) + + executor.getExecutor().submitSafely(options, "screenshot_recorder.canvas", pictureRenderTask) + } + + override fun onContentChanged() { + // ignored + } + + override fun close() { + isClosed.set(true) + screenshot?.apply { + if (!isRecycled) { + recycle() + } + } + } + + override fun lastCaptureSuccessful(): Boolean { + return lastCaptureSuccessful.get() + } + + override fun emitLastScreenshot() { + if (lastCaptureSuccessful()) { + screenshot?.let { + if (!it.isRecycled) { + screenshotRecorderCallback?.onScreenshotRecorded(it) + } + } + } + } +} + +@SuppressLint("NewApi", "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) + } + + override fun enableZ() { + delegate.enableZ() + } + + override fun disableZ() { + delegate.disableZ() + } + + override fun isOpaque(): Boolean { + return delegate.isOpaque() + } + + override fun getWidth(): Int { + return delegate.getWidth() + } + + override fun getHeight(): Int { + return delegate.getHeight() + } + + override fun getDensity(): Int { + return delegate.getDensity() + } + + 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 + } + + fun save(saveFlags: Int): Int { + return save() + } + + override fun saveLayer(bounds: RectF?, paint: Paint?, saveFlags: Int): Int { + return delegate.saveLayer(bounds, paint, saveFlags) + } + + override fun saveLayer(bounds: RectF?, paint: Paint?): Int { + return delegate.saveLayer(bounds, paint) + } + + override fun saveLayer( + left: Float, + top: Float, + right: Float, + bottom: Float, + paint: Paint?, + saveFlags: Int, + ): Int { + return delegate.saveLayer(left, top, right, bottom, paint, saveFlags) + } + + override fun saveLayer(left: Float, top: Float, right: Float, bottom: Float, paint: Paint?): Int { + return delegate.saveLayer(left, top, right, bottom, paint) + } + + 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) + } + + 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) + } + + override fun getMatrix(ctm: Matrix) { + delegate.getMatrix(ctm) + } + + override fun clipRect(rect: RectF, op: Region.Op): Boolean { + return delegate.clipRect(rect, op) + } + + 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) + } + + 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) + } + + override fun clipOutRect(rect: RectF): Boolean { + return delegate.clipOutRect(rect) + } + + override fun clipOutRect(rect: Rect): Boolean { + return delegate.clipOutRect(rect) + } + + override fun clipOutRect(left: Float, top: Float, right: Float, bottom: Float): Boolean { + return delegate.clipOutRect(left, top, right, bottom) + } + + override fun clipOutRect(left: Int, top: Int, right: Int, bottom: Int): Boolean { + return delegate.clipOutRect(left, top, right, bottom) + } + + override fun clipPath(path: Path, op: Region.Op): Boolean { + return delegate.clipPath(path, op) + } + + override fun clipPath(path: Path): Boolean { + return delegate.clipPath(path) + } + + override fun clipOutPath(path: Path): Boolean { + return delegate.clipOutPath(path) + } + + override fun getDrawFilter(): DrawFilter? { + return delegate.getDrawFilter() + } + + override fun setDrawFilter(filter: DrawFilter?) { + delegate.setDrawFilter(filter) + } + + override fun quickReject(rect: RectF, type: EdgeType): Boolean { + return delegate.quickReject(rect, type) + } + + override fun quickReject(rect: RectF): Boolean { + return delegate.quickReject(rect) + } + + override fun quickReject(path: Path, type: EdgeType): Boolean { + return delegate.quickReject(path, type) + } + + override fun quickReject(path: Path): Boolean { + return delegate.quickReject(path) + } + + override fun quickReject( + left: Float, + top: Float, + right: Float, + bottom: Float, + type: EdgeType, + ): Boolean { + return delegate.quickReject(left, top, right, bottom, type) + } + + 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) { + delegate.drawPicture(picture) + } + + override fun drawPicture(picture: Picture, dst: RectF) { + delegate.drawPicture(picture, dst) + } + + override fun drawPicture(picture: Picture, dst: Rect) { + delegate.drawPicture(picture, dst) + } + + override fun drawArc( + oval: RectF, + startAngle: Float, + sweepAngle: Float, + useCenter: Boolean, + paint: Paint, + ) { + delegate.drawArc(oval, startAngle, sweepAngle, useCenter, paint) + } + + override fun drawArc( + left: Float, + top: Float, + right: Float, + bottom: Float, + startAngle: Float, + sweepAngle: Float, + useCenter: Boolean, + paint: Paint, + ) { + delegate.drawArc(left, top, right, bottom, startAngle, sweepAngle, useCenter, paint) + } + + override fun drawARGB(a: Int, r: Int, g: Int, b: Int) { + delegate.drawARGB(a, r, g, b) + } + + override fun drawBitmap(bitmap: Bitmap, left: Float, top: Float, paint: Paint?) { + val sampledColor = sampleBitmapColor(bitmap, paint, null) + solidPaint.setColor(sampledColor) + delegate.drawRect(left, top, left + bitmap.width, top + bitmap.height, solidPaint) + } + + override fun drawBitmap(bitmap: Bitmap, src: Rect?, dst: RectF, paint: Paint?) { + val sampledColor = sampleBitmapColor(bitmap, paint, src) + solidPaint.setColor(sampledColor) + delegate.drawRect(dst, solidPaint) + } + + override fun drawBitmap(bitmap: Bitmap, src: Rect?, dst: Rect, paint: Paint?) { + val sampledColor = sampleBitmapColor(bitmap, paint, src) + solidPaint.setColor(sampledColor) + delegate.drawRect(dst, solidPaint) + } + + override fun drawBitmap( + colors: IntArray, + offset: Int, + stride: Int, + x: Float, + y: Float, + width: Int, + height: Int, + hasAlpha: Boolean, + paint: Paint?, + ) { + // TODO + } + + override fun drawBitmap( + colors: IntArray, + offset: Int, + stride: Int, + x: Int, + y: Int, + width: Int, + height: Int, + hasAlpha: Boolean, + paint: Paint?, + ) { + // TODO + } + + override fun drawBitmap(bitmap: Bitmap, matrix: Matrix, paint: Paint?) { + val sampledColor = sampleBitmapColor(bitmap, paint, null) + solidPaint.setColor(sampledColor) + 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?, + ) { + // TODO should we support this? + delegate.drawBitmapMesh( + bitmap, + meshWidth, + meshHeight, + verts, + vertOffset, + colors, + colorOffset, + paint, + ) + } + + override fun drawCircle(cx: Float, cy: Float, radius: Float, paint: Paint) { + delegate.drawCircle(cx, cy, radius, paint) + } + + override fun drawColor(color: Int) { + delegate.drawColor(color) + } + + override fun drawColor(color: Long) { + delegate.drawColor(color) + } + + override fun drawColor(color: Int, mode: PorterDuff.Mode) { + delegate.drawColor(color, mode) + } + + override fun drawColor(color: Int, mode: BlendMode) { + delegate.drawColor(color, mode) + } + + override fun drawColor(color: Long, mode: BlendMode) { + delegate.drawColor(color, mode) + } + + override fun drawLine(startX: Float, startY: Float, stopX: Float, stopY: Float, paint: Paint) { + delegate.drawLine(startX, startY, stopX, stopY, paint) + } + + override fun drawLines(pts: FloatArray, offset: Int, count: Int, paint: Paint) { + delegate.drawLines(pts, offset, count, paint) + } + + override fun drawLines(pts: FloatArray, paint: Paint) { + delegate.drawLines(pts, paint) + } + + override fun drawOval(oval: RectF, paint: Paint) { + delegate.drawOval(oval, paint) + } + + override fun drawOval(left: Float, top: Float, right: Float, bottom: Float, paint: Paint) { + delegate.drawOval(left, top, right, bottom, paint) + } + + override fun drawPaint(paint: Paint) { + delegate.drawPaint(paint) + } + + override fun drawPatch(patch: NinePatch, dst: Rect, paint: Paint?) { + delegate.drawPatch(patch, dst, paint) + } + + override fun drawPatch(patch: NinePatch, dst: RectF, paint: Paint?) { + delegate.drawPatch(patch, dst, paint) + } + + override fun drawPath(path: Path, paint: Paint) { + delegate.drawPath(path, paint) + } + + override fun drawPoint(x: Float, y: Float, paint: Paint) { + delegate.drawPoint(x, y, paint) + } + + override fun drawPoints(pts: FloatArray?, offset: Int, count: Int, paint: Paint) { + delegate.drawPoints(pts, offset, count, paint) + } + + override fun drawPoints(pts: FloatArray, paint: Paint) { + delegate.drawPoints(pts, paint) + } + + override fun drawRect(rect: RectF, paint: Paint) { + delegate.drawRect(rect, paint) + } + + override fun drawRect(r: Rect, paint: Paint) { + delegate.drawRect(r, paint) + } + + override fun drawRect(left: Float, top: Float, right: Float, bottom: Float, paint: Paint) { + delegate.drawRect(left, top, right, bottom, paint) + } + + 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) { + delegate.drawRoundRect(rect, rx, ry, paint) + } + + override fun drawRoundRect( + left: Float, + top: Float, + right: Float, + bottom: Float, + rx: Float, + ry: Float, + paint: Paint, + ) { + delegate.drawRoundRect(left, top, right, bottom, rx, ry, paint) + } + + override fun drawDoubleRoundRect( + outer: RectF, + outerRx: Float, + outerRy: Float, + inner: RectF, + innerRx: Float, + innerRy: Float, + paint: Paint, + ) { + delegate.drawDoubleRoundRect(outer, outerRx, outerRy, inner, innerRx, innerRy, paint) + } + + override fun drawDoubleRoundRect( + outer: RectF, + outerRadii: FloatArray, + inner: RectF, + innerRadii: FloatArray, + paint: Paint, + ) { + delegate.drawDoubleRoundRect(outer, outerRadii, inner, innerRadii, paint) + } + + override fun drawGlyphs( + glyphIds: IntArray, + glyphIdOffset: Int, + positions: FloatArray, + positionOffset: Int, + glyphCount: Int, + font: Font, + paint: Paint, + ) { + // TODO should we support this? + } + + 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, + ) { + // TODO should we support this? + delegate.drawVertices( + mode, + vertexCount, + verts, + vertOffset, + texs, + texOffset, + colors, + colorOffset, + indices, + indexOffset, + indexCount, + paint, + ) + // TODO should we support this? + } + + override fun drawRenderNode(renderNode: RenderNode) { + // TODO should we support this? + // delegate.drawRenderNode(renderNode) + } + + override fun drawMesh(mesh: Mesh, blendMode: BlendMode?, paint: Paint) { + // TODO should we support this? + // delegate.drawMesh(mesh, blendMode, paint) + } + + override fun drawPosText(text: CharArray, index: Int, count: Int, pos: FloatArray, paint: Paint) { + // TODO implement + } + + override fun drawPosText(text: String, pos: FloatArray, paint: Paint) { + // TODO implement + } + + 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, 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, + ) { + // TODO implement + } + + override fun drawTextOnPath( + text: String, + path: Path, + hOffset: Float, + vOffset: Float, + paint: Paint, + ) { + // TODO implement + } + + 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, 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, + ) { + text.getBounds(start, end, tmpRect) + drawMaskedText(paint, x, y) + } + + private fun sampleBitmapColor(bitmap: Bitmap, paint: Paint?, region: Rect?): Int { + if (bitmap.isRecycled) { + return Color.BLACK + } + + val cache = bitmapColorCache[bitmap] + if (cache != null && cache.first == bitmap.generationId) { + return cache.second + } else { + singlePixelCanvas.drawBitmap(bitmap.asShared(), region, singlePixelBitmapBounds, paint) + val color = singlePixelBitmap.getPixel(0, 0) + 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, + ) + } +} + +private class PictureReaderHolder( + val width: Int, + val height: Int, + val listener: (holder: PictureReaderHolder) -> Unit, +) : ImageReader.OnImageAvailableListener { + 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) + } + } +} 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..e1fff4d007 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/PixelCopyStrategy.kt @@ -0,0 +1,192 @@ +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) { + contentChanged.set(false) + + 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..b04f3c480a --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ScreenshotRecorderTest.kt @@ -0,0 +1,82 @@ +package io.sentry.android.replay + +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, + mock(), + 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/api/sentry.api b/sentry/api/sentry.api index 4bfa96f120..5bb9310112 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 @@ -3750,6 +3757,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; @@ -3766,6 +3774,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()) + } }