From dde1cf9541fcf7110e8b7347954c71ee42a7d6f8 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 30 Jun 2025 12:12:47 +0200 Subject: [PATCH 01/22] WIP --- sentry-android-replay/build.gradle.kts | 2 +- .../android/replay/ScreenshotRecorder.kt | 173 +++++++++--------- .../sentry/android/replay/WindowRecorder.kt | 128 ++++++++----- .../android/replay/util/MainLooperHandler.kt | 12 +- .../sentry/android/replay/ReplaySmokeTest.kt | 24 ++- 5 files changed, 206 insertions(+), 133 deletions(-) diff --git a/sentry-android-replay/build.gradle.kts b/sentry-android-replay/build.gradle.kts index 9cb46cc007b..a7bc87f9f83 100644 --- a/sentry-android-replay/build.gradle.kts +++ b/sentry-android-replay/build.gradle.kts @@ -39,7 +39,7 @@ android { kotlinOptions { jvmTarget = JavaVersion.VERSION_1_8.toString() - kotlinOptions.languageVersion = libs.versions.kotlin.compatible.version.get() +// kotlinOptions.languageVersion = libs.versions.kotlin.compatible.version.get() } testOptions { 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 618f25fb782..c8face7c352 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 @@ -30,7 +30,9 @@ import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarc import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode import java.io.File import java.lang.ref.WeakReference +import java.util.concurrent.CountDownLatch import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.TimeUnit.SECONDS import java.util.concurrent.atomic.AtomicBoolean import kotlin.LazyThreadSafetyMode.NONE import kotlin.math.roundToInt @@ -60,6 +62,7 @@ internal class ScreenshotRecorder( private val debugOverlayDrawable = DebugOverlayDrawable() fun capture() { + options.logger.log(DEBUG, "Capturing screenshot, isCapturing: %s", isCapturing.get()) if (!isCapturing.get()) { if (options.sessionReplay.isDebug) { options.logger.log(DEBUG, "ScreenshotRecorder is paused, not capturing screenshot") @@ -67,6 +70,13 @@ internal class ScreenshotRecorder( return } + options.logger.log( + DEBUG, + "Capturing screenshot, contentChanged: %s, lastCaptureSuccessful: %s", + contentChanged.get(), + lastCaptureSuccessful.get(), + ) + if (!contentChanged.get() && lastCaptureSuccessful.get()) { screenshotRecorderCallback?.onScreenshotRecorded(screenshot) return @@ -84,99 +94,96 @@ internal class ScreenshotRecorder( return } - // postAtFrontOfQueue to ensure the view hierarchy and bitmap are ase close in-sync as possible - mainLooperHandler.post { - 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 - } + 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) + } - // 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 - } + is TextViewHierarchyNode -> { + val textColor = + node.layout?.dominantTextColor ?: node.dominantColor ?: Color.BLACK + node.layout.getVisibleRects( + node.visibleRect, + node.paddingLeft, + node.paddingTop, + ) to textColor } - maskingPaint.setColor(color) - visibleRects.forEach { rect -> - canvas.drawRoundRect(RectF(rect), 10f, 10f, maskingPaint) - } - if (options.replayController.isDebugMaskingOverlayEnabled()) { - debugMasks.addAll(visibleRects) + 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 } + return@traverse true + } - if (options.replayController.isDebugMaskingOverlayEnabled()) { - mainLooperHandler.post { - if (debugOverlayDrawable.callback == null) { - root.overlay.add(debugOverlayDrawable) - } - debugOverlayDrawable.updateMasks(debugMasks) - root.postInvalidate() + 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) - } + 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) } } 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 7039389427b..5f1c32ac653 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 @@ -4,20 +4,17 @@ import android.annotation.TargetApi import android.graphics.Point import android.view.View import android.view.ViewTreeObserver +import io.sentry.SentryLevel.DEBUG +import io.sentry.SentryLevel.ERROR +import io.sentry.SentryLevel.WARNING import io.sentry.SentryOptions import io.sentry.android.replay.util.MainLooperHandler import io.sentry.android.replay.util.addOnPreDrawListenerSafe -import io.sentry.android.replay.util.gracefullyShutdown import io.sentry.android.replay.util.hasSize import io.sentry.android.replay.util.removeOnPreDrawListenerSafe -import io.sentry.android.replay.util.scheduleAtFixedRateSafely import io.sentry.util.AutoClosableReentrantLock import java.lang.ref.WeakReference -import java.util.concurrent.Executors import java.util.concurrent.ScheduledExecutorService -import java.util.concurrent.ScheduledFuture -import java.util.concurrent.ThreadFactory -import java.util.concurrent.TimeUnit.MILLISECONDS import java.util.concurrent.atomic.AtomicBoolean @TargetApi(26) @@ -36,25 +33,77 @@ internal class WindowRecorder( private val rootViews = ArrayList>() private var lastKnownWindowSize: Point = Point() private val rootViewsLock = AutoClosableReentrantLock() - private var recorder: ScreenshotRecorder? = null - private var capturingTask: ScheduledFuture<*>? = null - private val capturer by lazy { - Executors.newSingleThreadScheduledExecutor(RecorderExecutorServiceThreadFactory()) + @Volatile + private var capturer: Capturer? = null + + private class Capturer( + private val options: SentryOptions, + private val mainLooperHandler: MainLooperHandler, + ) : Runnable { + + var recorder: ScreenshotRecorder? = null + var config: ScreenshotRecorderConfig? = null + private val isRecording = AtomicBoolean(true) + + fun resume() { + options.logger.log(DEBUG, "Resuming the capture runnable.") + recorder?.resume() + isRecording.getAndSet(true) + val posted = mainLooperHandler.post(this) + if (!posted) { + options.logger.log(WARNING, "Failed to post the capture runnable, main looper is not ready.") + } + } + + fun pause() { + recorder?.pause() + isRecording.getAndSet(false) + } + + fun stop() { + recorder?.close() + recorder = null + isRecording.getAndSet(false) + } + + override fun run() { + // protection against the case where the capture is executed after the recording has stopped + if (!isRecording.get()) { + options.logger.log(DEBUG, "Not capturing frames, recording is not running.") + return + } + + try { + options.logger.log(DEBUG, "Capturing a frame.") + recorder?.capture() + } catch (e: Throwable) { + options.logger.log(ERROR, "Failed to capture a frame", e) + } + + options.logger.log( + DEBUG, + "Posting the capture runnable again, frame rate is ${config?.frameRate ?: 1} fps.", + ) + val posted = mainLooperHandler.postDelayed(this, 1000L / (config?.frameRate ?: 1)) + if (!posted) { + options.logger.log(WARNING, "Failed to post the capture runnable, main looper is shutting down.") + } + } } override fun onRootViewsChanged(root: View, added: Boolean) { rootViewsLock.acquire().use { if (added) { rootViews.add(WeakReference(root)) - recorder?.bind(root) + capturer?.recorder?.bind(root) determineWindowSize(root) } else { - recorder?.unbind(root) + capturer?.recorder?.unbind(root) rootViews.removeAll { it.get() == root } val newRoot = rootViews.lastOrNull()?.get() if (newRoot != null && root != newRoot) { - recorder?.bind(newRoot) + capturer?.recorder?.bind(newRoot) determineWindowSize(newRoot) } else { Unit // synchronized block wants us to return something lol @@ -102,7 +151,13 @@ internal class WindowRecorder( return } - recorder = + if (capturer == null) { + // don't recreate runnable for every config change, just update the config + capturer = Capturer(options, mainLooperHandler) + } + + capturer?.config = config + capturer?.recorder = ScreenshotRecorder( config, options, @@ -113,59 +168,44 @@ internal class WindowRecorder( val newRoot = rootViews.lastOrNull()?.get() if (newRoot != null) { - recorder?.bind(newRoot) + capturer?.recorder?.bind(newRoot) } - // TODO: change this to use MainThreadHandler and just post on the main thread with delay - // to avoid thread context switch every time - capturingTask = - capturer.scheduleAtFixedRateSafely( - options, - "$TAG.capture", + + val posted = + mainLooperHandler.postDelayed( + capturer, 100L, // delay the first run by a bit, to allow root view listener to register - 1000L / config.frameRate, - MILLISECONDS, - ) { - recorder?.capture() - } + ) + if (!posted) { + options.logger.log(WARNING, "Failed to post the capture runnable, main looper is shutting down.") + } } override fun resume() { - recorder?.resume() + capturer?.resume() } override fun pause() { - recorder?.pause() + capturer?.pause() } override fun reset() { lastKnownWindowSize.set(0, 0) rootViewsLock.acquire().use { - rootViews.forEach { recorder?.unbind(it.get()) } + rootViews.forEach { capturer?.recorder?.unbind(it.get()) } rootViews.clear() } } override fun stop() { - recorder?.close() - recorder = null - capturingTask?.cancel(false) - capturingTask = null + capturer?.stop() + capturer = null isRecording.set(false) } override fun close() { reset() stop() - capturer.gracefullyShutdown(options) - } - - private class RecorderExecutorServiceThreadFactory : ThreadFactory { - private var cnt = 0 - - override fun newThread(r: Runnable): Thread { - val ret = Thread(r, "SentryWindowRecorder-" + cnt++) - ret.setDaemon(true) - return ret - } + mainLooperHandler.removeCallbacks(capturer) } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/MainLooperHandler.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/MainLooperHandler.kt index 7c067111e7e..691cce03a78 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/MainLooperHandler.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/MainLooperHandler.kt @@ -6,7 +6,15 @@ import android.os.Looper internal class MainLooperHandler(looper: Looper = Looper.getMainLooper()) { val handler = Handler(looper) - fun post(runnable: Runnable) { - handler.post(runnable) + fun post(runnable: Runnable): Boolean { + return handler.post(runnable) + } + + fun postDelayed(runnable: Runnable?, delay: Long): Boolean { + return handler.postDelayed(runnable ?: return false, delay) + } + + fun removeCallbacks(runnable: Runnable?) { + handler.removeCallbacks(runnable ?: return) } } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt index 0b3b8097d9c..53811127910 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt @@ -18,11 +18,15 @@ import io.sentry.Scope import io.sentry.ScopeCallback import io.sentry.SentryOptions import io.sentry.SentryReplayEvent.ReplayType +import io.sentry.SystemOutLogger import io.sentry.android.replay.util.ReplayShadowMediaCodec +import io.sentry.protocol.SentryId import io.sentry.rrweb.RRWebMetaEvent import io.sentry.rrweb.RRWebVideoEvent import io.sentry.transport.CurrentDateProvider import io.sentry.transport.ICurrentDateProvider +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking import java.time.Duration import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean @@ -35,6 +39,7 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.anyLong import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.check @@ -46,6 +51,8 @@ import org.robolectric.Robolectric.buildActivity import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config import org.robolectric.shadows.ShadowPixelCopy +import java.util.concurrent.Executors +import kotlin.concurrent.thread @RunWith(AndroidJUnit4::class) @Config( @@ -57,7 +64,10 @@ class ReplaySmokeTest { @get:Rule val tmpDir = TemporaryFolder() internal class Fixture { - val options = SentryOptions() + val options = SentryOptions().apply { + setLogger(SystemOutLogger()) + isDebug = true + } val scope = Scope(options) val scopes = mock { @@ -65,7 +75,6 @@ class ReplaySmokeTest { .whenever(it) .configureScope(any()) } - var count: Int = 0 private class ImmediateHandler : Handler( @@ -75,6 +84,8 @@ class ReplaySmokeTest { } ) + private val recordingThread = Executors.newSingleThreadScheduledExecutor() + fun getSut( context: Context, dateProvider: ICurrentDateProvider = CurrentDateProvider.getInstance(), @@ -90,7 +101,14 @@ class ReplaySmokeTest { whenever(mock.handler).thenReturn(ImmediateHandler()) whenever(mock.post(any())).then { (it.arguments[0] as Runnable).run() - count++ + } + whenever(mock.postDelayed(any(), anyLong())).then { + // have to use another thread here otherwise it will block the test thread + recordingThread.schedule( + it.arguments[0] as Runnable, + it.arguments[1] as Long, + TimeUnit.MILLISECONDS, + ) } }, ) From ea48169143331dbb31a7ae9180b85b33d7669626 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 30 Jun 2025 16:24:46 +0200 Subject: [PATCH 02/22] refactor(replay): Use main thread handler to schedule replay capture --- sentry-android-replay/build.gradle.kts | 2 +- .../android/replay/ScreenshotRecorder.kt | 23 ++++++----- .../sentry/android/replay/WindowRecorder.kt | 40 +++++++++++++------ .../sentry/android/replay/ReplaySmokeTest.kt | 19 ++++----- 4 files changed, 48 insertions(+), 36 deletions(-) diff --git a/sentry-android-replay/build.gradle.kts b/sentry-android-replay/build.gradle.kts index a7bc87f9f83..9cb46cc007b 100644 --- a/sentry-android-replay/build.gradle.kts +++ b/sentry-android-replay/build.gradle.kts @@ -39,7 +39,7 @@ android { kotlinOptions { jvmTarget = JavaVersion.VERSION_1_8.toString() -// kotlinOptions.languageVersion = libs.versions.kotlin.compatible.version.get() + kotlinOptions.languageVersion = libs.versions.kotlin.compatible.version.get() } testOptions { 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 c8face7c352..2d866e6a6db 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 @@ -30,9 +30,7 @@ import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarc import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode import java.io.File import java.lang.ref.WeakReference -import java.util.concurrent.CountDownLatch import java.util.concurrent.ScheduledExecutorService -import java.util.concurrent.TimeUnit.SECONDS import java.util.concurrent.atomic.AtomicBoolean import kotlin.LazyThreadSafetyMode.NONE import kotlin.math.roundToInt @@ -62,7 +60,9 @@ internal class ScreenshotRecorder( private val debugOverlayDrawable = DebugOverlayDrawable() fun capture() { - options.logger.log(DEBUG, "Capturing screenshot, isCapturing: %s", isCapturing.get()) + if (options.sessionReplay.isDebug) { + options.logger.log(DEBUG, "Capturing screenshot, isCapturing: %s", isCapturing.get()) + } if (!isCapturing.get()) { if (options.sessionReplay.isDebug) { options.logger.log(DEBUG, "ScreenshotRecorder is paused, not capturing screenshot") @@ -70,12 +70,14 @@ internal class ScreenshotRecorder( return } - options.logger.log( - DEBUG, - "Capturing screenshot, contentChanged: %s, lastCaptureSuccessful: %s", - contentChanged.get(), - lastCaptureSuccessful.get(), - ) + if (options.sessionReplay.isDebug) { + options.logger.log( + DEBUG, + "Capturing screenshot, contentChanged: %s, lastCaptureSuccessful: %s", + contentChanged.get(), + lastCaptureSuccessful.get(), + ) + } if (!contentChanged.get() && lastCaptureSuccessful.get()) { screenshotRecorderCallback?.onScreenshotRecorded(screenshot) @@ -135,8 +137,7 @@ internal class ScreenshotRecorder( val (visibleRects, color) = when (node) { is ImageViewHierarchyNode -> { - listOf(node.visibleRect) to - screenshot.dominantColorForRect(node.visibleRect) + listOf(node.visibleRect) to screenshot.dominantColorForRect(node.visibleRect) } is TextViewHierarchyNode -> { 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 5f1c32ac653..197ec099661 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 @@ -33,8 +33,7 @@ internal class WindowRecorder( private val rootViews = ArrayList>() private var lastKnownWindowSize: Point = Point() private val rootViewsLock = AutoClosableReentrantLock() - @Volatile - private var capturer: Capturer? = null + @Volatile private var capturer: Capturer? = null private class Capturer( private val options: SentryOptions, @@ -46,12 +45,17 @@ internal class WindowRecorder( private val isRecording = AtomicBoolean(true) fun resume() { - options.logger.log(DEBUG, "Resuming the capture runnable.") + if (options.sessionReplay.isDebug) { + options.logger.log(DEBUG, "Resuming the capture runnable.") + } recorder?.resume() isRecording.getAndSet(true) val posted = mainLooperHandler.post(this) if (!posted) { - options.logger.log(WARNING, "Failed to post the capture runnable, main looper is not ready.") + options.logger.log( + WARNING, + "Failed to post the capture runnable, main looper is not ready.", + ) } } @@ -69,24 +73,33 @@ internal class WindowRecorder( override fun run() { // protection against the case where the capture is executed after the recording has stopped if (!isRecording.get()) { - options.logger.log(DEBUG, "Not capturing frames, recording is not running.") + if (options.sessionReplay.isDebug) { + options.logger.log(DEBUG, "Not capturing frames, recording is not running.") + } return } try { - options.logger.log(DEBUG, "Capturing a frame.") + if (options.sessionReplay.isDebug) { + options.logger.log(DEBUG, "Capturing a frame.") + } recorder?.capture() } catch (e: Throwable) { options.logger.log(ERROR, "Failed to capture a frame", e) } - options.logger.log( - DEBUG, - "Posting the capture runnable again, frame rate is ${config?.frameRate ?: 1} fps.", - ) + if (options.sessionReplay.isDebug) { + options.logger.log( + DEBUG, + "Posting the capture runnable again, frame rate is ${config?.frameRate ?: 1} fps.", + ) + } val posted = mainLooperHandler.postDelayed(this, 1000L / (config?.frameRate ?: 1)) if (!posted) { - options.logger.log(WARNING, "Failed to post the capture runnable, main looper is shutting down.") + options.logger.log( + WARNING, + "Failed to post the capture runnable, main looper is shutting down.", + ) } } } @@ -177,7 +190,10 @@ internal class WindowRecorder( 100L, // delay the first run by a bit, to allow root view listener to register ) if (!posted) { - options.logger.log(WARNING, "Failed to post the capture runnable, main looper is shutting down.") + options.logger.log( + WARNING, + "Failed to post the capture runnable, main looper is shutting down.", + ) } } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt index 53811127910..2e9937a62cc 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt @@ -20,14 +20,12 @@ import io.sentry.SentryOptions import io.sentry.SentryReplayEvent.ReplayType import io.sentry.SystemOutLogger import io.sentry.android.replay.util.ReplayShadowMediaCodec -import io.sentry.protocol.SentryId import io.sentry.rrweb.RRWebMetaEvent import io.sentry.rrweb.RRWebVideoEvent import io.sentry.transport.CurrentDateProvider import io.sentry.transport.ICurrentDateProvider -import kotlinx.coroutines.delay -import kotlinx.coroutines.runBlocking import java.time.Duration +import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import kotlin.test.BeforeTest @@ -51,8 +49,6 @@ import org.robolectric.Robolectric.buildActivity import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config import org.robolectric.shadows.ShadowPixelCopy -import java.util.concurrent.Executors -import kotlin.concurrent.thread @RunWith(AndroidJUnit4::class) @Config( @@ -64,10 +60,11 @@ class ReplaySmokeTest { @get:Rule val tmpDir = TemporaryFolder() internal class Fixture { - val options = SentryOptions().apply { - setLogger(SystemOutLogger()) - isDebug = true - } + val options = + SentryOptions().apply { + setLogger(SystemOutLogger()) + isDebug = true + } val scope = Scope(options) val scopes = mock { @@ -99,9 +96,7 @@ class ReplaySmokeTest { mainLooperHandler = mock { whenever(mock.handler).thenReturn(ImmediateHandler()) - whenever(mock.post(any())).then { - (it.arguments[0] as Runnable).run() - } + whenever(mock.post(any())).then { (it.arguments[0] as Runnable).run() } whenever(mock.postDelayed(any(), anyLong())).then { // have to use another thread here otherwise it will block the test thread recordingThread.schedule( From 2ab706a0693355ad61e854655f77512566702e01 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 30 Jun 2025 16:27:57 +0200 Subject: [PATCH 03/22] Remove logging in test --- .../test/java/io/sentry/android/replay/ReplaySmokeTest.kt | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt index 2e9937a62cc..2ff54348b00 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt @@ -60,11 +60,7 @@ class ReplaySmokeTest { @get:Rule val tmpDir = TemporaryFolder() internal class Fixture { - val options = - SentryOptions().apply { - setLogger(SystemOutLogger()) - isDebug = true - } + val options = SentryOptions() val scope = Scope(options) val scopes = mock { From 55c69c0e860e756334206431656957809bfc4a5f Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Mon, 7 Jul 2025 08:04:24 +0000 Subject: [PATCH 04/22] Format code --- .../src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt index 2ff54348b00..c26e6be9c41 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt @@ -18,7 +18,6 @@ import io.sentry.Scope import io.sentry.ScopeCallback import io.sentry.SentryOptions import io.sentry.SentryReplayEvent.ReplayType -import io.sentry.SystemOutLogger import io.sentry.android.replay.util.ReplayShadowMediaCodec import io.sentry.rrweb.RRWebMetaEvent import io.sentry.rrweb.RRWebVideoEvent From 1fa757f50ea94819905ee9871e583e596332993e Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 7 Jul 2025 10:10:16 +0200 Subject: [PATCH 05/22] Changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf354f1c460..b85d79bc78f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Improvements + +- Session Replay: Use main thread looper to schedule replay capture ([#4542](https://github.com/getsentry/sentry-java/pull/4542)) + ## 8.16.1-alpha.2 ### Fixes From 78118d78b754e26d4b7547594bb0e447197b6708 Mon Sep 17 00:00:00 2001 From: markushi Date: Tue, 15 Jul 2025 12:44:33 +0200 Subject: [PATCH 06/22] Initial impl of text ignoring canvas approach --- .../android/replay/ScreenshotRecorder.kt | 165 +--- .../sentry/android/replay/WindowRecorder.kt | 8 +- .../replay/screenshot/CanvasStrategy.kt | 894 ++++++++++++++++++ .../replay/screenshot/PixelCopyStrategy.kt | 168 ++++ .../replay/screenshot/ScreenshotStrategy.kt | 15 + 5 files changed, 1105 insertions(+), 145 deletions(-) create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/CanvasStrategy.kt create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/PixelCopyStrategy.kt create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/ScreenshotStrategy.kt 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 2d866e6a6db..14faebaaae0 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.os.SystemClock import android.view.View import android.view.ViewTreeObserver 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.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,23 @@ 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?, + recorder: ScheduledExecutorService, + 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 = + CanvasStrategy(recorder, screenshotRecorderCallback, options, config) + + // PixelCopyStrategy( + // recorder, + // mainLooperHandler, + // screenshotRecorderCallback, + // options, config) fun capture() { if (options.sessionReplay.isDebug) { @@ -75,12 +61,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 +84,14 @@ 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, - ) + val start = SystemClock.uptimeMillis() + screenshotStrategy.capture(root) + if (options.sessionReplay.isDebug || true) { + val duration = SystemClock.uptimeMillis() - start + options.logger.log(DEBUG, "screenshotStrategy.capture took %d ms", duration) + } } 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 197ec099661..c22204362ee 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 @@ -171,13 +171,7 @@ internal class WindowRecorder( capturer?.config = config capturer?.recorder = - ScreenshotRecorder( - config, - options, - mainLooperHandler, - replayExecutor, - screenshotRecorderCallback, - ) + ScreenshotRecorder(config, options, replayExecutor, screenshotRecorderCallback) val newRoot = rootViews.lastOrNull()?.get() if (newRoot != null) { 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 00000000000..e2060f9d944 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/CanvasStrategy.kt @@ -0,0 +1,894 @@ +@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.Image +import android.media.ImageReader +import android.os.Handler +import android.os.Looper +import android.os.SystemClock +import android.util.Log +import android.view.View +import io.sentry.SentryOptions +import io.sentry.android.replay.ScreenshotRecorderCallback +import io.sentry.android.replay.ScreenshotRecorderConfig +import io.sentry.android.replay.util.submitSafely +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.LazyThreadSafetyMode.NONE + +@SuppressLint("UseKtx") +internal class CanvasStrategy( + private val executor: ScheduledExecutorService, + private val screenshotRecorderCallback: ScreenshotRecorderCallback?, + private val options: SentryOptions, + private val config: ScreenshotRecorderConfig, +) : ScreenshotStrategy { + + private val screenshot = + Bitmap.createBitmap(config.recordingWidth, config.recordingHeight, Bitmap.Config.ARGB_8888) + private val prescaledMatrix by + lazy(NONE) { Matrix().apply { preScale(config.scaleFactorX, config.scaleFactorY) } } + private val lastCaptureSuccessful = AtomicBoolean(false) + private val debugMasks = mutableListOf() + private val contentChanged = AtomicBoolean(false) + + private val textIgnoringCanvas = TextIgnoringDelegateCanvas() + private val bitmapCanvas = Canvas() + + private var cachedReader: ImageReader? = null + private var cachedPixels: IntArray? = null + + override fun capture(root: View) { + contentChanged.set(false) + + val start = SystemClock.uptimeMillis() + + // todo doesn't necessarily mean the canvas is hardware accelerated too + // maybe use pictureCanvas.isHardwareAccelerated instead + if (root.isHardwareAccelerated) { + val picture = Picture() + val pictureCanvas = picture.beginRecording(root.width, root.height) + textIgnoringCanvas.delegate = pictureCanvas + textIgnoringCanvas.setMatrix(prescaledMatrix) + + root.draw(textIgnoringCanvas) + picture.endRecording() + + executor.submitSafely(options, "screenshot_recorder.canvas") { + val reader = getOrCreateReader(root) + reader.setOnImageAvailableListener( + { + val hwImage = it?.acquireLatestImage() + if (hwImage != null) { + val hwScreenshot = toBitmap(hwImage) + screenshotRecorderCallback?.onScreenshotRecorded(hwScreenshot) + } + }, + Handler(Looper.getMainLooper()), + ) + + val surface = reader.surface + val canvas = surface.lockHardwareCanvas() + try { + canvas.drawColor(Color.BLACK, PorterDuff.Mode.CLEAR) + picture.draw(canvas) + } finally { + surface.unlockCanvasAndPost(canvas) + } + } + } else { + bitmapCanvas.setBitmap(screenshot) + textIgnoringCanvas.delegate = bitmapCanvas + textIgnoringCanvas.setMatrix(prescaledMatrix) + root.draw(textIgnoringCanvas) + screenshotRecorderCallback?.onScreenshotRecorded(screenshot) + } + val end = SystemClock.uptimeMillis() - start + Log.w("TAG", "capture: took ${end}ms") + } + + override fun onContentChanged() { + contentChanged.set(true) + } + + override fun close() { + if (!screenshot.isRecycled) { + screenshot.recycle() + } + } + + override fun lastCaptureSuccessful(): Boolean { + return lastCaptureSuccessful.get() + } + + override fun emitLastScreenshot() { + if (lastCaptureSuccessful()) { + screenshotRecorderCallback?.onScreenshotRecorded(screenshot) + } + } + + private fun getOrCreateReader(root: View): ImageReader { + var r = cachedReader + return if (r == null || r.width != root.width || r.height != root.height) { + cachedReader?.close() // close the old reader if it exists + r = ImageReader.newInstance(root.width, root.height, PixelFormat.RGBA_8888, 1) + r + } else { + r + } + } + + private fun toBitmap(image: Image): Bitmap { + image.planes!!.let { + val plane = it[0] + val pixelCount = image.width * image.height + + if (cachedPixels == null || cachedPixels!!.size != pixelCount) { + cachedPixels = IntArray(pixelCount) + } + val pixels = cachedPixels!! + + // Do a bulk copy as it is more efficient than buffer.get then rearrange the pixels + // in memory from RGBA to ARGB for bitmap consumption + plane.buffer.asIntBuffer().get(pixels) + for (i in 0 until pixelCount) { + val color = pixels[i] + val red = color and 0xff + val green = color shr 8 and 0xff + val blue = color shr 16 and 0xff + val alpha = color shr 24 and 0xff + pixels[i] = Color.argb(alpha, red, green, blue) + } + return Bitmap.createBitmap( + pixels, + image.width, + image.height, + android.graphics.Bitmap.Config.ARGB_8888, + ) + } + } +} + +@SuppressLint("NewApi", "UseKtx") +private class TextIgnoringDelegateCanvas() : Canvas() { + + lateinit var delegate: Canvas + private val tmpRect = Rect() + private val solidPaint = Paint() + + // Hardware color sampling infrastructure + private var colorSamplingReader: ImageReader? = null + private val colorSamplingHandler = Handler(Looper.getMainLooper()) + + 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 { + return delegate.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.getSaveCount() + } + + 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) + delegate.save() + delegate.setMatrix(matrix) + delegate.drawRect(0f, 0f, bitmap.width.toFloat(), bitmap.height.toFloat(), solidPaint) + delegate.restore() + } + + 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, + ) + } + + 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) { + // ignored + } + + override fun drawPosText(text: String, pos: FloatArray, paint: Paint) { + // ignored + } + + override fun drawText(text: CharArray, index: Int, count: Int, x: Float, y: Float, paint: Paint) { + // ignored + } + + override fun drawText(text: String, x: Float, y: Float, paint: Paint) { + // ignored + } + + override fun drawText(text: String, start: Int, end: Int, x: Float, y: Float, paint: Paint) { + // ignored + } + + override fun drawText( + text: CharSequence, + start: Int, + end: Int, + x: Float, + y: Float, + paint: Paint, + ) { + // ignored + } + + override fun drawTextOnPath( + text: CharArray, + index: Int, + count: Int, + path: Path, + hOffset: Float, + vOffset: Float, + paint: Paint, + ) { + // ignored + } + + override fun drawTextOnPath( + text: String, + path: Path, + hOffset: Float, + vOffset: Float, + paint: Paint, + ) { + // ignored + } + + override fun drawTextRun( + text: CharArray, + index: Int, + count: Int, + contextIndex: Int, + contextCount: Int, + x: Float, + y: Float, + isRtl: Boolean, + paint: Paint, + ) { + // ignored + } + + override fun drawTextRun( + text: CharSequence, + start: Int, + end: Int, + contextStart: Int, + contextEnd: Int, + x: Float, + y: Float, + isRtl: Boolean, + paint: Paint, + ) { + // ignored + } + + override fun drawTextRun( + text: MeasuredText, + start: Int, + end: Int, + contextStart: Int, + contextEnd: Int, + x: Float, + y: Float, + isRtl: Boolean, + paint: Paint, + ) { + // ignored + } + + private fun getOrCreateColorSamplingReader(): ImageReader { + var reader = colorSamplingReader + if (reader == null) { + reader = ImageReader.newInstance(1, 1, PixelFormat.RGBA_8888, 1) + colorSamplingReader = reader + } + return reader + } + + private fun sampleBitmapColor(bitmap: Bitmap, paint: Paint?, region: Rect?): Int { + val reader = getOrCreateColorSamplingReader() + val surface = reader.surface + val canvas = surface.lockHardwareCanvas() + + val left = region?.left ?: 0 + val top = region?.top ?: 0 + val right = region?.right ?: (left + bitmap.width) + val bottom = region?.bottom ?: (top + bitmap.height) + try { + // Clear and draw the bitmap scaled to 1x1 + canvas.drawColor(Color.BLACK, PorterDuff.Mode.CLEAR) + canvas.drawBitmap(bitmap, Rect(left, top, right, bottom), Rect(0, 0, 1, 1), paint) + } finally { + surface.unlockCanvasAndPost(canvas) + } + + // Blocking read - this is the synchronous part + val image = reader.acquireLatestImage() + return if (image != null) { + try { + image.planes!!.let { + val plane = it[0] + val pixel = plane.buffer.asIntBuffer().get(0) + val red = pixel and 0xff + val green = pixel shr 8 and 0xff + val blue = pixel shr 16 and 0xff + val alpha = pixel shr 24 and 0xff + Color.argb(alpha, red, green, blue) + } + } finally { + image.close() + } + } else { + // Fallback to direct sampling if hardware fails + fallbackSampleBitmapColor(bitmap) + } + } + + private fun fallbackSampleBitmapColor(bitmap: Bitmap): Int { + val w = bitmap.width - 1 + val h = bitmap.height - 1 + + val colors = + intArrayOf( + bitmap.getPixel(0, 0), // left-top + bitmap.getPixel(w, 0), // right-top + bitmap.getPixel(0, h), // left-bottom + bitmap.getPixel(w, h), // right-bottom + bitmap.getPixel(w / 2, h / 2), // center + ) + + var r = 0 + var g = 0 + var b = 0 + var a = 0 + colors.forEach { color -> + r += Color.red(color) + g += Color.green(color) + b += Color.blue(color) + a += Color.alpha(color) + } + val l = colors.size + + return Color.argb(a / l, r / l, g / l, b / l) + } +} 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 00000000000..56dbb294446 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/PixelCopyStrategy.kt @@ -0,0 +1,168 @@ +package io.sentry.android.replay.screenshot + +import android.annotation.SuppressLint +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Matrix +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.SentryOptions +import io.sentry.android.replay.ScreenshotRecorderCallback +import io.sentry.android.replay.ScreenshotRecorderConfig +import io.sentry.android.replay.phoneWindow +import io.sentry.android.replay.util.MainLooperHandler +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.ScheduledExecutorService +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.LazyThreadSafetyMode.NONE + +@SuppressLint("UseKtx") +internal class PixelCopyStrategy( + private val executor: ScheduledExecutorService, + private val mainLooperHandler: MainLooperHandler, + private val screenshotRecorderCallback: ScreenshotRecorderCallback?, + private val options: SentryOptions, + private val config: ScreenshotRecorderConfig, +) : ScreenshotStrategy { + + 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 debugMasks = mutableListOf() + 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 + } + + 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 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 + ?: android.graphics.Color.BLACK + node.layout.getVisibleRects( + node.visibleRect, + node.paddingLeft, + node.paddingTop, + ) to textColor + } + + else -> { + listOf(node.visibleRect) to android.graphics.Color.BLACK + } + } + + if (options.replayController.isDebugMaskingOverlayEnabled()) { + debugMasks.addAll(visibleRects) + } + } + return@traverse true + } + + screenshotRecorderCallback?.onScreenshotRecorded(screenshot) + lastCaptureSuccessful.set(true) + contentChanged.set(false) + } + }, + mainLooperHandler.handler, + ) + } + + override fun onContentChanged() { + contentChanged.set(true) + } + + override fun lastCaptureSuccessful(): Boolean { + return lastCaptureSuccessful.get() + } + + override fun emitLastScreenshot() { + if (lastCaptureSuccessful()) { + 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 00000000000..a7b2334ea77 --- /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() +} From 9c9609066a1c95db6146a42ed87b2c62e57688a9 Mon Sep 17 00:00:00 2001 From: markushi Date: Wed, 16 Jul 2025 14:45:18 +0200 Subject: [PATCH 07/22] Extend options --- .../android/replay/ScreenshotRecorder.kt | 22 +++-- .../android/replay/ScreenshotRecorderTest.kt | 80 +++++++++++++++++++ sentry/api/sentry.api | 9 +++ .../io/sentry/ScreenshotStrategyType.java | 12 +++ .../java/io/sentry/SentryReplayOptions.java | 27 +++++++ .../io/sentry/SentryReplayOptionsTest.java | 28 +++++++ 6 files changed, 171 insertions(+), 7 deletions(-) create mode 100644 sentry-android-replay/src/test/java/io/sentry/android/replay/ScreenshotRecorderTest.kt create mode 100644 sentry/src/main/java/io/sentry/ScreenshotStrategyType.java create mode 100644 sentry/src/test/java/io/sentry/SentryReplayOptionsTest.java 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 14faebaaae0..a5e521cc372 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 @@ -7,13 +7,16 @@ import android.graphics.Bitmap import android.os.SystemClock import android.view.View import android.view.ViewTreeObserver +import io.sentry.ScreenshotStrategyType import io.sentry.SentryLevel.DEBUG 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.removeOnDrawListenerSafe import java.io.File @@ -37,13 +40,18 @@ internal class ScreenshotRecorder( private val contentChanged = AtomicBoolean(false) private val screenshotStrategy: ScreenshotStrategy = - CanvasStrategy(recorder, screenshotRecorderCallback, options, config) - - // PixelCopyStrategy( - // recorder, - // mainLooperHandler, - // screenshotRecorderCallback, - // options, config) + when (options.sessionReplay.screenshotStrategy) { + ScreenshotStrategyType.CANVAS -> + CanvasStrategy(recorder, screenshotRecorderCallback, options, config) + ScreenshotStrategyType.PIXEL_COPY -> + PixelCopyStrategy( + recorder, + MainLooperHandler(), + screenshotRecorderCallback, + options, + config, + ) + } fun capture() { if (options.sessionReplay.isDebug) { 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 00000000000..b8f59099403 --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ScreenshotRecorderTest.kt @@ -0,0 +1,80 @@ +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 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(), + 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 72d0bc1d629..7dbe9249852 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -2489,6 +2489,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 @@ -3673,6 +3680,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; @@ -3689,6 +3697,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 00000000000..28167aec6c1 --- /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.Internal +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 3cddf4705ab..9648a040cb7 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -139,6 +139,13 @@ public enum SentryReplayQuality { */ private boolean debug = false; + /** + * The screenshot strategy to use for capturing screenshots during replay recording. Defaults to + * PIXEL_COPY for better performance and quality. + */ + @ApiStatus.Internal + private ScreenshotStrategyType screenshotStrategy = ScreenshotStrategyType.PIXEL_COPY; + public SentryReplayOptions(final boolean empty, final @Nullable SdkVersion sdkVersion) { if (!empty) { setMaskAllText(true); @@ -339,4 +346,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.Internal + 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.Internal + public void setScreenshotStrategy(final @NotNull ScreenshotStrategyType screenshotStrategy) { + this.screenshotStrategy = screenshotStrategy; + } } diff --git a/sentry/src/test/java/io/sentry/SentryReplayOptionsTest.java b/sentry/src/test/java/io/sentry/SentryReplayOptionsTest.java new file mode 100644 index 00000000000..a0af1cddea9 --- /dev/null +++ b/sentry/src/test/java/io/sentry/SentryReplayOptionsTest.java @@ -0,0 +1,28 @@ +package io.sentry; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +public final class SentryReplayOptionsTest { + + @Test + public void testDefaultScreenshotStrategy() { + SentryReplayOptions options = new SentryReplayOptions(false, null); + assertEquals(ScreenshotStrategyType.PIXEL_COPY, options.getScreenshotStrategy()); + } + + @Test + public void testSetScreenshotStrategyToCanvas() { + SentryReplayOptions options = new SentryReplayOptions(false, null); + options.setScreenshotStrategy(ScreenshotStrategyType.CANVAS); + assertEquals(ScreenshotStrategyType.CANVAS, options.getScreenshotStrategy()); + } + + @Test + public void testSetScreenshotStrategyToPixelCopy() { + SentryReplayOptions options = new SentryReplayOptions(false, null); + options.setScreenshotStrategy(ScreenshotStrategyType.PIXEL_COPY); + assertEquals(ScreenshotStrategyType.PIXEL_COPY, options.getScreenshotStrategy()); + } +} From 7b6b9db9a0e84fa28975db31012456ef00c876b3 Mon Sep 17 00:00:00 2001 From: markushi Date: Mon, 21 Jul 2025 08:52:26 +0200 Subject: [PATCH 08/22] Fix race conditions --- .../replay/screenshot/CanvasStrategy.kt | 172 +++++++++--------- 1 file changed, 91 insertions(+), 81 deletions(-) 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 index e2060f9d944..6c0feeca066 100644 --- 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 @@ -26,13 +26,13 @@ import android.media.Image import android.media.ImageReader import android.os.Handler import android.os.Looper -import android.os.SystemClock -import android.util.Log import android.view.View +import io.sentry.SentryLevel import io.sentry.SentryOptions import io.sentry.android.replay.ScreenshotRecorderCallback import io.sentry.android.replay.ScreenshotRecorderConfig import io.sentry.android.replay.util.submitSafely +import java.util.LinkedList import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.atomic.AtomicBoolean import kotlin.LazyThreadSafetyMode.NONE @@ -45,76 +45,92 @@ internal class CanvasStrategy( private val config: ScreenshotRecorderConfig, ) : ScreenshotStrategy { - private val screenshot = - Bitmap.createBitmap(config.recordingWidth, config.recordingHeight, Bitmap.Config.ARGB_8888) + 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 debugMasks = mutableListOf() - private val contentChanged = AtomicBoolean(false) - private val textIgnoringCanvas = TextIgnoringDelegateCanvas() - private val bitmapCanvas = Canvas() - - private var cachedReader: ImageReader? = null private var cachedPixels: IntArray? = null + private val freePictures: MutableList = + LinkedList( + listOf( + PictureReaderHolder(config.recordingWidth, config.recordingHeight), + PictureReaderHolder(config.recordingWidth, config.recordingHeight), + ) + ) + private val unprocessedPictures: MutableList = LinkedList() + + private val pictureRenderTask = Runnable { + val holder: PictureReaderHolder? = + synchronized(unprocessedPictures) { + when { + unprocessedPictures.isNotEmpty() -> unprocessedPictures.removeAt(0) + else -> null + } + } + if (holder == null) { + return@Runnable + } + try { + holder.reader.setOnImageAvailableListener( + { + val hwImage = it?.acquireLatestImage() + if (hwImage != null) { + val hwScreenshot = toBitmap(hwImage) + screenshot = hwScreenshot + screenshotRecorderCallback?.onScreenshotRecorded(hwScreenshot) + } else { + options.logger.log(SentryLevel.DEBUG, "Canvas Strategy: hardware image is null") + } + }, + Handler(Looper.getMainLooper()), + ) + + val surface = holder.reader.surface + val canvas = surface.lockHardwareCanvas() + canvas.drawColor(Color.BLACK, PorterDuff.Mode.CLEAR) + holder.picture.draw(canvas) + surface.unlockCanvasAndPost(canvas) + } finally { + synchronized(freePictures) { freePictures.add(holder) } + } + } + override fun capture(root: View) { - contentChanged.set(false) - - val start = SystemClock.uptimeMillis() - - // todo doesn't necessarily mean the canvas is hardware accelerated too - // maybe use pictureCanvas.isHardwareAccelerated instead - if (root.isHardwareAccelerated) { - val picture = Picture() - val pictureCanvas = picture.beginRecording(root.width, root.height) - textIgnoringCanvas.delegate = pictureCanvas - textIgnoringCanvas.setMatrix(prescaledMatrix) - - root.draw(textIgnoringCanvas) - picture.endRecording() - - executor.submitSafely(options, "screenshot_recorder.canvas") { - val reader = getOrCreateReader(root) - reader.setOnImageAvailableListener( - { - val hwImage = it?.acquireLatestImage() - if (hwImage != null) { - val hwScreenshot = toBitmap(hwImage) - screenshotRecorderCallback?.onScreenshotRecorded(hwScreenshot) - } - }, - Handler(Looper.getMainLooper()), - ) - - val surface = reader.surface - val canvas = surface.lockHardwareCanvas() - try { - canvas.drawColor(Color.BLACK, PorterDuff.Mode.CLEAR) - picture.draw(canvas) - } finally { - surface.unlockCanvasAndPost(canvas) + val holder: PictureReaderHolder? = + synchronized(freePictures) { + when { + freePictures.isNotEmpty() -> freePictures.removeAt(0) + else -> null } } - } else { - bitmapCanvas.setBitmap(screenshot) - textIgnoringCanvas.delegate = bitmapCanvas - textIgnoringCanvas.setMatrix(prescaledMatrix) - root.draw(textIgnoringCanvas) - screenshotRecorderCallback?.onScreenshotRecorded(screenshot) + if (holder == null) { + options.logger.log(SentryLevel.DEBUG, "No free Picture available, skipping capture") + executor.submitSafely(options, "screenshot_recorder.canvas", pictureRenderTask) + return } - val end = SystemClock.uptimeMillis() - start - Log.w("TAG", "capture: took ${end}ms") + + val pictureCanvas = holder.picture.beginRecording(root.width, root.height) + textIgnoringCanvas.delegate = pictureCanvas + textIgnoringCanvas.setMatrix(prescaledMatrix) + root.draw(textIgnoringCanvas) + holder.picture.endRecording() + + synchronized(unprocessedPictures) { unprocessedPictures.add(holder) } + + executor.submitSafely(options, "screenshot_recorder.canvas", pictureRenderTask) } override fun onContentChanged() { - contentChanged.set(true) + // ignored } override fun close() { - if (!screenshot.isRecycled) { - screenshot.recycle() + screenshot?.apply { + if (!isRecycled) { + recycle() + } } } @@ -124,18 +140,7 @@ internal class CanvasStrategy( override fun emitLastScreenshot() { if (lastCaptureSuccessful()) { - screenshotRecorderCallback?.onScreenshotRecorded(screenshot) - } - } - - private fun getOrCreateReader(root: View): ImageReader { - var r = cachedReader - return if (r == null || r.width != root.width || r.height != root.height) { - cachedReader?.close() // close the old reader if it exists - r = ImageReader.newInstance(root.width, root.height, PixelFormat.RGBA_8888, 1) - r - } else { - r + screenshot?.let { screenshotRecorderCallback?.onScreenshotRecorded(it) } } } @@ -144,10 +149,13 @@ internal class CanvasStrategy( val plane = it[0] val pixelCount = image.width * image.height - if (cachedPixels == null || cachedPixels!!.size != pixelCount) { - cachedPixels = IntArray(pixelCount) - } - val pixels = cachedPixels!! + val pixels = + synchronized(this) { + if (cachedPixels == null || cachedPixels!!.size != pixelCount) { + cachedPixels = IntArray(pixelCount) + } + cachedPixels!! + } // Do a bulk copy as it is more efficient than buffer.get then rearrange the pixels // in memory from RGBA to ARGB for bitmap consumption @@ -171,18 +179,14 @@ internal class CanvasStrategy( } @SuppressLint("NewApi", "UseKtx") -private class TextIgnoringDelegateCanvas() : Canvas() { +private class TextIgnoringDelegateCanvas : Canvas() { lateinit var delegate: Canvas - private val tmpRect = Rect() private val solidPaint = Paint() - - // Hardware color sampling infrastructure private var colorSamplingReader: ImageReader? = null - private val colorSamplingHandler = Handler(Looper.getMainLooper()) override fun isHardwareAccelerated(): Boolean { - return false + return true } override fun setBitmap(bitmap: Bitmap?) { @@ -286,7 +290,7 @@ private class TextIgnoringDelegateCanvas() : Canvas() { } override fun getSaveCount(): Int { - return delegate.getSaveCount() + return delegate.saveCount } override fun restoreToCount(saveCount: Int) { @@ -513,10 +517,10 @@ private class TextIgnoringDelegateCanvas() : Canvas() { override fun drawBitmap(bitmap: Bitmap, matrix: Matrix, paint: Paint?) { val sampledColor = sampleBitmapColor(bitmap, paint, null) solidPaint.setColor(sampledColor) - delegate.save() + val count = delegate.save() delegate.setMatrix(matrix) delegate.drawRect(0f, 0f, bitmap.width.toFloat(), bitmap.height.toFloat(), solidPaint) - delegate.restore() + delegate.restoreToCount(count) } override fun drawBitmapMesh( @@ -892,3 +896,9 @@ private class TextIgnoringDelegateCanvas() : Canvas() { return Color.argb(a / l, r / l, g / l, b / l) } } + +private data class PictureReaderHolder(val width: Int, val height: Int) { + val picture = Picture() + val reader: ImageReader + get() = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 1) +} From 6adbca93c60090a528dee807bba2770c2c4c6e09 Mon Sep 17 00:00:00 2001 From: markushi Date: Mon, 21 Jul 2025 11:39:55 +0200 Subject: [PATCH 09/22] Cleanup --- .../android/replay/ScreenshotRecorder.kt | 2 +- .../replay/screenshot/CanvasStrategy.kt | 20 ++++++++++++------- 2 files changed, 14 insertions(+), 8 deletions(-) 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 a5e521cc372..56647945ac4 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 @@ -94,7 +94,7 @@ internal class ScreenshotRecorder( contentChanged.set(false) val start = SystemClock.uptimeMillis() screenshotStrategy.capture(root) - if (options.sessionReplay.isDebug || true) { + if (options.sessionReplay.isDebug) { val duration = SystemClock.uptimeMillis() - start options.logger.log(DEBUG, "screenshotStrategy.capture took %d ms", duration) } 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 index 6c0feeca066..bce5bfc1ead 100644 --- 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 @@ -79,10 +79,13 @@ internal class CanvasStrategy( if (hwImage != null) { val hwScreenshot = toBitmap(hwImage) screenshot = hwScreenshot + lastCaptureSuccessful.set(true) screenshotRecorderCallback?.onScreenshotRecorded(hwScreenshot) + hwImage.close() } else { options.logger.log(SentryLevel.DEBUG, "Canvas Strategy: hardware image is null") } + synchronized(freePictures) { freePictures.add(holder) } }, Handler(Looper.getMainLooper()), ) @@ -92,9 +95,7 @@ internal class CanvasStrategy( canvas.drawColor(Color.BLACK, PorterDuff.Mode.CLEAR) holder.picture.draw(canvas) surface.unlockCanvasAndPost(canvas) - } finally { - synchronized(freePictures) { freePictures.add(holder) } - } + } finally {} } override fun capture(root: View) { @@ -186,7 +187,7 @@ private class TextIgnoringDelegateCanvas : Canvas() { private var colorSamplingReader: ImageReader? = null override fun isHardwareAccelerated(): Boolean { - return true + return false } override fun setBitmap(bitmap: Bitmap?) { @@ -230,7 +231,12 @@ private class TextIgnoringDelegateCanvas : Canvas() { } override fun save(): Int { - return delegate.save() + val result = delegate.save() + return result + } + + fun save(saveFlags: Int): Int { + return save() } override fun saveLayer(bounds: RectF?, paint: Paint?, saveFlags: Int): Int { @@ -713,6 +719,7 @@ private class TextIgnoringDelegateCanvas : Canvas() { indexCount, paint, ) + // TODO should we support this? } override fun drawRenderNode(renderNode: RenderNode) { @@ -899,6 +906,5 @@ private class TextIgnoringDelegateCanvas : Canvas() { private data class PictureReaderHolder(val width: Int, val height: Int) { val picture = Picture() - val reader: ImageReader - get() = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 1) + val reader: ImageReader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 1) } From a5db3840ba8427f176f47c834569db40ae7a5c00 Mon Sep 17 00:00:00 2001 From: markushi Date: Thu, 24 Jul 2025 21:36:01 +0200 Subject: [PATCH 10/22] Draw rects for text, utilize Bitmap.asShared --- .../replay/screenshot/CanvasStrategy.kt | 121 ++++++------------ .../replay/screenshot/PixelCopyStrategy.kt | 21 ++- 2 files changed, 50 insertions(+), 92 deletions(-) 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 index bce5bfc1ead..cdf6cebe54c 100644 --- 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 @@ -184,7 +184,12 @@ private class TextIgnoringDelegateCanvas : Canvas() { lateinit var delegate: Canvas private val solidPaint = Paint() - private var colorSamplingReader: ImageReader? = null + 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) override fun isHardwareAccelerated(): Boolean { return false @@ -733,23 +738,26 @@ private class TextIgnoringDelegateCanvas : Canvas() { } override fun drawPosText(text: CharArray, index: Int, count: Int, pos: FloatArray, paint: Paint) { - // ignored + // TODO implement } override fun drawPosText(text: String, pos: FloatArray, paint: Paint) { - // ignored + // TODO implement } override fun drawText(text: CharArray, index: Int, count: Int, x: Float, y: Float, paint: Paint) { - // ignored + paint.getTextBounds(text, index, count, tmpRect) + drawMaskedText(paint, x, y) } override fun drawText(text: String, x: Float, y: Float, paint: Paint) { - // ignored + 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) { - // ignored + paint.getTextBounds(text, start, end, tmpRect) + drawMaskedText(paint, x, y) } override fun drawText( @@ -760,7 +768,8 @@ private class TextIgnoringDelegateCanvas : Canvas() { y: Float, paint: Paint, ) { - // ignored + paint.getTextBounds(text, 0, text.length, tmpRect) + drawMaskedText(paint, x, y) } override fun drawTextOnPath( @@ -772,7 +781,7 @@ private class TextIgnoringDelegateCanvas : Canvas() { vOffset: Float, paint: Paint, ) { - // ignored + // TODO implement } override fun drawTextOnPath( @@ -782,7 +791,7 @@ private class TextIgnoringDelegateCanvas : Canvas() { vOffset: Float, paint: Paint, ) { - // ignored + // TODO implement } override fun drawTextRun( @@ -796,7 +805,8 @@ private class TextIgnoringDelegateCanvas : Canvas() { isRtl: Boolean, paint: Paint, ) { - // ignored + paint.getTextBounds(text, 0, index + count, tmpRect) + drawMaskedText(paint, x, y) } override fun drawTextRun( @@ -810,7 +820,8 @@ private class TextIgnoringDelegateCanvas : Canvas() { isRtl: Boolean, paint: Paint, ) { - // ignored + paint.getTextBounds(text, start, end, tmpRect) + drawMaskedText(paint, x, y) } override fun drawTextRun( @@ -824,83 +835,23 @@ private class TextIgnoringDelegateCanvas : Canvas() { isRtl: Boolean, paint: Paint, ) { - // ignored - } - - private fun getOrCreateColorSamplingReader(): ImageReader { - var reader = colorSamplingReader - if (reader == null) { - reader = ImageReader.newInstance(1, 1, PixelFormat.RGBA_8888, 1) - colorSamplingReader = reader - } - return reader + text.getBounds(start, end, tmpRect) + drawMaskedText(paint, x, y) } private fun sampleBitmapColor(bitmap: Bitmap, paint: Paint?, region: Rect?): Int { - val reader = getOrCreateColorSamplingReader() - val surface = reader.surface - val canvas = surface.lockHardwareCanvas() - - val left = region?.left ?: 0 - val top = region?.top ?: 0 - val right = region?.right ?: (left + bitmap.width) - val bottom = region?.bottom ?: (top + bitmap.height) - try { - // Clear and draw the bitmap scaled to 1x1 - canvas.drawColor(Color.BLACK, PorterDuff.Mode.CLEAR) - canvas.drawBitmap(bitmap, Rect(left, top, right, bottom), Rect(0, 0, 1, 1), paint) - } finally { - surface.unlockCanvasAndPost(canvas) - } - - // Blocking read - this is the synchronous part - val image = reader.acquireLatestImage() - return if (image != null) { - try { - image.planes!!.let { - val plane = it[0] - val pixel = plane.buffer.asIntBuffer().get(0) - val red = pixel and 0xff - val green = pixel shr 8 and 0xff - val blue = pixel shr 16 and 0xff - val alpha = pixel shr 24 and 0xff - Color.argb(alpha, red, green, blue) - } - } finally { - image.close() - } - } else { - // Fallback to direct sampling if hardware fails - fallbackSampleBitmapColor(bitmap) - } - } - - private fun fallbackSampleBitmapColor(bitmap: Bitmap): Int { - val w = bitmap.width - 1 - val h = bitmap.height - 1 - - val colors = - intArrayOf( - bitmap.getPixel(0, 0), // left-top - bitmap.getPixel(w, 0), // right-top - bitmap.getPixel(0, h), // left-bottom - bitmap.getPixel(w, h), // right-bottom - bitmap.getPixel(w / 2, h / 2), // center - ) - - var r = 0 - var g = 0 - var b = 0 - var a = 0 - colors.forEach { color -> - r += Color.red(color) - g += Color.green(color) - b += Color.blue(color) - a += Color.alpha(color) - } - val l = colors.size - - return Color.argb(a / l, r / l, g / l, b / l) + singlePixelCanvas.drawBitmap(bitmap.asShared(), region, singlePixelBitmapBounds, paint) + return singlePixelBitmap.getPixel(0, 0) + } + + private fun drawMaskedText(paint: Paint, x: Float, y: Float) { + solidPaint.colorFilter = paint.colorFilter + solidPaint.setColor(paint.color) + solidPaint.alpha = 100 + save() + translate(x, y) + drawRect(tmpRect, solidPaint) + restore() } } 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 index 56dbb294446..c0ee7b6224e 100644 --- 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 @@ -3,7 +3,9 @@ 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 @@ -34,6 +36,7 @@ internal class PixelCopyStrategy( private val config: ScreenshotRecorderConfig, ) : ScreenshotStrategy { + 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 = @@ -78,6 +81,8 @@ internal class PixelCopyStrategy( root.traverse(viewHierarchy, options) executor.submitSafely(options, "screenshot_recorder.mask") { + val debugMasks = mutableListOf() + val canvas = Canvas(screenshot) canvas.setMatrix(prescaledMatrix) viewHierarchy.traverse { node -> @@ -85,9 +90,9 @@ internal class PixelCopyStrategy( node.visibleRect ?: return@traverse false // TODO: investigate why it returns true on RN when it shouldn't - // if (viewHierarchy.isObscured(node)) { - // return@traverse true - // } + // if (viewHierarchy.isObscured(node)) { + // return@traverse true + // } val (visibleRects, color) = when (node) { @@ -97,9 +102,7 @@ internal class PixelCopyStrategy( is TextViewHierarchyNode -> { val textColor = - node.layout?.dominantTextColor - ?: node.dominantColor - ?: android.graphics.Color.BLACK + node.layout?.dominantTextColor ?: node.dominantColor ?: Color.BLACK node.layout.getVisibleRects( node.visibleRect, node.paddingLeft, @@ -108,10 +111,14 @@ internal class PixelCopyStrategy( } else -> { - listOf(node.visibleRect) to android.graphics.Color.BLACK + 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) } From b4d8e03c13833c9a7447e37e49cbec34d6c0415c Mon Sep 17 00:00:00 2001 From: markushi Date: Fri, 25 Jul 2025 09:36:15 +0200 Subject: [PATCH 11/22] Fix processing on main thread, picture canvas size --- .../android/replay/screenshot/CanvasStrategy.kt | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) 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 index cdf6cebe54c..a90173edf68 100644 --- 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 @@ -25,7 +25,7 @@ import android.graphics.text.MeasuredText import android.media.Image import android.media.ImageReader import android.os.Handler -import android.os.Looper +import android.os.HandlerThread import android.view.View import io.sentry.SentryLevel import io.sentry.SentryOptions @@ -61,6 +61,14 @@ internal class CanvasStrategy( ) private val unprocessedPictures: MutableList = LinkedList() + private val processingThread = HandlerThread("screenshot_recorder.canvas") + private val processingHandler: Handler + + init { + processingThread.start() + processingHandler = Handler(processingThread.looper) + } + private val pictureRenderTask = Runnable { val holder: PictureReaderHolder? = synchronized(unprocessedPictures) { @@ -87,7 +95,7 @@ internal class CanvasStrategy( } synchronized(freePictures) { freePictures.add(holder) } }, - Handler(Looper.getMainLooper()), + processingHandler, ) val surface = holder.reader.surface @@ -112,7 +120,7 @@ internal class CanvasStrategy( return } - val pictureCanvas = holder.picture.beginRecording(root.width, root.height) + val pictureCanvas = holder.picture.beginRecording(config.recordingWidth, config.recordingHeight) textIgnoringCanvas.delegate = pictureCanvas textIgnoringCanvas.setMatrix(prescaledMatrix) root.draw(textIgnoringCanvas) @@ -133,6 +141,7 @@ internal class CanvasStrategy( recycle() } } + processingThread.quitSafely() } override fun lastCaptureSuccessful(): Boolean { From 49b8d9a3db20ee3b29e78351baf081ccab5948a8 Mon Sep 17 00:00:00 2001 From: markushi Date: Fri, 25 Jul 2025 09:42:52 +0200 Subject: [PATCH 12/22] Naive way to measure PixelCopy processing duration --- .../android/replay/screenshot/PixelCopyStrategy.kt | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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 index c0ee7b6224e..5568daa6833 100644 --- 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 @@ -8,6 +8,7 @@ import android.graphics.Matrix import android.graphics.Paint import android.graphics.Rect import android.graphics.RectF +import android.os.SystemClock import android.view.PixelCopy import android.view.View import io.sentry.SentryLevel.DEBUG @@ -27,7 +28,6 @@ import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.atomic.AtomicBoolean import kotlin.LazyThreadSafetyMode.NONE -@SuppressLint("UseKtx") internal class PixelCopyStrategy( private val executor: ScheduledExecutorService, private val mainLooperHandler: MainLooperHandler, @@ -48,6 +48,8 @@ internal class PixelCopyStrategy( private val debugMasks = mutableListOf() private val contentChanged = AtomicBoolean(false) + private var lastPixelCopyRequestDuration: Long = 0 + @SuppressLint("NewApi") override fun capture(root: View) { contentChanged.set(false) @@ -58,10 +60,12 @@ internal class PixelCopyStrategy( return } + val pixelCopyRequestStartTime = SystemClock.uptimeMillis() PixelCopy.request( window, screenshot, { copyResult: Int -> + val pixelCopyProcessStartTime = SystemClock.uptimeMillis() if (copyResult != PixelCopy.SUCCESS) { options.logger.log(INFO, "Failed to capture replay recording: %d", copyResult) lastCaptureSuccessful.set(false) @@ -130,9 +134,15 @@ internal class PixelCopyStrategy( lastCaptureSuccessful.set(true) contentChanged.set(false) } + + val pixelCopyProcessDuration = SystemClock.uptimeMillis() - pixelCopyProcessStartTime + val totalUiDuration = lastPixelCopyRequestDuration + pixelCopyProcessDuration + options.logger.log(INFO, "pixelCopy.request took ${lastPixelCopyRequestDuration}ms") + options.logger.log(INFO, "pixelCopy.capture took ${totalUiDuration}ms (ui thread)") }, mainLooperHandler.handler, ) + lastPixelCopyRequestDuration = SystemClock.uptimeMillis() - pixelCopyRequestStartTime } override fun onContentChanged() { From b96aa95fed419992454b602ecf8ae15609359412 Mon Sep 17 00:00:00 2001 From: markushi Date: Tue, 29 Jul 2025 08:34:07 +0200 Subject: [PATCH 13/22] Cache bitmap sampling --- .../android/replay/screenshot/CanvasStrategy.kt | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) 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 index a90173edf68..29d5b213b6d 100644 --- 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 @@ -200,6 +200,8 @@ private class TextIgnoringDelegateCanvas : Canvas() { val singlePixelBitmapBounds = Rect(0, 0, 1, 1) + private val bitmapColorCache = WeakHashMap>() + override fun isHardwareAccelerated(): Boolean { return false } @@ -849,8 +851,19 @@ private class TextIgnoringDelegateCanvas : Canvas() { } private fun sampleBitmapColor(bitmap: Bitmap, paint: Paint?, region: Rect?): Int { - singlePixelCanvas.drawBitmap(bitmap.asShared(), region, singlePixelBitmapBounds, paint) - return singlePixelBitmap.getPixel(0, 0) + 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) { From e211d3bac28dadcf69b2e1009388990abb9a97f3 Mon Sep 17 00:00:00 2001 From: markushi Date: Tue, 29 Jul 2025 08:34:31 +0200 Subject: [PATCH 14/22] Simplify text drawing --- .../android/replay/screenshot/CanvasStrategy.kt | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) 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 index 29d5b213b6d..be2dad6177a 100644 --- 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 @@ -33,9 +33,11 @@ import io.sentry.android.replay.ScreenshotRecorderCallback import io.sentry.android.replay.ScreenshotRecorderConfig import io.sentry.android.replay.util.submitSafely import java.util.LinkedList +import java.util.WeakHashMap import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.atomic.AtomicBoolean import kotlin.LazyThreadSafetyMode.NONE +import kotlin.math.roundToInt @SuppressLint("UseKtx") internal class CanvasStrategy( @@ -193,6 +195,7 @@ 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) @@ -867,13 +870,11 @@ private class TextIgnoringDelegateCanvas : Canvas() { } private fun drawMaskedText(paint: Paint, x: Float, y: Float) { - solidPaint.colorFilter = paint.colorFilter - solidPaint.setColor(paint.color) - solidPaint.alpha = 100 - save() - translate(x, y) + textPaint.colorFilter = paint.colorFilter + val color = paint.color + textPaint.color = Color.argb(100, Color.red(color), Color.green(color), Color.blue(color)) + tmpRect.offset(x.roundToInt(), y.roundToInt()) drawRect(tmpRect, solidPaint) - restore() } } From 58ade25a5c60cae972936c665cedff22d5d11568 Mon Sep 17 00:00:00 2001 From: markushi Date: Thu, 7 Aug 2025 08:33:12 +0200 Subject: [PATCH 15/22] Add trace sections --- .../android/replay/screenshot/CanvasStrategy.kt | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) 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 index be2dad6177a..4946edcc506 100644 --- 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 @@ -26,6 +26,7 @@ import android.media.Image import android.media.ImageReader import android.os.Handler import android.os.HandlerThread +import android.os.Trace import android.view.View import io.sentry.SentryLevel import io.sentry.SentryOptions @@ -72,6 +73,7 @@ internal class CanvasStrategy( } private val pictureRenderTask = Runnable { + Trace.beginSection("CanvasStrategy.pictureRenderTask") val holder: PictureReaderHolder? = synchronized(unprocessedPictures) { when { @@ -106,9 +108,13 @@ internal class CanvasStrategy( holder.picture.draw(canvas) surface.unlockCanvasAndPost(canvas) } finally {} + Trace.endSection() } + @SuppressLint("UnclosedTrace") override fun capture(root: View) { + Trace.beginSection("Canvas.capture") + Trace.beginSection("Canvas.capture.prepare_picture") val holder: PictureReaderHolder? = synchronized(freePictures) { when { @@ -121,16 +127,22 @@ internal class CanvasStrategy( executor.submitSafely(options, "screenshot_recorder.canvas", pictureRenderTask) return } + Trace.endSection() + Trace.beginSection("Canvas.capture.record_picture") val pictureCanvas = holder.picture.beginRecording(config.recordingWidth, config.recordingHeight) textIgnoringCanvas.delegate = pictureCanvas textIgnoringCanvas.setMatrix(prescaledMatrix) root.draw(textIgnoringCanvas) + Trace.endSection() + Trace.beginSection("Canvas.capture.end_recording") holder.picture.endRecording() + Trace.endSection() synchronized(unprocessedPictures) { unprocessedPictures.add(holder) } executor.submitSafely(options, "screenshot_recorder.canvas", pictureRenderTask) + Trace.endSection() } override fun onContentChanged() { @@ -743,12 +755,12 @@ private class TextIgnoringDelegateCanvas : Canvas() { override fun drawRenderNode(renderNode: RenderNode) { // TODO should we support this? - delegate.drawRenderNode(renderNode) + // delegate.drawRenderNode(renderNode) } override fun drawMesh(mesh: Mesh, blendMode: BlendMode?, paint: Paint) { // TODO should we support this? - delegate.drawMesh(mesh, blendMode, paint) + // delegate.drawMesh(mesh, blendMode, paint) } override fun drawPosText(text: CharArray, index: Int, count: Int, pos: FloatArray, paint: Paint) { From 6bf46d0eb644b033b98ebf0d82fa7e405234fdbd Mon Sep 17 00:00:00 2001 From: markushi Date: Thu, 7 Aug 2025 08:33:22 +0200 Subject: [PATCH 16/22] Default to Canvas --- sentry/src/main/java/io/sentry/SentryReplayOptions.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index 9648a040cb7..0e9c2bfde20 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -144,7 +144,7 @@ public enum SentryReplayQuality { * PIXEL_COPY for better performance and quality. */ @ApiStatus.Internal - private ScreenshotStrategyType screenshotStrategy = ScreenshotStrategyType.PIXEL_COPY; + private ScreenshotStrategyType screenshotStrategy = ScreenshotStrategyType.CANVAS; public SentryReplayOptions(final boolean empty, final @Nullable SdkVersion sdkVersion) { if (!empty) { From ae09b33dc772b35ff575a83c8c568744bf6f459c Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Tue, 9 Sep 2025 12:46:26 +0200 Subject: [PATCH 17/22] temp --- CLAUDE.md | 130 ++++++++ check-elf.sh | 113 +++++++ gradle.properties | 2 +- scripts/next-pr-number.sh | 87 ++++++ .../android/replay/ReplayIntegration.kt | 5 +- .../android/replay/ScreenshotRecorder.kt | 9 +- .../sentry/android/replay/WindowRecorder.kt | 58 +++- .../replay/screenshot/CanvasStrategy.kt | 8 - .../replay/screenshot/FrameTimingsTracker.kt | 66 ++++ .../replay/screenshot/PixelCopyStrategy.kt | 1 - .../android/replay/ScreenshotRecorderTest.kt | 2 + .../main/java/io/sentry/JsonSerializer.java | 8 +- tracebox | 286 ++++++++++++++++++ update_sentry_plugin.py | 203 +++++++++++++ 14 files changed, 951 insertions(+), 27 deletions(-) create mode 100644 CLAUDE.md create mode 100755 check-elf.sh create mode 100755 scripts/next-pr-number.sh create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/FrameTimingsTracker.kt create mode 100755 tracebox create mode 100755 update_sentry_plugin.py diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000000..565ed90a28c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,130 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is the Sentry SDK for Java and Android - a comprehensive error and performance monitoring solution. The repository is a multi-module Gradle project containing: +- Core Sentry Java SDK (`sentry/`) +- Android-specific SDK modules (`sentry-android-*`) +- Framework integrations (Spring, Spring Boot, Servlet, etc.) +- Logging integrations (Logback, Log4j2, JUL) +- HTTP client integrations (OkHttp, Apache HTTP Client) +- GraphQL, Apollo, OpenTelemetry integrations +- Sample applications for testing and demonstration + +## Build System + +**Primary Build Tool**: Gradle with Kotlin DSL (.kts files) +**Java Version**: JDK 17 required for development, targets Java 8 compatibility +**Android Requirements**: Android SDK with NDK (can be excluded if working only on Java modules) + +### Essential Build Commands + +```bash +# Clean build +make clean + +# Build and run tests +make compile +# or: ./gradlew build + +# Format code (required for CI) +make format +# or: ./gradlew spotlessApply + +# Check code formatting +make checkFormat +# or: ./gradlew spotlessJavaCheck spotlessKotlinCheck + +# Run tests and lint +make check +# or: ./gradlew check + +# Update API compatibility files (after public API changes) +make api +# or: ./gradlew apiDump + +# Generate Javadocs +make javadocs +# or: ./gradlew aggregateJavadocs + +# Create coverage reports +make createCoverageReports +# or: ./gradlew jacocoTestReport koverXmlReportRelease + +# Dry release (local Maven deploy) +make dryRelease +# or: ./gradlew aggregateJavadocs distZip --no-build-cache --no-configuration-cache +``` + +### Running Specific Tests + +```bash +# Run tests for a specific module +./gradlew :sentry:test +./gradlew :sentry-android-core:test + +# Run Android instrumentation tests +./gradlew :sentry-android-integration-tests:sentry-uitest-android:assembleAndroidTest + +# System tests (requires Python setup) +make setupPython +make systemTest +``` + +## Code Architecture + +### Core Module Structure + +**sentry/**: Core Java SDK with protocol definitions, transport, scoping, and event processing +- `io.sentry`: Main public API classes (Sentry, Hub, Scope, etc.) +- `io.sentry.protocol`: Sentry protocol data structures +- `io.sentry.transport`: HTTP transport and envelope handling +- `io.sentry.util`: Utility classes and helpers + +**sentry-android-core/**: Core Android functionality +- `io.sentry.android.core`: Android-specific implementations and integrations +- Lifecycle tracking, ANR detection, performance monitoring +- Activity/Fragment integrations, breadcrumb collection + +**Integration Modules**: Each integration follows the pattern `sentry-{integration-name}` +- Self-contained with minimal dependencies +- Consistent naming: `io.sentry.{integration}` package structure +- Include both main implementation and test classes + +### Key Patterns + +**Integration Classes**: Most integrations implement `Integration` interface for lifecycle management +**Event Processors**: Implement `EventProcessor` to modify events before sending +**Options**: Each integration typically extends `SentryOptions` or adds configuration +**Transport**: Abstracted through `ITransport` interface with HTTP and stdout implementations + +## Development Workflow + +### Code Style +- **Formatting**: Spotless is enforced - always run `make format` before committing +- **API Compatibility**: Binary compatibility is validated - update API files with `make api` after public API changes +- **Java 8 Compatibility**: Code must compile and run on Java 8 despite being built with JDK 17 + +### Testing Strategy +- **Unit Tests**: Comprehensive coverage required, use JUnit 4/5 and Mockito +- **Android Tests**: Robolectric for unit tests, instrumentation tests for integration +- **System Tests**: Python-based end-to-end testing of sample applications +- **Coverage**: Jacoco for Java/Android modules, Kover for Kotlin Multiplatform (sentry-compose) + +### Module Dependencies +- Keep integration modules lightweight with minimal external dependencies +- Android modules depend on `sentry-android-core`, which depends on `sentry` +- Spring integrations have both javax and jakarta variants for compatibility + +### Common Tasks +- **Adding New Integration**: Create new module, add to `settings.gradle.kts`, follow existing integration patterns +- **Version Updates**: Modify `gradle.properties` `versionName` property +- **Platform-Specific Code**: Use `Platform.isAndroid()` checks for conditional Android functionality + +## CI/CD Notes +- GitHub Actions run build, tests, and quality checks +- Spotless formatting and API compatibility are enforced +- Android builds require SDK/NDK setup +- Coverage reports are generated and uploaded to Codecov \ No newline at end of file diff --git a/check-elf.sh b/check-elf.sh new file mode 100755 index 00000000000..0cd8f92a982 --- /dev/null +++ b/check-elf.sh @@ -0,0 +1,113 @@ +#!/bin/bash +progname="${0##*/}" +progname="${progname%.sh}" + +# usage: check_elf_alignment.sh [path to *.so files|path to *.apk] + +cleanup_trap() { + if [ -n "${tmp}" -a -d "${tmp}" ]; then + rm -rf ${tmp} + fi + exit $1 +} + +usage() { + echo "Host side script to check the ELF alignment of shared libraries." + echo "Shared libraries are reported ALIGNED when their ELF regions are" + echo "16 KB or 64 KB aligned. Otherwise they are reported as UNALIGNED." + echo + echo "Usage: ${progname} [input-path|input-APK|input-APEX]" +} + +if [ ${#} -ne 1 ]; then + usage + exit +fi + +case ${1} in + --help | -h | -\?) + usage + exit + ;; + + *) + dir="${1}" + ;; +esac + +if ! [ -f "${dir}" -o -d "${dir}" ]; then + echo "Invalid file: ${dir}" >&2 + exit 1 +fi + +if [[ "${dir}" == *.apk ]]; then + trap 'cleanup_trap' EXIT + + echo + echo "Recursively analyzing $dir" + echo + + if { zipalign --help 2>&1 | grep -q "\-P "; }; then + echo "=== APK zip-alignment ===" + zipalign -v -c -P 16 4 "${dir}" | egrep 'lib/arm64-v8a|lib/x86_64|Verification' + echo "=========================" + else + echo "NOTICE: Zip alignment check requires build-tools version 35.0.0-rc3 or higher." + echo " You can install the latest build-tools by running the below command" + echo " and updating your \$PATH:" + echo + echo " sdkmanager \"build-tools;35.0.0-rc3\"" + fi + + dir_filename=$(basename "${dir}") + tmp=$(mktemp -d -t "${dir_filename%.apk}_out_XXXXX") + unzip "${dir}" lib/* -d "${tmp}" >/dev/null 2>&1 + dir="${tmp}" +fi + +if [[ "${dir}" == *.apex ]]; then + trap 'cleanup_trap' EXIT + + echo + echo "Recursively analyzing $dir" + echo + + dir_filename=$(basename "${dir}") + tmp=$(mktemp -d -t "${dir_filename%.apex}_out_XXXXX") + deapexer extract "${dir}" "${tmp}" || { echo "Failed to deapex." && exit 1; } + dir="${tmp}" +fi + +RED="\e[31m" +GREEN="\e[32m" +ENDCOLOR="\e[0m" + +unaligned_libs=() + +echo +echo "=== ELF alignment ===" + +matches="$(find "${dir}" -type f)" +IFS=$'\n' +for match in $matches; do + # We could recursively call this script or rewrite it to though. + [[ "${match}" == *".apk" ]] && echo "WARNING: doesn't recursively inspect .apk file: ${match}" + [[ "${match}" == *".apex" ]] && echo "WARNING: doesn't recursively inspect .apex file: ${match}" + + [[ $(file "${match}") == *"ELF"* ]] || continue + + res="$(objdump -p "${match}" | grep LOAD | awk '{ print $NF }' | head -1)" + if [[ $res =~ 2\*\*(1[4-9]|[2-9][0-9]|[1-9][0-9]{2,}) ]]; then + echo -e "${match}: ${GREEN}ALIGNED${ENDCOLOR} ($res)" + else + echo -e "${match}: ${RED}UNALIGNED${ENDCOLOR} ($res)" + unaligned_libs+=("${match}") + fi +done + +if [ ${#unaligned_libs[@]} -gt 0 ]; then + echo -e "${RED}Found ${#unaligned_libs[@]} unaligned libs (only arm64-v8a/x86_64 libs need to be aligned).${ENDCOLOR}" +elif [ -n "${dir_filename}" ]; then + echo -e "ELF Verification Successful" +fi +echo "=====================" \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index b40983f3ffe..fcec29be0f8 100644 --- a/gradle.properties +++ b/gradle.properties @@ -11,7 +11,7 @@ org.jetbrains.dokka.experimental.gradle.pluginMode=V2Enabled android.useAndroidX=true # Release information -versionName=8.18.0 +versionName=8.18.0-mah-320 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android diff --git a/scripts/next-pr-number.sh b/scripts/next-pr-number.sh new file mode 100755 index 00000000000..e6984aea161 --- /dev/null +++ b/scripts/next-pr-number.sh @@ -0,0 +1,87 @@ +#!/bin/bash + +# Script to get the next PR number for a GitHub repository +# Based on: https://github.com/ichard26/next-pr-number/blob/main/app.py + +set -euo pipefail + +# GraphQL query to get the last issue/PR/discussion number +GRAPHQL_QUERY=' +query getLastIssueNumber($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + discussions(orderBy: {field: CREATED_AT, direction: DESC}, first: 1) { + nodes { + number + } + } + issues(orderBy: {field: CREATED_AT, direction: DESC}, first: 1) { + nodes { + number + } + } + pullRequests(orderBy: {field: CREATED_AT, direction: DESC}, first: 1) { + nodes { + number + } + } + } +}' + +usage() { + echo "Usage: $0 " + echo "Example: $0 getsentry sentry" + echo "" + echo "Requires: gh CLI tool to be installed and authenticated" + echo "Run 'gh auth login' to authenticate if needed" + exit 1 +} + +# Check arguments +if [[ $# -ne 2 ]]; then + usage +fi + +OWNER="$1" +REPO="$2" + +# Check if gh CLI is available +if ! command -v gh &> /dev/null; then + echo "Error: gh CLI is not installed" + echo "Please install it from https://cli.github.com/" + exit 1 +fi + +# Check if gh is authenticated +if ! gh auth status &> /dev/null; then + echo "Error: gh CLI is not authenticated" + echo "Please run 'gh auth login' to authenticate" + exit 1 +fi + +# Make GraphQL request using gh +response=$(gh api graphql -f query="$GRAPHQL_QUERY" -f owner="$OWNER" -f name="$REPO") + +# Check if the request was successful +if [[ $? -ne 0 ]]; then + echo "Error: Failed to make GraphQL request" + exit 1 +fi + +# Parse the response and extract the highest number +highest_number=$(echo "$response" | jq -r ' + .data.repository | + [ + (.discussions.nodes[]? | .number // 0), + (.issues.nodes[]? | .number // 0), + (.pullRequests.nodes[]? | .number // 0) + ] | + max // 0 +') + +# Calculate next number +if [[ "$highest_number" != "null" && "$highest_number" -gt 0 ]]; then + next_number=$((highest_number + 1)) + echo "$next_number" +else + echo "1" +fi diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index e6cce23aaa1..c498e0a4ca8 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -30,6 +30,7 @@ import io.sentry.android.replay.capture.CaptureStrategy.ReplaySegment import io.sentry.android.replay.capture.SessionCaptureStrategy import io.sentry.android.replay.gestures.GestureRecorder import io.sentry.android.replay.gestures.TouchRecorderCallback +import io.sentry.android.replay.screenshot.FrameTimingsTracker import io.sentry.android.replay.util.MainLooperHandler import io.sentry.android.replay.util.appContext import io.sentry.android.replay.util.gracefullyShutdown @@ -102,6 +103,8 @@ public class ReplayIntegration( private var gestureRecorder: GestureRecorder? = null private val random by lazy { Random() } internal val rootViewsSpy by lazy { RootViewsSpy.install() } + + private val framesTracker = FrameTimingsTracker(context) private val replayExecutor by lazy { Executors.newSingleThreadScheduledExecutor(ReplayExecutorServiceThreadFactory()) } @@ -139,7 +142,7 @@ public class ReplayIntegration( this.scopes = scopes recorder = recorderProvider?.invoke() - ?: WindowRecorder(options, this, this, mainLooperHandler, replayExecutor) + ?: WindowRecorder(options, this, this, mainLooperHandler, replayExecutor, framesTracker) gestureRecorder = gestureRecorderProvider?.invoke() ?: GestureRecorder(options, this) isEnabled.set(true) 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 f898fc7807a..63ff37340d4 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 @@ -5,6 +5,7 @@ import android.annotation.TargetApi import android.content.Context import android.graphics.Bitmap import android.os.SystemClock +import android.util.Log import android.view.View import android.view.ViewTreeObserver import io.sentry.ScreenshotStrategyType @@ -51,7 +52,7 @@ internal class ScreenshotRecorder( screenshotRecorderCallback, options, config, - debugOverlayDrawable + debugOverlayDrawable, ) } @@ -96,10 +97,8 @@ internal class ScreenshotRecorder( contentChanged.set(false) val start = SystemClock.uptimeMillis() screenshotStrategy.capture(root) - if (options.sessionReplay.isDebug) { - val duration = SystemClock.uptimeMillis() - start - options.logger.log(DEBUG, "screenshotStrategy.capture took %d ms", duration) - } + val duration = SystemClock.uptimeMillis() - start + Log.d("TAG", "Canvas.capture took ${duration}ms") } catch (e: Throwable) { options.logger.log(WARNING, "Failed to capture replay recording", e) } 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 9ad9c40078a..67e350bc3c1 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,12 +2,15 @@ package io.sentry.android.replay import android.annotation.TargetApi import android.graphics.Point +import android.os.Build +import android.util.Log import android.view.View import android.view.ViewTreeObserver import io.sentry.SentryLevel.DEBUG import io.sentry.SentryLevel.ERROR import io.sentry.SentryLevel.WARNING import io.sentry.SentryOptions +import io.sentry.android.replay.screenshot.FrameTimingsTracker import io.sentry.android.replay.util.MainLooperHandler import io.sentry.android.replay.util.addOnPreDrawListenerSafe import io.sentry.android.replay.util.hasSize @@ -24,6 +27,7 @@ internal class WindowRecorder( private val windowCallback: WindowCallback, private val mainLooperHandler: MainLooperHandler, private val replayExecutor: ScheduledExecutorService, + private val frameTimingsTracker: FrameTimingsTracker ) : Recorder, OnRootViewsChangedListener { private val isRecording = AtomicBoolean(false) @@ -31,17 +35,25 @@ internal class WindowRecorder( private var lastKnownWindowSize: Point = Point() private val rootViewsLock = AutoClosableReentrantLock() private val capturerLock = AutoClosableReentrantLock() - @Volatile private var capturer: Capturer? = null + @Volatile + private var capturer: Capturer? = null private class Capturer( private val options: SentryOptions, private val mainLooperHandler: MainLooperHandler, + private val frameTimingsTracker: FrameTimingsTracker, ) : Runnable { var recorder: ScreenshotRecorder? = null var config: ScreenshotRecorderConfig? = null private val isRecording = AtomicBoolean(true) + private val maxCaptureDelayMs = 166L // ~10 frames @ 60fps + + private var currentCaptureDelay = 0L + + private var rootView = WeakReference(null) + fun resume() { if (options.sessionReplay.isDebug) { options.logger.log(DEBUG, "Resuming the capture runnable.") @@ -79,22 +91,48 @@ internal class WindowRecorder( return } + var delay = 1000L / (config?.frameRate ?: 1) + + val rootView = rootView.get() + val isViewIdle = + if (rootView != null) { + !rootView.isDirty && !rootView.isLayoutRequested && !rootView.isInLayout + } else { + false + } + + Log.d("TAG", "View is idle: $isViewIdle") + val frameTrackerIdle = frameTimingsTracker.isIdle() + try { - if (options.sessionReplay.isDebug) { - options.logger.log(DEBUG, "Capturing a frame.") + if ((isViewIdle) || currentCaptureDelay > maxCaptureDelayMs) { + if (options.sessionReplay.isDebug) { + Log.d("TAG", "Capturing a frame.") + } + currentCaptureDelay = 0L + recorder?.capture() + } else { + delay = 16 + currentCaptureDelay += delay + if (options.sessionReplay.isDebug) { + Log.d("TAG", "Skipping capture of this frame, app is not idle.") + } + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + android.os.Trace.setCounter("sentry.capture_delay", currentCaptureDelay) } - recorder?.capture() } catch (e: Throwable) { options.logger.log(ERROR, "Failed to capture a frame", e) } + if (options.sessionReplay.isDebug) { options.logger.log( DEBUG, "Posting the capture runnable again, frame rate is ${config?.frameRate ?: 1} fps.", ) } - val posted = mainLooperHandler.postDelayed(this, 1000L / (config?.frameRate ?: 1)) + val posted = mainLooperHandler.postDelayed(this, delay) if (!posted) { options.logger.log( WARNING, @@ -102,12 +140,17 @@ internal class WindowRecorder( ) } } + + fun bind(newRoot: View) { + rootView = WeakReference(newRoot) + } } override fun onRootViewsChanged(root: View, added: Boolean) { rootViewsLock.acquire().use { if (added) { rootViews.add(WeakReference(root)) + capturer?.bind(root) capturer?.recorder?.bind(root) determineWindowSize(root) } else { @@ -168,7 +211,7 @@ internal class WindowRecorder( capturerLock.acquire().use { if (capturer == null) { // don't recreate runnable for every config change, just update the config - capturer = Capturer(options, mainLooperHandler) + capturer = Capturer(options, mainLooperHandler, frameTimingsTracker) } } } @@ -180,11 +223,12 @@ internal class WindowRecorder( options, mainLooperHandler, replayExecutor, - screenshotRecorderCallback, + screenshotRecorderCallback ) val newRoot = rootViews.lastOrNull()?.get() if (newRoot != null) { + capturer?.bind(newRoot) capturer?.recorder?.bind(newRoot) } 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 index 4946edcc506..22faf9b9287 100644 --- 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 @@ -73,7 +73,6 @@ internal class CanvasStrategy( } private val pictureRenderTask = Runnable { - Trace.beginSection("CanvasStrategy.pictureRenderTask") val holder: PictureReaderHolder? = synchronized(unprocessedPictures) { when { @@ -108,13 +107,11 @@ internal class CanvasStrategy( holder.picture.draw(canvas) surface.unlockCanvasAndPost(canvas) } finally {} - Trace.endSection() } @SuppressLint("UnclosedTrace") override fun capture(root: View) { Trace.beginSection("Canvas.capture") - Trace.beginSection("Canvas.capture.prepare_picture") val holder: PictureReaderHolder? = synchronized(freePictures) { when { @@ -127,17 +124,12 @@ internal class CanvasStrategy( executor.submitSafely(options, "screenshot_recorder.canvas", pictureRenderTask) return } - Trace.endSection() - Trace.beginSection("Canvas.capture.record_picture") val pictureCanvas = holder.picture.beginRecording(config.recordingWidth, config.recordingHeight) textIgnoringCanvas.delegate = pictureCanvas textIgnoringCanvas.setMatrix(prescaledMatrix) root.draw(textIgnoringCanvas) - Trace.endSection() - Trace.beginSection("Canvas.capture.end_recording") holder.picture.endRecording() - Trace.endSection() synchronized(unprocessedPictures) { unprocessedPictures.add(holder) } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/FrameTimingsTracker.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/FrameTimingsTracker.kt new file mode 100644 index 00000000000..31cbc251e06 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/FrameTimingsTracker.kt @@ -0,0 +1,66 @@ +package io.sentry.android.replay.screenshot + +import android.app.Activity +import android.app.Application +import android.content.Context +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.view.FrameMetrics +import android.view.Window + +internal class FrameTimingsTracker(app: Context) { + + companion object { + private const val ANIMATION_THRESHOLD_NS = 1000000L // 1ms + private const val LAYOUT_THRESHOLD_NS = 500000L // 0.5ms + } + + @Volatile + private var lastAnimDuration: Long = 0 + + @Volatile + private var lastTotalDuration: Long = 0 + + @Volatile + private var lastLayoutDuration: Long = 0 + + private val handler = Handler(Looper.getMainLooper()) + + init { + (app as Application?)?.registerActivityLifecycleCallbacks(object : Application.ActivityLifecycleCallbacks { + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + activity.window?.addOnFrameMetricsAvailableListener(listener, handler) + } + } + + override fun onActivityDestroyed(activity: Activity) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + activity.window?.removeOnFrameMetricsAvailableListener(listener) + } + } + + override fun onActivityStarted(activity: Activity) {} + override fun onActivityResumed(activity: Activity) {} + override fun onActivityPaused(activity: Activity) {} + override fun onActivityStopped(activity: Activity) {} + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {} + }) + } + + private val listener = Window.OnFrameMetricsAvailableListener { _, frameMetrics, _ -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + lastTotalDuration = frameMetrics.getMetric(FrameMetrics.TOTAL_DURATION) + lastAnimDuration = frameMetrics.getMetric(FrameMetrics.ANIMATION_DURATION) + lastLayoutDuration = frameMetrics.getMetric(FrameMetrics.LAYOUT_MEASURE_DURATION) + } + } + + fun isIdle(): Boolean { + Log.d("TAG", "isIdle: lastTotalDuration: ${lastTotalDuration/1000000.0}, lastAnimDuration: ${lastAnimDuration/1000000.0}, layoutDuration: ${lastLayoutDuration/1000000.0}") + return lastAnimDuration < ANIMATION_THRESHOLD_NS && lastLayoutDuration < LAYOUT_THRESHOLD_NS + } +} 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 index 47d0c24d6c7..82341405734 100644 --- 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 @@ -8,7 +8,6 @@ import android.graphics.Matrix import android.graphics.Paint import android.graphics.Rect import android.graphics.RectF -import android.os.SystemClock import android.view.PixelCopy import android.view.View import io.sentry.SentryLevel.DEBUG 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 index b8f59099403..b04f3c480a8 100644 --- 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 @@ -7,6 +7,7 @@ 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 @@ -24,6 +25,7 @@ class ScreenshotRecorderTest { return ScreenshotRecorder( ScreenshotRecorderConfig(100, 100, 1f, 1f, 1, 1000), options, + mock(), mock(), null, ) diff --git a/sentry/src/main/java/io/sentry/JsonSerializer.java b/sentry/src/main/java/io/sentry/JsonSerializer.java index 65e672bea13..80b58798803 100644 --- a/sentry/src/main/java/io/sentry/JsonSerializer.java +++ b/sentry/src/main/java/io/sentry/JsonSerializer.java @@ -204,10 +204,10 @@ public void serialize(@NotNull T entity, @NotNull Writer writer) throws IOEx Objects.requireNonNull(entity, "The entity is required."); Objects.requireNonNull(writer, "The Writer object is required."); - if (options.getLogger().isEnabled(SentryLevel.DEBUG)) { - String serialized = serializeToString(entity, options.isEnablePrettySerializationOutput()); - options.getLogger().log(SentryLevel.DEBUG, "Serializing object: %s", serialized); - } +// if (options.getLogger().isEnabled(SentryLevel.DEBUG)) { +// String serialized = serializeToString(entity, options.isEnablePrettySerializationOutput()); +// options.getLogger().log(SentryLevel.DEBUG, "Serializing object: %s", serialized); +// } JsonObjectWriter jsonObjectWriter = new JsonObjectWriter(writer, options.getMaxDepth()); jsonObjectWriter.value(options.getLogger(), entity); writer.flush(); diff --git a/tracebox b/tracebox new file mode 100755 index 00000000000..66256e9f937 --- /dev/null +++ b/tracebox @@ -0,0 +1,286 @@ +#!/usr/bin/env python3 +# Copyright (C) 2021 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! +# DO NOT EDIT. Auto-generated by tools/gen_amalgamated_python_tools +# !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +# This file should do the same thing when being invoked in any of these ways: +# ./tracebox +# python tracebox +# bash tracebox +# cat ./tracebox | bash +# cat ./tracebox | python - + +BASH_FALLBACK=""" " +exec python3 - "$@" <<'#'EOF +#""" # yapf: disable + + +# ----- Amalgamator: begin of python/perfetto/prebuilts/manifests/tracebox.py +# This file has been generated by: tools/roll-prebuilts v51.2 +TRACEBOX_MANIFEST = [{ + 'arch': + 'mac-amd64', + 'file_name': + 'tracebox', + 'file_size': + 1729080, + 'url': + 'https://commondatastorage.googleapis.com/perfetto-luci-artifacts/v51.2/mac-amd64/tracebox', + 'sha256': + '1448ac96e30529fd9f184eb7b7aaf3222e13636c10fadcfebb397c27887894dc', + 'platform': + 'darwin', + 'machine': ['x86_64'] +}, { + 'arch': + 'mac-arm64', + 'file_name': + 'tracebox', + 'file_size': + 1591720, + 'url': + 'https://commondatastorage.googleapis.com/perfetto-luci-artifacts/v51.2/mac-arm64/tracebox', + 'sha256': + 'f4a0cc31acf9745d457aef9fec1ac4ff7f0e136329bb65904b9180aca2a7b7e1', + 'platform': + 'darwin', + 'machine': ['arm64'] +}, { + 'arch': + 'linux-amd64', + 'file_name': + 'tracebox', + 'file_size': + 2505648, + 'url': + 'https://commondatastorage.googleapis.com/perfetto-luci-artifacts/v51.2/linux-amd64/tracebox', + 'sha256': + '4b81495f019705dab925ad2400519b4ddb4f29e91adb9973c2c0e0276e4da5bd', + 'platform': + 'linux', + 'machine': ['x86_64'] +}, { + 'arch': + 'linux-arm', + 'file_name': + 'tracebox', + 'file_size': + 1532472, + 'url': + 'https://commondatastorage.googleapis.com/perfetto-luci-artifacts/v51.2/linux-arm/tracebox', + 'sha256': + 'd9044b9857f1cf0939de61b3589effac6a1250f17bf9f550748ccfb0aecc2fe1', + 'platform': + 'linux', + 'machine': ['armv6l', 'armv7l', 'armv8l'] +}, { + 'arch': + 'linux-arm64', + 'file_name': + 'tracebox', + 'file_size': + 2386960, + 'url': + 'https://commondatastorage.googleapis.com/perfetto-luci-artifacts/v51.2/linux-arm64/tracebox', + 'sha256': + 'f601fc0c75b234b9f4c56bec7dc6868e2dc2ddd9e660a30834537edfbcdb1efb', + 'platform': + 'linux', + 'machine': ['aarch64'] +}, { + 'arch': + 'android-arm', + 'file_name': + 'tracebox', + 'file_size': + 1403716, + 'url': + 'https://commondatastorage.googleapis.com/perfetto-luci-artifacts/v51.2/android-arm/tracebox', + 'sha256': + '75ce98c1147bca933bde322deddb5e119272293b04b41590d1738258e3b6721a' +}, { + 'arch': + 'android-arm64', + 'file_name': + 'tracebox', + 'file_size': + 2213288, + 'url': + 'https://commondatastorage.googleapis.com/perfetto-luci-artifacts/v51.2/android-arm64/tracebox', + 'sha256': + 'a11474acf7965644dde5c0c39892894f32ccad1b0e63ef73196d49dd055ebdfb' +}, { + 'arch': + 'android-x86', + 'file_name': + 'tracebox', + 'file_size': + 2426604, + 'url': + 'https://commondatastorage.googleapis.com/perfetto-luci-artifacts/v51.2/android-x86/tracebox', + 'sha256': + '700e43ecd1f3639b8a70f7433f821f9613e54085dfa80d4f656a4fdce70cf5b6' +}, { + 'arch': + 'android-x64', + 'file_name': + 'tracebox', + 'file_size': + 2254192, + 'url': + 'https://commondatastorage.googleapis.com/perfetto-luci-artifacts/v51.2/android-x64/tracebox', + 'sha256': + '945d747522ad53e7e193c1a2c1ae0809b59b7da8a19f031458438094f10b5d36' +}] + +# ----- Amalgamator: end of python/perfetto/prebuilts/manifests/tracebox.py + +# ----- Amalgamator: begin of python/perfetto/prebuilts/perfetto_prebuilts.py +# Copyright (C) 2021 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Functions to fetch pre-pinned Perfetto prebuilts. + +This function is used in different places: +- Into the //tools/{trace_processor, traceconv} scripts, which are just plain + wrappers around executables. +- Into the //tools/{heap_profiler, record_android_trace} scripts, which contain + some other hand-written python code. + +The manifest argument looks as follows: +TRACECONV_MANIFEST = [ + { + 'arch': 'mac-amd64', + 'file_name': 'traceconv', + 'file_size': 7087080, + 'url': https://commondatastorage.googleapis.com/.../trace_to_text', + 'sha256': 7d957c005b0dc130f5bd855d6cec27e060d38841b320d04840afc569f9087490', + 'platform': 'darwin', + 'machine': 'x86_64' + }, + ... +] + +The intended usage is: + + from perfetto.prebuilts.manifests.traceconv import TRACECONV_MANIFEST + bin_path = get_perfetto_prebuilt(TRACECONV_MANIFEST) + subprocess.call(bin_path, ...) +""" + +import hashlib +import os +import platform +import random +import subprocess +import sys + + +def download_or_get_cached(file_name, url, sha256): + """ Downloads a prebuilt or returns a cached version + + The first time this is invoked, it downloads the |url| and caches it into + ~/.local/share/perfetto/prebuilts/$tool_name. On subsequent invocations it + just runs the cached version. + """ + dir = os.path.join( + os.path.expanduser('~'), '.local', 'share', 'perfetto', 'prebuilts') + os.makedirs(dir, exist_ok=True) + bin_path = os.path.join(dir, file_name) + sha256_path = os.path.join(dir, file_name + '.sha256') + needs_download = True + + # Avoid recomputing the SHA-256 on each invocation. The SHA-256 of the last + # download is cached into file_name.sha256, just check if that matches. + if os.path.exists(bin_path) and os.path.exists(sha256_path): + with open(sha256_path, 'rb') as f: + digest = f.read().decode() + if digest == sha256: + needs_download = False + + if needs_download: # The file doesn't exist or the SHA256 doesn't match. + # Use a unique random file to guard against concurrent executions. + # See https://github.com/google/perfetto/issues/786 . + tmp_path = '%s.%d.tmp' % (bin_path, random.randint(0, 100000)) + print('Downloading ' + url) + subprocess.check_call(['curl', '-f', '-L', '-#', '-o', tmp_path, url]) + with open(tmp_path, 'rb') as fd: + actual_sha256 = hashlib.sha256(fd.read()).hexdigest() + if actual_sha256 != sha256: + raise Exception('Checksum mismatch for %s (actual: %s, expected: %s)' % + (url, actual_sha256, sha256)) + os.chmod(tmp_path, 0o755) + os.replace(tmp_path, bin_path) + with open(tmp_path, 'w') as f: + f.write(sha256) + os.replace(tmp_path, sha256_path) + return bin_path + + +def get_perfetto_prebuilt(manifest, soft_fail=False, arch=None): + """ Downloads the prebuilt, if necessary, and returns its path on disk. """ + plat = sys.platform.lower() + machine = platform.machine().lower() + manifest_entry = None + for entry in manifest: + # If the caller overrides the arch, just match that (for Android prebuilts). + if arch: + if entry.get('arch') == arch: + manifest_entry = entry + break + continue + # Otherwise guess the local machine arch. + if entry.get('platform') == plat and machine in entry.get('machine', []): + manifest_entry = entry + break + if manifest_entry is None: + if soft_fail: + return None + raise Exception( + ('No prebuilts available for %s-%s\n' % (plat, machine)) + + 'See https://perfetto.dev/docs/contributing/build-instructions') + + return download_or_get_cached( + file_name=manifest_entry['file_name'], + url=manifest_entry['url'], + sha256=manifest_entry['sha256']) + + +def run_perfetto_prebuilt(manifest): + bin_path = get_perfetto_prebuilt(manifest) + if sys.platform.lower() == 'win32': + sys.exit(subprocess.check_call([bin_path, *sys.argv[1:]])) + os.execv(bin_path, [bin_path] + sys.argv[1:]) + + +# ----- Amalgamator: end of python/perfetto/prebuilts/perfetto_prebuilts.py + +if __name__ == '__main__': + run_perfetto_prebuilt(TRACEBOX_MANIFEST) + +#EOF diff --git a/update_sentry_plugin.py b/update_sentry_plugin.py new file mode 100755 index 00000000000..84a92d765a1 --- /dev/null +++ b/update_sentry_plugin.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +""" +Script to update Sentry Android Gradle Plugin versions and publish to Maven Local. + +This script: +1. Increments the VERSION_NAME in gradle.properties files +2. Publishes plugins to Maven Local +3. Updates the checkmate app build.gradle with new versions +""" + +import os +import re +import subprocess +import sys +from pathlib import Path + +# File paths +KOTLIN_COMPILER_GRADLE_PROPERTIES = "/Users/markushi/sentry/sentry-android-gradle-plugin/sentry-kotlin-compiler-plugin/gradle.properties" +PLUGIN_BUILD_GRADLE_PROPERTIES = "/Users/markushi/sentry/sentry-android-gradle-plugin/plugin-build/gradle.properties" +CHECKMATE_BUILD_GRADLE = "/Users/markushi/p/checkmate/app/build.gradle" + +# Directory paths for gradle commands +KOTLIN_COMPILER_DIR = "/Users/markushi/sentry/sentry-android-gradle-plugin/sentry-kotlin-compiler-plugin" +PLUGIN_BUILD_DIR = "/Users/markushi/sentry/sentry-android-gradle-plugin/plugin-build" +CHECKMATE_APP_DIR = "/Users/markushi/p/checkmate/app/" + +def increment_version(version_string): + """ + Increment the patch version of a version string. + Example: 5.8.0-mah-018 -> 5.8.0-mah-019 + """ + # Pattern to match version like 5.8.0-mah-018 + pattern = r'(\d+\.\d+\.\d+-\w+-?)(\d+)' + match = re.search(pattern, version_string) + + if match: + prefix = match.group(1) + number = int(match.group(2)) + new_number = number + 1 + return f"{prefix}{new_number:03d}" + else: + # Fallback: just increment the last number found + pattern = r'(\d+)$' + match = re.search(pattern, version_string) + if match: + number = int(match.group(1)) + new_number = number + 1 + return re.sub(r'\d+$', str(new_number), version_string) + else: + raise ValueError(f"Could not parse version string: {version_string}") + +def update_gradle_properties(file_path, version_key, new_version): + """Update a gradle.properties file with a new version.""" + if not os.path.exists(file_path): + raise FileNotFoundError(f"File not found: {file_path}") + + with open(file_path, 'r') as f: + content = f.read() + + # Update the version line + pattern = rf'^{version_key}\s*=\s*.*$' + replacement = f'{version_key} = {new_version}' + new_content = re.sub(pattern, replacement, content, flags=re.MULTILINE) + + if new_content == content: + print(f"Warning: No {version_key} found in {file_path}") + return False + + with open(file_path, 'w') as f: + f.write(new_content) + + print(f"Updated {file_path}: {version_key} = {new_version}") + return True + +def get_current_version(file_path, version_key): + """Get the current version from a gradle.properties file.""" + if not os.path.exists(file_path): + raise FileNotFoundError(f"File not found: {file_path}") + + with open(file_path, 'r') as f: + content = f.read() + + pattern = rf'^{version_key}\s*=\s*(.+)$' + match = re.search(pattern, content, re.MULTILINE) + + if match: + return match.group(1).strip() + else: + raise ValueError(f"Could not find {version_key} in {file_path}") + +def run_gradle_command(directory, command, print_output=False): + """Run a gradle command in the specified directory.""" + print(f"Running '{command}' in {directory}") + + try: + result = subprocess.run( + command, + cwd=directory, + shell=True, + check=True, + capture_output=True, + text=True + ) + print(f"✓ Command completed successfully") + if print_output: + print(result.stdout) + return True + except subprocess.CalledProcessError as e: + print(f"✗ Command failed with exit code {e.returncode}") + print(f"stdout: {e.stdout}") + print(f"stderr: {e.stderr}") + return False + +def update_checkmate_build_gradle(file_path, new_version): + """Update the checkmate app build.gradle with new plugin versions.""" + if not os.path.exists(file_path): + raise FileNotFoundError(f"File not found: {file_path}") + + with open(file_path, 'r') as f: + content = f.read() + + # Update both plugin versions + patterns = [ + (r'id "io\.sentry\.android\.gradle" version "[^"]*"', + f'id "io.sentry.android.gradle" version "{new_version}"'), + (r'id "io\.sentry\.kotlin\.compiler\.gradle" version "[^"]*"', + f'id "io.sentry.kotlin.compiler.gradle" version "{new_version}"') + ] + + new_content = content + updated = False + + for pattern, replacement in patterns: + if re.search(pattern, new_content): + new_content = re.sub(pattern, replacement, new_content) + updated = True + + if not updated: + print(f"Warning: No Sentry plugin versions found in {file_path}") + return False + + with open(file_path, 'w') as f: + f.write(new_content) + + print(f"Updated {file_path} with version {new_version}") + return True + +def main(): + """Main function to orchestrate the version update process.""" + try: + # Step 1: Get current versions and increment them + print("=== Step 1: Getting current versions ===") + + current_kotlin_version = get_current_version(KOTLIN_COMPILER_GRADLE_PROPERTIES, "VERSION_NAME") + current_plugin_version = get_current_version(PLUGIN_BUILD_GRADLE_PROPERTIES, "version") + + print(f"Current Kotlin compiler version: {current_kotlin_version}") + print(f"Current plugin build version: {current_plugin_version}") + + # Check if versions are the same + if current_kotlin_version != current_plugin_version: + print("Warning: Versions are different between the two gradle.properties files") + + # Increment version (use the kotlin compiler version as reference) + new_version = increment_version(current_kotlin_version) + print(f"New version: {new_version}") + + # Step 2: Update gradle.properties files + print("\n=== Step 2: Updating gradle.properties files ===") + + update_gradle_properties(KOTLIN_COMPILER_GRADLE_PROPERTIES, "VERSION_NAME", new_version) + update_gradle_properties(PLUGIN_BUILD_GRADLE_PROPERTIES, "version", new_version) + + # Step 3: Publish to Maven Local + print("\n=== Step 3: Publishing to Maven Local ===") + + success1 = run_gradle_command(KOTLIN_COMPILER_DIR, "../gradlew publishToMavenLocal") + success2 = run_gradle_command(PLUGIN_BUILD_DIR, "../gradlew publishToMavenLocal") + + if not (success1 and success2): + print("✗ One or more gradle commands failed. Stopping.") + sys.exit(1) + + # Step 4: Update checkmate app build.gradle + print("\n=== Step 4: Updating checkmate app build.gradle ===") + + update_checkmate_build_gradle(CHECKMATE_BUILD_GRADLE, new_version) + + # Step 5: Sync the project + print("\n=== Step 5: Building the app project ===") + run_gradle_command(CHECKMATE_APP_DIR, "../gradlew --console=plain --no-daemon -Dorg.gradle.debug=true -Dkotlin.compiler.execution.strategy=\"in-process\" -Dkotlin.daemon.jvm.options=\"-Xdebug,-Xrunjdwp:transport=dt_socket,address=5005,server=y,suspend=n\" assembleDebug") + + print(f"\n✓ All steps completed successfully!") + print(f"✓ Version updated to: {new_version}") + print(f"✓ Plugins published to Maven Local") + print(f"✓ Checkmate app updated with new version") + + except Exception as e: + print(f"✗ Error: {e}") + sys.exit(1) + +if __name__ == "__main__": + main() \ No newline at end of file From fbbbb13ef4da0c4491eb61e6e7563dc1147edae6 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Mon, 22 Sep 2025 11:14:29 +0200 Subject: [PATCH 18/22] cleanup --- check-elf.sh | 113 --------------- scripts/next-pr-number.sh | 87 ------------ tracebox | 286 -------------------------------------- update_sentry_plugin.py | 203 --------------------------- 4 files changed, 689 deletions(-) delete mode 100755 check-elf.sh delete mode 100755 scripts/next-pr-number.sh delete mode 100755 tracebox delete mode 100755 update_sentry_plugin.py diff --git a/check-elf.sh b/check-elf.sh deleted file mode 100755 index 0cd8f92a982..00000000000 --- a/check-elf.sh +++ /dev/null @@ -1,113 +0,0 @@ -#!/bin/bash -progname="${0##*/}" -progname="${progname%.sh}" - -# usage: check_elf_alignment.sh [path to *.so files|path to *.apk] - -cleanup_trap() { - if [ -n "${tmp}" -a -d "${tmp}" ]; then - rm -rf ${tmp} - fi - exit $1 -} - -usage() { - echo "Host side script to check the ELF alignment of shared libraries." - echo "Shared libraries are reported ALIGNED when their ELF regions are" - echo "16 KB or 64 KB aligned. Otherwise they are reported as UNALIGNED." - echo - echo "Usage: ${progname} [input-path|input-APK|input-APEX]" -} - -if [ ${#} -ne 1 ]; then - usage - exit -fi - -case ${1} in - --help | -h | -\?) - usage - exit - ;; - - *) - dir="${1}" - ;; -esac - -if ! [ -f "${dir}" -o -d "${dir}" ]; then - echo "Invalid file: ${dir}" >&2 - exit 1 -fi - -if [[ "${dir}" == *.apk ]]; then - trap 'cleanup_trap' EXIT - - echo - echo "Recursively analyzing $dir" - echo - - if { zipalign --help 2>&1 | grep -q "\-P "; }; then - echo "=== APK zip-alignment ===" - zipalign -v -c -P 16 4 "${dir}" | egrep 'lib/arm64-v8a|lib/x86_64|Verification' - echo "=========================" - else - echo "NOTICE: Zip alignment check requires build-tools version 35.0.0-rc3 or higher." - echo " You can install the latest build-tools by running the below command" - echo " and updating your \$PATH:" - echo - echo " sdkmanager \"build-tools;35.0.0-rc3\"" - fi - - dir_filename=$(basename "${dir}") - tmp=$(mktemp -d -t "${dir_filename%.apk}_out_XXXXX") - unzip "${dir}" lib/* -d "${tmp}" >/dev/null 2>&1 - dir="${tmp}" -fi - -if [[ "${dir}" == *.apex ]]; then - trap 'cleanup_trap' EXIT - - echo - echo "Recursively analyzing $dir" - echo - - dir_filename=$(basename "${dir}") - tmp=$(mktemp -d -t "${dir_filename%.apex}_out_XXXXX") - deapexer extract "${dir}" "${tmp}" || { echo "Failed to deapex." && exit 1; } - dir="${tmp}" -fi - -RED="\e[31m" -GREEN="\e[32m" -ENDCOLOR="\e[0m" - -unaligned_libs=() - -echo -echo "=== ELF alignment ===" - -matches="$(find "${dir}" -type f)" -IFS=$'\n' -for match in $matches; do - # We could recursively call this script or rewrite it to though. - [[ "${match}" == *".apk" ]] && echo "WARNING: doesn't recursively inspect .apk file: ${match}" - [[ "${match}" == *".apex" ]] && echo "WARNING: doesn't recursively inspect .apex file: ${match}" - - [[ $(file "${match}") == *"ELF"* ]] || continue - - res="$(objdump -p "${match}" | grep LOAD | awk '{ print $NF }' | head -1)" - if [[ $res =~ 2\*\*(1[4-9]|[2-9][0-9]|[1-9][0-9]{2,}) ]]; then - echo -e "${match}: ${GREEN}ALIGNED${ENDCOLOR} ($res)" - else - echo -e "${match}: ${RED}UNALIGNED${ENDCOLOR} ($res)" - unaligned_libs+=("${match}") - fi -done - -if [ ${#unaligned_libs[@]} -gt 0 ]; then - echo -e "${RED}Found ${#unaligned_libs[@]} unaligned libs (only arm64-v8a/x86_64 libs need to be aligned).${ENDCOLOR}" -elif [ -n "${dir_filename}" ]; then - echo -e "ELF Verification Successful" -fi -echo "=====================" \ No newline at end of file diff --git a/scripts/next-pr-number.sh b/scripts/next-pr-number.sh deleted file mode 100755 index e6984aea161..00000000000 --- a/scripts/next-pr-number.sh +++ /dev/null @@ -1,87 +0,0 @@ -#!/bin/bash - -# Script to get the next PR number for a GitHub repository -# Based on: https://github.com/ichard26/next-pr-number/blob/main/app.py - -set -euo pipefail - -# GraphQL query to get the last issue/PR/discussion number -GRAPHQL_QUERY=' -query getLastIssueNumber($owner: String!, $name: String!) { - repository(owner: $owner, name: $name) { - discussions(orderBy: {field: CREATED_AT, direction: DESC}, first: 1) { - nodes { - number - } - } - issues(orderBy: {field: CREATED_AT, direction: DESC}, first: 1) { - nodes { - number - } - } - pullRequests(orderBy: {field: CREATED_AT, direction: DESC}, first: 1) { - nodes { - number - } - } - } -}' - -usage() { - echo "Usage: $0 " - echo "Example: $0 getsentry sentry" - echo "" - echo "Requires: gh CLI tool to be installed and authenticated" - echo "Run 'gh auth login' to authenticate if needed" - exit 1 -} - -# Check arguments -if [[ $# -ne 2 ]]; then - usage -fi - -OWNER="$1" -REPO="$2" - -# Check if gh CLI is available -if ! command -v gh &> /dev/null; then - echo "Error: gh CLI is not installed" - echo "Please install it from https://cli.github.com/" - exit 1 -fi - -# Check if gh is authenticated -if ! gh auth status &> /dev/null; then - echo "Error: gh CLI is not authenticated" - echo "Please run 'gh auth login' to authenticate" - exit 1 -fi - -# Make GraphQL request using gh -response=$(gh api graphql -f query="$GRAPHQL_QUERY" -f owner="$OWNER" -f name="$REPO") - -# Check if the request was successful -if [[ $? -ne 0 ]]; then - echo "Error: Failed to make GraphQL request" - exit 1 -fi - -# Parse the response and extract the highest number -highest_number=$(echo "$response" | jq -r ' - .data.repository | - [ - (.discussions.nodes[]? | .number // 0), - (.issues.nodes[]? | .number // 0), - (.pullRequests.nodes[]? | .number // 0) - ] | - max // 0 -') - -# Calculate next number -if [[ "$highest_number" != "null" && "$highest_number" -gt 0 ]]; then - next_number=$((highest_number + 1)) - echo "$next_number" -else - echo "1" -fi diff --git a/tracebox b/tracebox deleted file mode 100755 index 66256e9f937..00000000000 --- a/tracebox +++ /dev/null @@ -1,286 +0,0 @@ -#!/usr/bin/env python3 -# Copyright (C) 2021 The Android Open Source Project -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -# DO NOT EDIT. Auto-generated by tools/gen_amalgamated_python_tools -# !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - -# This file should do the same thing when being invoked in any of these ways: -# ./tracebox -# python tracebox -# bash tracebox -# cat ./tracebox | bash -# cat ./tracebox | python - - -BASH_FALLBACK=""" " -exec python3 - "$@" <<'#'EOF -#""" # yapf: disable - - -# ----- Amalgamator: begin of python/perfetto/prebuilts/manifests/tracebox.py -# This file has been generated by: tools/roll-prebuilts v51.2 -TRACEBOX_MANIFEST = [{ - 'arch': - 'mac-amd64', - 'file_name': - 'tracebox', - 'file_size': - 1729080, - 'url': - 'https://commondatastorage.googleapis.com/perfetto-luci-artifacts/v51.2/mac-amd64/tracebox', - 'sha256': - '1448ac96e30529fd9f184eb7b7aaf3222e13636c10fadcfebb397c27887894dc', - 'platform': - 'darwin', - 'machine': ['x86_64'] -}, { - 'arch': - 'mac-arm64', - 'file_name': - 'tracebox', - 'file_size': - 1591720, - 'url': - 'https://commondatastorage.googleapis.com/perfetto-luci-artifacts/v51.2/mac-arm64/tracebox', - 'sha256': - 'f4a0cc31acf9745d457aef9fec1ac4ff7f0e136329bb65904b9180aca2a7b7e1', - 'platform': - 'darwin', - 'machine': ['arm64'] -}, { - 'arch': - 'linux-amd64', - 'file_name': - 'tracebox', - 'file_size': - 2505648, - 'url': - 'https://commondatastorage.googleapis.com/perfetto-luci-artifacts/v51.2/linux-amd64/tracebox', - 'sha256': - '4b81495f019705dab925ad2400519b4ddb4f29e91adb9973c2c0e0276e4da5bd', - 'platform': - 'linux', - 'machine': ['x86_64'] -}, { - 'arch': - 'linux-arm', - 'file_name': - 'tracebox', - 'file_size': - 1532472, - 'url': - 'https://commondatastorage.googleapis.com/perfetto-luci-artifacts/v51.2/linux-arm/tracebox', - 'sha256': - 'd9044b9857f1cf0939de61b3589effac6a1250f17bf9f550748ccfb0aecc2fe1', - 'platform': - 'linux', - 'machine': ['armv6l', 'armv7l', 'armv8l'] -}, { - 'arch': - 'linux-arm64', - 'file_name': - 'tracebox', - 'file_size': - 2386960, - 'url': - 'https://commondatastorage.googleapis.com/perfetto-luci-artifacts/v51.2/linux-arm64/tracebox', - 'sha256': - 'f601fc0c75b234b9f4c56bec7dc6868e2dc2ddd9e660a30834537edfbcdb1efb', - 'platform': - 'linux', - 'machine': ['aarch64'] -}, { - 'arch': - 'android-arm', - 'file_name': - 'tracebox', - 'file_size': - 1403716, - 'url': - 'https://commondatastorage.googleapis.com/perfetto-luci-artifacts/v51.2/android-arm/tracebox', - 'sha256': - '75ce98c1147bca933bde322deddb5e119272293b04b41590d1738258e3b6721a' -}, { - 'arch': - 'android-arm64', - 'file_name': - 'tracebox', - 'file_size': - 2213288, - 'url': - 'https://commondatastorage.googleapis.com/perfetto-luci-artifacts/v51.2/android-arm64/tracebox', - 'sha256': - 'a11474acf7965644dde5c0c39892894f32ccad1b0e63ef73196d49dd055ebdfb' -}, { - 'arch': - 'android-x86', - 'file_name': - 'tracebox', - 'file_size': - 2426604, - 'url': - 'https://commondatastorage.googleapis.com/perfetto-luci-artifacts/v51.2/android-x86/tracebox', - 'sha256': - '700e43ecd1f3639b8a70f7433f821f9613e54085dfa80d4f656a4fdce70cf5b6' -}, { - 'arch': - 'android-x64', - 'file_name': - 'tracebox', - 'file_size': - 2254192, - 'url': - 'https://commondatastorage.googleapis.com/perfetto-luci-artifacts/v51.2/android-x64/tracebox', - 'sha256': - '945d747522ad53e7e193c1a2c1ae0809b59b7da8a19f031458438094f10b5d36' -}] - -# ----- Amalgamator: end of python/perfetto/prebuilts/manifests/tracebox.py - -# ----- Amalgamator: begin of python/perfetto/prebuilts/perfetto_prebuilts.py -# Copyright (C) 2021 The Android Open Source Project -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -""" -Functions to fetch pre-pinned Perfetto prebuilts. - -This function is used in different places: -- Into the //tools/{trace_processor, traceconv} scripts, which are just plain - wrappers around executables. -- Into the //tools/{heap_profiler, record_android_trace} scripts, which contain - some other hand-written python code. - -The manifest argument looks as follows: -TRACECONV_MANIFEST = [ - { - 'arch': 'mac-amd64', - 'file_name': 'traceconv', - 'file_size': 7087080, - 'url': https://commondatastorage.googleapis.com/.../trace_to_text', - 'sha256': 7d957c005b0dc130f5bd855d6cec27e060d38841b320d04840afc569f9087490', - 'platform': 'darwin', - 'machine': 'x86_64' - }, - ... -] - -The intended usage is: - - from perfetto.prebuilts.manifests.traceconv import TRACECONV_MANIFEST - bin_path = get_perfetto_prebuilt(TRACECONV_MANIFEST) - subprocess.call(bin_path, ...) -""" - -import hashlib -import os -import platform -import random -import subprocess -import sys - - -def download_or_get_cached(file_name, url, sha256): - """ Downloads a prebuilt or returns a cached version - - The first time this is invoked, it downloads the |url| and caches it into - ~/.local/share/perfetto/prebuilts/$tool_name. On subsequent invocations it - just runs the cached version. - """ - dir = os.path.join( - os.path.expanduser('~'), '.local', 'share', 'perfetto', 'prebuilts') - os.makedirs(dir, exist_ok=True) - bin_path = os.path.join(dir, file_name) - sha256_path = os.path.join(dir, file_name + '.sha256') - needs_download = True - - # Avoid recomputing the SHA-256 on each invocation. The SHA-256 of the last - # download is cached into file_name.sha256, just check if that matches. - if os.path.exists(bin_path) and os.path.exists(sha256_path): - with open(sha256_path, 'rb') as f: - digest = f.read().decode() - if digest == sha256: - needs_download = False - - if needs_download: # The file doesn't exist or the SHA256 doesn't match. - # Use a unique random file to guard against concurrent executions. - # See https://github.com/google/perfetto/issues/786 . - tmp_path = '%s.%d.tmp' % (bin_path, random.randint(0, 100000)) - print('Downloading ' + url) - subprocess.check_call(['curl', '-f', '-L', '-#', '-o', tmp_path, url]) - with open(tmp_path, 'rb') as fd: - actual_sha256 = hashlib.sha256(fd.read()).hexdigest() - if actual_sha256 != sha256: - raise Exception('Checksum mismatch for %s (actual: %s, expected: %s)' % - (url, actual_sha256, sha256)) - os.chmod(tmp_path, 0o755) - os.replace(tmp_path, bin_path) - with open(tmp_path, 'w') as f: - f.write(sha256) - os.replace(tmp_path, sha256_path) - return bin_path - - -def get_perfetto_prebuilt(manifest, soft_fail=False, arch=None): - """ Downloads the prebuilt, if necessary, and returns its path on disk. """ - plat = sys.platform.lower() - machine = platform.machine().lower() - manifest_entry = None - for entry in manifest: - # If the caller overrides the arch, just match that (for Android prebuilts). - if arch: - if entry.get('arch') == arch: - manifest_entry = entry - break - continue - # Otherwise guess the local machine arch. - if entry.get('platform') == plat and machine in entry.get('machine', []): - manifest_entry = entry - break - if manifest_entry is None: - if soft_fail: - return None - raise Exception( - ('No prebuilts available for %s-%s\n' % (plat, machine)) + - 'See https://perfetto.dev/docs/contributing/build-instructions') - - return download_or_get_cached( - file_name=manifest_entry['file_name'], - url=manifest_entry['url'], - sha256=manifest_entry['sha256']) - - -def run_perfetto_prebuilt(manifest): - bin_path = get_perfetto_prebuilt(manifest) - if sys.platform.lower() == 'win32': - sys.exit(subprocess.check_call([bin_path, *sys.argv[1:]])) - os.execv(bin_path, [bin_path] + sys.argv[1:]) - - -# ----- Amalgamator: end of python/perfetto/prebuilts/perfetto_prebuilts.py - -if __name__ == '__main__': - run_perfetto_prebuilt(TRACEBOX_MANIFEST) - -#EOF diff --git a/update_sentry_plugin.py b/update_sentry_plugin.py deleted file mode 100755 index 84a92d765a1..00000000000 --- a/update_sentry_plugin.py +++ /dev/null @@ -1,203 +0,0 @@ -#!/usr/bin/env python3 -""" -Script to update Sentry Android Gradle Plugin versions and publish to Maven Local. - -This script: -1. Increments the VERSION_NAME in gradle.properties files -2. Publishes plugins to Maven Local -3. Updates the checkmate app build.gradle with new versions -""" - -import os -import re -import subprocess -import sys -from pathlib import Path - -# File paths -KOTLIN_COMPILER_GRADLE_PROPERTIES = "/Users/markushi/sentry/sentry-android-gradle-plugin/sentry-kotlin-compiler-plugin/gradle.properties" -PLUGIN_BUILD_GRADLE_PROPERTIES = "/Users/markushi/sentry/sentry-android-gradle-plugin/plugin-build/gradle.properties" -CHECKMATE_BUILD_GRADLE = "/Users/markushi/p/checkmate/app/build.gradle" - -# Directory paths for gradle commands -KOTLIN_COMPILER_DIR = "/Users/markushi/sentry/sentry-android-gradle-plugin/sentry-kotlin-compiler-plugin" -PLUGIN_BUILD_DIR = "/Users/markushi/sentry/sentry-android-gradle-plugin/plugin-build" -CHECKMATE_APP_DIR = "/Users/markushi/p/checkmate/app/" - -def increment_version(version_string): - """ - Increment the patch version of a version string. - Example: 5.8.0-mah-018 -> 5.8.0-mah-019 - """ - # Pattern to match version like 5.8.0-mah-018 - pattern = r'(\d+\.\d+\.\d+-\w+-?)(\d+)' - match = re.search(pattern, version_string) - - if match: - prefix = match.group(1) - number = int(match.group(2)) - new_number = number + 1 - return f"{prefix}{new_number:03d}" - else: - # Fallback: just increment the last number found - pattern = r'(\d+)$' - match = re.search(pattern, version_string) - if match: - number = int(match.group(1)) - new_number = number + 1 - return re.sub(r'\d+$', str(new_number), version_string) - else: - raise ValueError(f"Could not parse version string: {version_string}") - -def update_gradle_properties(file_path, version_key, new_version): - """Update a gradle.properties file with a new version.""" - if not os.path.exists(file_path): - raise FileNotFoundError(f"File not found: {file_path}") - - with open(file_path, 'r') as f: - content = f.read() - - # Update the version line - pattern = rf'^{version_key}\s*=\s*.*$' - replacement = f'{version_key} = {new_version}' - new_content = re.sub(pattern, replacement, content, flags=re.MULTILINE) - - if new_content == content: - print(f"Warning: No {version_key} found in {file_path}") - return False - - with open(file_path, 'w') as f: - f.write(new_content) - - print(f"Updated {file_path}: {version_key} = {new_version}") - return True - -def get_current_version(file_path, version_key): - """Get the current version from a gradle.properties file.""" - if not os.path.exists(file_path): - raise FileNotFoundError(f"File not found: {file_path}") - - with open(file_path, 'r') as f: - content = f.read() - - pattern = rf'^{version_key}\s*=\s*(.+)$' - match = re.search(pattern, content, re.MULTILINE) - - if match: - return match.group(1).strip() - else: - raise ValueError(f"Could not find {version_key} in {file_path}") - -def run_gradle_command(directory, command, print_output=False): - """Run a gradle command in the specified directory.""" - print(f"Running '{command}' in {directory}") - - try: - result = subprocess.run( - command, - cwd=directory, - shell=True, - check=True, - capture_output=True, - text=True - ) - print(f"✓ Command completed successfully") - if print_output: - print(result.stdout) - return True - except subprocess.CalledProcessError as e: - print(f"✗ Command failed with exit code {e.returncode}") - print(f"stdout: {e.stdout}") - print(f"stderr: {e.stderr}") - return False - -def update_checkmate_build_gradle(file_path, new_version): - """Update the checkmate app build.gradle with new plugin versions.""" - if not os.path.exists(file_path): - raise FileNotFoundError(f"File not found: {file_path}") - - with open(file_path, 'r') as f: - content = f.read() - - # Update both plugin versions - patterns = [ - (r'id "io\.sentry\.android\.gradle" version "[^"]*"', - f'id "io.sentry.android.gradle" version "{new_version}"'), - (r'id "io\.sentry\.kotlin\.compiler\.gradle" version "[^"]*"', - f'id "io.sentry.kotlin.compiler.gradle" version "{new_version}"') - ] - - new_content = content - updated = False - - for pattern, replacement in patterns: - if re.search(pattern, new_content): - new_content = re.sub(pattern, replacement, new_content) - updated = True - - if not updated: - print(f"Warning: No Sentry plugin versions found in {file_path}") - return False - - with open(file_path, 'w') as f: - f.write(new_content) - - print(f"Updated {file_path} with version {new_version}") - return True - -def main(): - """Main function to orchestrate the version update process.""" - try: - # Step 1: Get current versions and increment them - print("=== Step 1: Getting current versions ===") - - current_kotlin_version = get_current_version(KOTLIN_COMPILER_GRADLE_PROPERTIES, "VERSION_NAME") - current_plugin_version = get_current_version(PLUGIN_BUILD_GRADLE_PROPERTIES, "version") - - print(f"Current Kotlin compiler version: {current_kotlin_version}") - print(f"Current plugin build version: {current_plugin_version}") - - # Check if versions are the same - if current_kotlin_version != current_plugin_version: - print("Warning: Versions are different between the two gradle.properties files") - - # Increment version (use the kotlin compiler version as reference) - new_version = increment_version(current_kotlin_version) - print(f"New version: {new_version}") - - # Step 2: Update gradle.properties files - print("\n=== Step 2: Updating gradle.properties files ===") - - update_gradle_properties(KOTLIN_COMPILER_GRADLE_PROPERTIES, "VERSION_NAME", new_version) - update_gradle_properties(PLUGIN_BUILD_GRADLE_PROPERTIES, "version", new_version) - - # Step 3: Publish to Maven Local - print("\n=== Step 3: Publishing to Maven Local ===") - - success1 = run_gradle_command(KOTLIN_COMPILER_DIR, "../gradlew publishToMavenLocal") - success2 = run_gradle_command(PLUGIN_BUILD_DIR, "../gradlew publishToMavenLocal") - - if not (success1 and success2): - print("✗ One or more gradle commands failed. Stopping.") - sys.exit(1) - - # Step 4: Update checkmate app build.gradle - print("\n=== Step 4: Updating checkmate app build.gradle ===") - - update_checkmate_build_gradle(CHECKMATE_BUILD_GRADLE, new_version) - - # Step 5: Sync the project - print("\n=== Step 5: Building the app project ===") - run_gradle_command(CHECKMATE_APP_DIR, "../gradlew --console=plain --no-daemon -Dorg.gradle.debug=true -Dkotlin.compiler.execution.strategy=\"in-process\" -Dkotlin.daemon.jvm.options=\"-Xdebug,-Xrunjdwp:transport=dt_socket,address=5005,server=y,suspend=n\" assembleDebug") - - print(f"\n✓ All steps completed successfully!") - print(f"✓ Version updated to: {new_version}") - print(f"✓ Plugins published to Maven Local") - print(f"✓ Checkmate app updated with new version") - - except Exception as e: - print(f"✗ Error: {e}") - sys.exit(1) - -if __name__ == "__main__": - main() \ No newline at end of file From 53654ebf8f41130ff4684e6105f773e20c76d683 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Mon, 22 Sep 2025 11:24:40 +0200 Subject: [PATCH 19/22] Add FramesTimingTracker, cleanup --- .../android/replay/ReplayIntegration.kt | 5 +- .../android/replay/ScreenshotRecorder.kt | 9 ++- .../sentry/android/replay/WindowRecorder.kt | 58 ++++++++++++++-- .../replay/screenshot/CanvasStrategy.kt | 8 --- .../replay/screenshot/FrameTimingsTracker.kt | 66 +++++++++++++++++++ .../replay/screenshot/PixelCopyStrategy.kt | 1 - .../android/replay/ScreenshotRecorderTest.kt | 2 + 7 files changed, 127 insertions(+), 22 deletions(-) create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/FrameTimingsTracker.kt diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index e6cce23aaa1..c498e0a4ca8 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -30,6 +30,7 @@ import io.sentry.android.replay.capture.CaptureStrategy.ReplaySegment import io.sentry.android.replay.capture.SessionCaptureStrategy import io.sentry.android.replay.gestures.GestureRecorder import io.sentry.android.replay.gestures.TouchRecorderCallback +import io.sentry.android.replay.screenshot.FrameTimingsTracker import io.sentry.android.replay.util.MainLooperHandler import io.sentry.android.replay.util.appContext import io.sentry.android.replay.util.gracefullyShutdown @@ -102,6 +103,8 @@ public class ReplayIntegration( private var gestureRecorder: GestureRecorder? = null private val random by lazy { Random() } internal val rootViewsSpy by lazy { RootViewsSpy.install() } + + private val framesTracker = FrameTimingsTracker(context) private val replayExecutor by lazy { Executors.newSingleThreadScheduledExecutor(ReplayExecutorServiceThreadFactory()) } @@ -139,7 +142,7 @@ public class ReplayIntegration( this.scopes = scopes recorder = recorderProvider?.invoke() - ?: WindowRecorder(options, this, this, mainLooperHandler, replayExecutor) + ?: WindowRecorder(options, this, this, mainLooperHandler, replayExecutor, framesTracker) gestureRecorder = gestureRecorderProvider?.invoke() ?: GestureRecorder(options, this) isEnabled.set(true) 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 f898fc7807a..63ff37340d4 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 @@ -5,6 +5,7 @@ import android.annotation.TargetApi import android.content.Context import android.graphics.Bitmap import android.os.SystemClock +import android.util.Log import android.view.View import android.view.ViewTreeObserver import io.sentry.ScreenshotStrategyType @@ -51,7 +52,7 @@ internal class ScreenshotRecorder( screenshotRecorderCallback, options, config, - debugOverlayDrawable + debugOverlayDrawable, ) } @@ -96,10 +97,8 @@ internal class ScreenshotRecorder( contentChanged.set(false) val start = SystemClock.uptimeMillis() screenshotStrategy.capture(root) - if (options.sessionReplay.isDebug) { - val duration = SystemClock.uptimeMillis() - start - options.logger.log(DEBUG, "screenshotStrategy.capture took %d ms", duration) - } + val duration = SystemClock.uptimeMillis() - start + Log.d("TAG", "Canvas.capture took ${duration}ms") } catch (e: Throwable) { options.logger.log(WARNING, "Failed to capture replay recording", e) } 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 9ad9c40078a..67e350bc3c1 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,12 +2,15 @@ package io.sentry.android.replay import android.annotation.TargetApi import android.graphics.Point +import android.os.Build +import android.util.Log import android.view.View import android.view.ViewTreeObserver import io.sentry.SentryLevel.DEBUG import io.sentry.SentryLevel.ERROR import io.sentry.SentryLevel.WARNING import io.sentry.SentryOptions +import io.sentry.android.replay.screenshot.FrameTimingsTracker import io.sentry.android.replay.util.MainLooperHandler import io.sentry.android.replay.util.addOnPreDrawListenerSafe import io.sentry.android.replay.util.hasSize @@ -24,6 +27,7 @@ internal class WindowRecorder( private val windowCallback: WindowCallback, private val mainLooperHandler: MainLooperHandler, private val replayExecutor: ScheduledExecutorService, + private val frameTimingsTracker: FrameTimingsTracker ) : Recorder, OnRootViewsChangedListener { private val isRecording = AtomicBoolean(false) @@ -31,17 +35,25 @@ internal class WindowRecorder( private var lastKnownWindowSize: Point = Point() private val rootViewsLock = AutoClosableReentrantLock() private val capturerLock = AutoClosableReentrantLock() - @Volatile private var capturer: Capturer? = null + @Volatile + private var capturer: Capturer? = null private class Capturer( private val options: SentryOptions, private val mainLooperHandler: MainLooperHandler, + private val frameTimingsTracker: FrameTimingsTracker, ) : Runnable { var recorder: ScreenshotRecorder? = null var config: ScreenshotRecorderConfig? = null private val isRecording = AtomicBoolean(true) + private val maxCaptureDelayMs = 166L // ~10 frames @ 60fps + + private var currentCaptureDelay = 0L + + private var rootView = WeakReference(null) + fun resume() { if (options.sessionReplay.isDebug) { options.logger.log(DEBUG, "Resuming the capture runnable.") @@ -79,22 +91,48 @@ internal class WindowRecorder( return } + var delay = 1000L / (config?.frameRate ?: 1) + + val rootView = rootView.get() + val isViewIdle = + if (rootView != null) { + !rootView.isDirty && !rootView.isLayoutRequested && !rootView.isInLayout + } else { + false + } + + Log.d("TAG", "View is idle: $isViewIdle") + val frameTrackerIdle = frameTimingsTracker.isIdle() + try { - if (options.sessionReplay.isDebug) { - options.logger.log(DEBUG, "Capturing a frame.") + if ((isViewIdle) || currentCaptureDelay > maxCaptureDelayMs) { + if (options.sessionReplay.isDebug) { + Log.d("TAG", "Capturing a frame.") + } + currentCaptureDelay = 0L + recorder?.capture() + } else { + delay = 16 + currentCaptureDelay += delay + if (options.sessionReplay.isDebug) { + Log.d("TAG", "Skipping capture of this frame, app is not idle.") + } + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + android.os.Trace.setCounter("sentry.capture_delay", currentCaptureDelay) } - recorder?.capture() } catch (e: Throwable) { options.logger.log(ERROR, "Failed to capture a frame", e) } + if (options.sessionReplay.isDebug) { options.logger.log( DEBUG, "Posting the capture runnable again, frame rate is ${config?.frameRate ?: 1} fps.", ) } - val posted = mainLooperHandler.postDelayed(this, 1000L / (config?.frameRate ?: 1)) + val posted = mainLooperHandler.postDelayed(this, delay) if (!posted) { options.logger.log( WARNING, @@ -102,12 +140,17 @@ internal class WindowRecorder( ) } } + + fun bind(newRoot: View) { + rootView = WeakReference(newRoot) + } } override fun onRootViewsChanged(root: View, added: Boolean) { rootViewsLock.acquire().use { if (added) { rootViews.add(WeakReference(root)) + capturer?.bind(root) capturer?.recorder?.bind(root) determineWindowSize(root) } else { @@ -168,7 +211,7 @@ internal class WindowRecorder( capturerLock.acquire().use { if (capturer == null) { // don't recreate runnable for every config change, just update the config - capturer = Capturer(options, mainLooperHandler) + capturer = Capturer(options, mainLooperHandler, frameTimingsTracker) } } } @@ -180,11 +223,12 @@ internal class WindowRecorder( options, mainLooperHandler, replayExecutor, - screenshotRecorderCallback, + screenshotRecorderCallback ) val newRoot = rootViews.lastOrNull()?.get() if (newRoot != null) { + capturer?.bind(newRoot) capturer?.recorder?.bind(newRoot) } 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 index 4946edcc506..22faf9b9287 100644 --- 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 @@ -73,7 +73,6 @@ internal class CanvasStrategy( } private val pictureRenderTask = Runnable { - Trace.beginSection("CanvasStrategy.pictureRenderTask") val holder: PictureReaderHolder? = synchronized(unprocessedPictures) { when { @@ -108,13 +107,11 @@ internal class CanvasStrategy( holder.picture.draw(canvas) surface.unlockCanvasAndPost(canvas) } finally {} - Trace.endSection() } @SuppressLint("UnclosedTrace") override fun capture(root: View) { Trace.beginSection("Canvas.capture") - Trace.beginSection("Canvas.capture.prepare_picture") val holder: PictureReaderHolder? = synchronized(freePictures) { when { @@ -127,17 +124,12 @@ internal class CanvasStrategy( executor.submitSafely(options, "screenshot_recorder.canvas", pictureRenderTask) return } - Trace.endSection() - Trace.beginSection("Canvas.capture.record_picture") val pictureCanvas = holder.picture.beginRecording(config.recordingWidth, config.recordingHeight) textIgnoringCanvas.delegate = pictureCanvas textIgnoringCanvas.setMatrix(prescaledMatrix) root.draw(textIgnoringCanvas) - Trace.endSection() - Trace.beginSection("Canvas.capture.end_recording") holder.picture.endRecording() - Trace.endSection() synchronized(unprocessedPictures) { unprocessedPictures.add(holder) } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/FrameTimingsTracker.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/FrameTimingsTracker.kt new file mode 100644 index 00000000000..31cbc251e06 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/FrameTimingsTracker.kt @@ -0,0 +1,66 @@ +package io.sentry.android.replay.screenshot + +import android.app.Activity +import android.app.Application +import android.content.Context +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.view.FrameMetrics +import android.view.Window + +internal class FrameTimingsTracker(app: Context) { + + companion object { + private const val ANIMATION_THRESHOLD_NS = 1000000L // 1ms + private const val LAYOUT_THRESHOLD_NS = 500000L // 0.5ms + } + + @Volatile + private var lastAnimDuration: Long = 0 + + @Volatile + private var lastTotalDuration: Long = 0 + + @Volatile + private var lastLayoutDuration: Long = 0 + + private val handler = Handler(Looper.getMainLooper()) + + init { + (app as Application?)?.registerActivityLifecycleCallbacks(object : Application.ActivityLifecycleCallbacks { + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + activity.window?.addOnFrameMetricsAvailableListener(listener, handler) + } + } + + override fun onActivityDestroyed(activity: Activity) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + activity.window?.removeOnFrameMetricsAvailableListener(listener) + } + } + + override fun onActivityStarted(activity: Activity) {} + override fun onActivityResumed(activity: Activity) {} + override fun onActivityPaused(activity: Activity) {} + override fun onActivityStopped(activity: Activity) {} + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {} + }) + } + + private val listener = Window.OnFrameMetricsAvailableListener { _, frameMetrics, _ -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + lastTotalDuration = frameMetrics.getMetric(FrameMetrics.TOTAL_DURATION) + lastAnimDuration = frameMetrics.getMetric(FrameMetrics.ANIMATION_DURATION) + lastLayoutDuration = frameMetrics.getMetric(FrameMetrics.LAYOUT_MEASURE_DURATION) + } + } + + fun isIdle(): Boolean { + Log.d("TAG", "isIdle: lastTotalDuration: ${lastTotalDuration/1000000.0}, lastAnimDuration: ${lastAnimDuration/1000000.0}, layoutDuration: ${lastLayoutDuration/1000000.0}") + return lastAnimDuration < ANIMATION_THRESHOLD_NS && lastLayoutDuration < LAYOUT_THRESHOLD_NS + } +} 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 index 47d0c24d6c7..82341405734 100644 --- 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 @@ -8,7 +8,6 @@ import android.graphics.Matrix import android.graphics.Paint import android.graphics.Rect import android.graphics.RectF -import android.os.SystemClock import android.view.PixelCopy import android.view.View import io.sentry.SentryLevel.DEBUG 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 index b8f59099403..b04f3c480a8 100644 --- 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 @@ -7,6 +7,7 @@ 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 @@ -24,6 +25,7 @@ class ScreenshotRecorderTest { return ScreenshotRecorder( ScreenshotRecorderConfig(100, 100, 1f, 1f, 1, 1000), options, + mock(), mock(), null, ) From 4bd6fbb13e73e50b1db5bafd1d4c4fe6dae1e5ef Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Mon, 22 Sep 2025 11:30:42 +0200 Subject: [PATCH 20/22] Cleanup --- CLAUDE.md | 2 +- gradle.properties | 2 +- sentry/src/main/java/io/sentry/JsonSerializer.java | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5f635b98a8e..9de4130c1a7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -153,4 +153,4 @@ When working on these specific areas, read the corresponding cursor rule file fi - Internal contributing guide: https://docs.sentry.io/internal/contributing/ - Git commit message conventions: https://develop.sentry.dev/engineering-practices/commit-messages/ -This SDK is production-ready and used by thousands of applications. Changes should be thoroughly tested and maintain backwards compatibility. +This SDK is production-ready and used by thousands of applications. Changes should be thoroughly tested and maintain backwards compatibility. \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index fcec29be0f8..1cc4209e661 100644 --- a/gradle.properties +++ b/gradle.properties @@ -11,7 +11,7 @@ org.jetbrains.dokka.experimental.gradle.pluginMode=V2Enabled android.useAndroidX=true # Release information -versionName=8.18.0-mah-320 +versionName=8.21.1 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android diff --git a/sentry/src/main/java/io/sentry/JsonSerializer.java b/sentry/src/main/java/io/sentry/JsonSerializer.java index 80b58798803..65e672bea13 100644 --- a/sentry/src/main/java/io/sentry/JsonSerializer.java +++ b/sentry/src/main/java/io/sentry/JsonSerializer.java @@ -204,10 +204,10 @@ public void serialize(@NotNull T entity, @NotNull Writer writer) throws IOEx Objects.requireNonNull(entity, "The entity is required."); Objects.requireNonNull(writer, "The Writer object is required."); -// if (options.getLogger().isEnabled(SentryLevel.DEBUG)) { -// String serialized = serializeToString(entity, options.isEnablePrettySerializationOutput()); -// options.getLogger().log(SentryLevel.DEBUG, "Serializing object: %s", serialized); -// } + if (options.getLogger().isEnabled(SentryLevel.DEBUG)) { + String serialized = serializeToString(entity, options.isEnablePrettySerializationOutput()); + options.getLogger().log(SentryLevel.DEBUG, "Serializing object: %s", serialized); + } JsonObjectWriter jsonObjectWriter = new JsonObjectWriter(writer, options.getMaxDepth()); jsonObjectWriter.value(options.getLogger(), entity); writer.flush(); From cb9e7f64e3ff94c77c92bed4df21f23a7e81a45e Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Thu, 2 Oct 2025 13:02:30 +0200 Subject: [PATCH 21/22] Cleanup --- .../android/replay/ReplayIntegration.kt | 5 +- .../android/replay/ScreenshotRecorder.kt | 5 -- .../sentry/android/replay/WindowRecorder.kt | 48 ++------------ .../replay/screenshot/CanvasStrategy.kt | 3 - .../replay/screenshot/FrameTimingsTracker.kt | 66 ------------------- .../java/io/sentry/SentryReplayOptions.java | 9 ++- 6 files changed, 14 insertions(+), 122 deletions(-) delete mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/FrameTimingsTracker.kt diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index c498e0a4ca8..e6cce23aaa1 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -30,7 +30,6 @@ import io.sentry.android.replay.capture.CaptureStrategy.ReplaySegment import io.sentry.android.replay.capture.SessionCaptureStrategy import io.sentry.android.replay.gestures.GestureRecorder import io.sentry.android.replay.gestures.TouchRecorderCallback -import io.sentry.android.replay.screenshot.FrameTimingsTracker import io.sentry.android.replay.util.MainLooperHandler import io.sentry.android.replay.util.appContext import io.sentry.android.replay.util.gracefullyShutdown @@ -103,8 +102,6 @@ public class ReplayIntegration( private var gestureRecorder: GestureRecorder? = null private val random by lazy { Random() } internal val rootViewsSpy by lazy { RootViewsSpy.install() } - - private val framesTracker = FrameTimingsTracker(context) private val replayExecutor by lazy { Executors.newSingleThreadScheduledExecutor(ReplayExecutorServiceThreadFactory()) } @@ -142,7 +139,7 @@ public class ReplayIntegration( this.scopes = scopes recorder = recorderProvider?.invoke() - ?: WindowRecorder(options, this, this, mainLooperHandler, replayExecutor, framesTracker) + ?: WindowRecorder(options, this, this, mainLooperHandler, replayExecutor) gestureRecorder = gestureRecorderProvider?.invoke() ?: GestureRecorder(options, this) isEnabled.set(true) 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 63ff37340d4..6417b51e9bf 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,8 +4,6 @@ import android.annotation.SuppressLint import android.annotation.TargetApi import android.content.Context import android.graphics.Bitmap -import android.os.SystemClock -import android.util.Log import android.view.View import android.view.ViewTreeObserver import io.sentry.ScreenshotStrategyType @@ -95,10 +93,7 @@ internal class ScreenshotRecorder( try { contentChanged.set(false) - val start = SystemClock.uptimeMillis() screenshotStrategy.capture(root) - val duration = SystemClock.uptimeMillis() - start - Log.d("TAG", "Canvas.capture took ${duration}ms") } catch (e: Throwable) { options.logger.log(WARNING, "Failed to capture replay recording", e) } 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 67e350bc3c1..94164a2cbca 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,15 +2,12 @@ package io.sentry.android.replay import android.annotation.TargetApi import android.graphics.Point -import android.os.Build -import android.util.Log import android.view.View import android.view.ViewTreeObserver import io.sentry.SentryLevel.DEBUG import io.sentry.SentryLevel.ERROR import io.sentry.SentryLevel.WARNING import io.sentry.SentryOptions -import io.sentry.android.replay.screenshot.FrameTimingsTracker import io.sentry.android.replay.util.MainLooperHandler import io.sentry.android.replay.util.addOnPreDrawListenerSafe import io.sentry.android.replay.util.hasSize @@ -27,7 +24,6 @@ internal class WindowRecorder( private val windowCallback: WindowCallback, private val mainLooperHandler: MainLooperHandler, private val replayExecutor: ScheduledExecutorService, - private val frameTimingsTracker: FrameTimingsTracker ) : Recorder, OnRootViewsChangedListener { private val isRecording = AtomicBoolean(false) @@ -35,21 +31,17 @@ internal class WindowRecorder( private var lastKnownWindowSize: Point = Point() private val rootViewsLock = AutoClosableReentrantLock() private val capturerLock = AutoClosableReentrantLock() - @Volatile - private var capturer: Capturer? = null + @Volatile private var capturer: Capturer? = null private class Capturer( private val options: SentryOptions, private val mainLooperHandler: MainLooperHandler, - private val frameTimingsTracker: FrameTimingsTracker, ) : Runnable { var recorder: ScreenshotRecorder? = null var config: ScreenshotRecorderConfig? = null private val isRecording = AtomicBoolean(true) - private val maxCaptureDelayMs = 166L // ~10 frames @ 60fps - private var currentCaptureDelay = 0L private var rootView = WeakReference(null) @@ -91,48 +83,22 @@ internal class WindowRecorder( return } - var delay = 1000L / (config?.frameRate ?: 1) - - val rootView = rootView.get() - val isViewIdle = - if (rootView != null) { - !rootView.isDirty && !rootView.isLayoutRequested && !rootView.isInLayout - } else { - false - } - - Log.d("TAG", "View is idle: $isViewIdle") - val frameTrackerIdle = frameTimingsTracker.isIdle() - try { - if ((isViewIdle) || currentCaptureDelay > maxCaptureDelayMs) { - if (options.sessionReplay.isDebug) { - Log.d("TAG", "Capturing a frame.") - } - currentCaptureDelay = 0L - recorder?.capture() - } else { - delay = 16 - currentCaptureDelay += delay - if (options.sessionReplay.isDebug) { - Log.d("TAG", "Skipping capture of this frame, app is not idle.") - } - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - android.os.Trace.setCounter("sentry.capture_delay", currentCaptureDelay) + if (options.sessionReplay.isDebug) { + options.logger.log(DEBUG, "Capturing a frame.") } + recorder?.capture() } catch (e: Throwable) { options.logger.log(ERROR, "Failed to capture a frame", e) } - if (options.sessionReplay.isDebug) { options.logger.log( DEBUG, "Posting the capture runnable again, frame rate is ${config?.frameRate ?: 1} fps.", ) } - val posted = mainLooperHandler.postDelayed(this, delay) + val posted = mainLooperHandler.postDelayed(this, 1000L / (config?.frameRate ?: 1)) if (!posted) { options.logger.log( WARNING, @@ -211,7 +177,7 @@ internal class WindowRecorder( capturerLock.acquire().use { if (capturer == null) { // don't recreate runnable for every config change, just update the config - capturer = Capturer(options, mainLooperHandler, frameTimingsTracker) + capturer = Capturer(options, mainLooperHandler) } } } @@ -223,7 +189,7 @@ internal class WindowRecorder( options, mainLooperHandler, replayExecutor, - screenshotRecorderCallback + screenshotRecorderCallback, ) val newRoot = rootViews.lastOrNull()?.get() 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 index 22faf9b9287..5d1be09c60e 100644 --- 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 @@ -26,7 +26,6 @@ import android.media.Image import android.media.ImageReader import android.os.Handler import android.os.HandlerThread -import android.os.Trace import android.view.View import io.sentry.SentryLevel import io.sentry.SentryOptions @@ -111,7 +110,6 @@ internal class CanvasStrategy( @SuppressLint("UnclosedTrace") override fun capture(root: View) { - Trace.beginSection("Canvas.capture") val holder: PictureReaderHolder? = synchronized(freePictures) { when { @@ -134,7 +132,6 @@ internal class CanvasStrategy( synchronized(unprocessedPictures) { unprocessedPictures.add(holder) } executor.submitSafely(options, "screenshot_recorder.canvas", pictureRenderTask) - Trace.endSection() } override fun onContentChanged() { diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/FrameTimingsTracker.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/FrameTimingsTracker.kt deleted file mode 100644 index 31cbc251e06..00000000000 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/FrameTimingsTracker.kt +++ /dev/null @@ -1,66 +0,0 @@ -package io.sentry.android.replay.screenshot - -import android.app.Activity -import android.app.Application -import android.content.Context -import android.os.Build -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.util.Log -import android.view.FrameMetrics -import android.view.Window - -internal class FrameTimingsTracker(app: Context) { - - companion object { - private const val ANIMATION_THRESHOLD_NS = 1000000L // 1ms - private const val LAYOUT_THRESHOLD_NS = 500000L // 0.5ms - } - - @Volatile - private var lastAnimDuration: Long = 0 - - @Volatile - private var lastTotalDuration: Long = 0 - - @Volatile - private var lastLayoutDuration: Long = 0 - - private val handler = Handler(Looper.getMainLooper()) - - init { - (app as Application?)?.registerActivityLifecycleCallbacks(object : Application.ActivityLifecycleCallbacks { - override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - activity.window?.addOnFrameMetricsAvailableListener(listener, handler) - } - } - - override fun onActivityDestroyed(activity: Activity) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - activity.window?.removeOnFrameMetricsAvailableListener(listener) - } - } - - override fun onActivityStarted(activity: Activity) {} - override fun onActivityResumed(activity: Activity) {} - override fun onActivityPaused(activity: Activity) {} - override fun onActivityStopped(activity: Activity) {} - override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {} - }) - } - - private val listener = Window.OnFrameMetricsAvailableListener { _, frameMetrics, _ -> - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - lastTotalDuration = frameMetrics.getMetric(FrameMetrics.TOTAL_DURATION) - lastAnimDuration = frameMetrics.getMetric(FrameMetrics.ANIMATION_DURATION) - lastLayoutDuration = frameMetrics.getMetric(FrameMetrics.LAYOUT_MEASURE_DURATION) - } - } - - fun isIdle(): Boolean { - Log.d("TAG", "isIdle: lastTotalDuration: ${lastTotalDuration/1000000.0}, lastAnimDuration: ${lastAnimDuration/1000000.0}, layoutDuration: ${lastLayoutDuration/1000000.0}") - return lastAnimDuration < ANIMATION_THRESHOLD_NS && lastLayoutDuration < LAYOUT_THRESHOLD_NS - } -} diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index 0e9c2bfde20..33cf3a25395 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -141,10 +141,13 @@ public enum SentryReplayQuality { /** * The screenshot strategy to use for capturing screenshots during replay recording. Defaults to - * PIXEL_COPY for better performance and quality. + * {@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.Internal - private ScreenshotStrategyType screenshotStrategy = ScreenshotStrategyType.CANVAS; + @ApiStatus.Experimental + private @NotNull ScreenshotStrategyType screenshotStrategy = ScreenshotStrategyType.PIXEL_COPY; public SentryReplayOptions(final boolean empty, final @Nullable SdkVersion sdkVersion) { if (!empty) { From 9cb82011695473a8242dd1b20fc1d3d9691fefed Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Thu, 2 Oct 2025 14:12:28 +0200 Subject: [PATCH 22/22] Move from ApiStatus.Internal to ApiStatus.Experimental --- .../io/sentry/android/replay/screenshot/CanvasStrategy.kt | 7 ++++++- sentry/src/main/java/io/sentry/ScreenshotStrategyType.java | 2 +- sentry/src/main/java/io/sentry/SentryReplayOptions.java | 4 ++-- 3 files changed, 9 insertions(+), 4 deletions(-) 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 index 5d1be09c60e..57981b4382c 100644 --- 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 @@ -32,6 +32,7 @@ import io.sentry.SentryOptions 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.LinkedList import java.util.WeakHashMap import java.util.concurrent.ScheduledExecutorService @@ -69,6 +70,8 @@ internal class CanvasStrategy( init { processingThread.start() processingHandler = Handler(processingThread.looper) + + IntegrationUtils.addIntegrationToSdkVersion("ReplayCanvasStrategy") } private val pictureRenderTask = Runnable { @@ -105,7 +108,9 @@ internal class CanvasStrategy( canvas.drawColor(Color.BLACK, PorterDuff.Mode.CLEAR) holder.picture.draw(canvas) surface.unlockCanvasAndPost(canvas) - } finally {} + } catch (t: Throwable) { + options.logger.log(SentryLevel.ERROR, "Canvas Strategy: picture render failed", t) + } } @SuppressLint("UnclosedTrace") diff --git a/sentry/src/main/java/io/sentry/ScreenshotStrategyType.java b/sentry/src/main/java/io/sentry/ScreenshotStrategyType.java index 28167aec6c1..ba5c3d1e3f1 100644 --- a/sentry/src/main/java/io/sentry/ScreenshotStrategyType.java +++ b/sentry/src/main/java/io/sentry/ScreenshotStrategyType.java @@ -3,7 +3,7 @@ import org.jetbrains.annotations.ApiStatus; /** Enum representing the available screenshot strategies for replay recording. */ -@ApiStatus.Internal +@ApiStatus.Experimental public enum ScreenshotStrategyType { /** Uses Canvas-based rendering for capturing screenshots */ CANVAS, diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index 33cf3a25395..80d9292ab5b 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -355,7 +355,7 @@ public void setDebug(final boolean debug) { * * @return the screenshot strategy */ - @ApiStatus.Internal + @ApiStatus.Experimental public @NotNull ScreenshotStrategyType getScreenshotStrategy() { return screenshotStrategy; } @@ -365,7 +365,7 @@ public void setDebug(final boolean debug) { * * @param screenshotStrategy the screenshot strategy to use */ - @ApiStatus.Internal + @ApiStatus.Experimental public void setScreenshotStrategy(final @NotNull ScreenshotStrategyType screenshotStrategy) { this.screenshotStrategy = screenshotStrategy; }