From d49b64a55b37f001994f7ba3fb9287120b8daece Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Fri, 8 May 2026 10:23:25 +0200 Subject: [PATCH 01/12] feat(replay): Add beforeStoreFrame callback (JAVA-504) Add an experimental callback that fires right before a replay frame is stored to disk. The callback receives the masked bitmap (via Hint), timestamp, and current screen name. This enables snapshot testing of replay masking without needing to decode stored video segments. Includes a Kotlin extension for ergonomic usage: options.sessionReplay.beforeStoreFrame { bitmap, ts, screen -> ... } Co-Authored-By: Claude Opus 4.6 (1M context) --- .../api/sentry-android-replay.api | 1 + .../android/replay/ReplayIntegration.kt | 13 +++ .../android/replay/SessionReplayOptions.kt | 27 ++++++ .../android/replay/ReplayIntegrationTest.kt | 91 +++++++++++++++++++ sentry/api/sentry.api | 7 ++ .../java/io/sentry/SentryReplayOptions.java | 51 +++++++++++ .../main/java/io/sentry/TypeCheckHint.java | 3 + 7 files changed, 193 insertions(+) diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index aeabe9c05c..a159a90448 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -116,6 +116,7 @@ public final class io/sentry/android/replay/SentryReplayModifiers { } public final class io/sentry/android/replay/SessionReplayOptionsKt { + public static final fun beforeStoreFrame (Lio/sentry/SentryReplayOptions;Lkotlin/jvm/functions/Function3;)V public static final fun getMaskAllImages (Lio/sentry/SentryReplayOptions;)Z public static final fun getMaskAllText (Lio/sentry/SentryReplayOptions;)Z public static final fun setMaskAllImages (Lio/sentry/SentryReplayOptions;Z)V 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 d25827e3c7..d473fd4fa4 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 @@ -7,6 +7,7 @@ import android.view.MotionEvent import io.sentry.Breadcrumb import io.sentry.DataCategory.All import io.sentry.DataCategory.Replay +import io.sentry.Hint import io.sentry.IConnectionStatusProvider.ConnectionStatus import io.sentry.IConnectionStatusProvider.ConnectionStatus.DISCONNECTED import io.sentry.IConnectionStatusProvider.IConnectionStatusObserver @@ -17,8 +18,10 @@ import io.sentry.ReplayBreadcrumbConverter import io.sentry.ReplayController import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryLevel.DEBUG +import io.sentry.SentryLevel.ERROR import io.sentry.SentryLevel.INFO import io.sentry.SentryOptions +import io.sentry.TypeCheckHint import io.sentry.android.replay.ReplayState.CLOSED import io.sentry.android.replay.ReplayState.PAUSED import io.sentry.android.replay.ReplayState.RESUMED @@ -308,6 +311,16 @@ public class ReplayIntegration( var screen: String? = null scopes?.configureScope { screen = it.screen?.substringAfterLast('.') } captureStrategy?.onScreenshotRecorded(bitmap) { frameTimeStamp -> + val callback = options.sessionReplay.beforeStoreFrame + if (callback != null) { + try { + val hint = Hint() + hint.set(TypeCheckHint.REPLAY_FRAME_BITMAP, bitmap) + callback.execute(hint, frameTimeStamp, screen) + } catch (e: Throwable) { + options.logger.log(ERROR, "Error in beforeStoreFrame callback", e) + } + } addFrame(bitmap, frameTimeStamp, screen) } checkCanRecord() diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/SessionReplayOptions.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/SessionReplayOptions.kt index f4723f1a49..db44e256aa 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/SessionReplayOptions.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/SessionReplayOptions.kt @@ -1,6 +1,8 @@ package io.sentry.android.replay +import android.graphics.Bitmap import io.sentry.SentryReplayOptions +import io.sentry.TypeCheckHint // since we don't have getters for maskAllText and maskAllimages, they won't be accessible as // properties in Kotlin, therefore we create these extensions where a getter is dummy, but a setter @@ -29,3 +31,28 @@ public var SentryReplayOptions.maskAllImages: Boolean @Deprecated("Getter is unsupported.", level = DeprecationLevel.ERROR) get() = error("Getter not supported") set(value) = setMaskAllImages(value) + +/** + * Sets a callback that is invoked right before a replay frame is stored to disk. The callback + * receives the frame bitmap (with masking applied), the timestamp, and the current screen name. + * + * The callback runs on a background thread (the replay executor). Do not recycle the bitmap — it + * may be reused by the replay system. + * + * @param callback the callback to invoke, or null to clear + */ +public fun SentryReplayOptions.beforeStoreFrame( + callback: ((frameBitmap: Bitmap, frameTimestamp: Long, screenName: String?) -> Unit)? +) { + beforeStoreFrame = + if (callback != null) { + SentryReplayOptions.BeforeStoreFrameCallback { hint, timestamp, screen -> + val bitmap = hint.getAs(TypeCheckHint.REPLAY_FRAME_BITMAP, Bitmap::class.java) + if (bitmap != null) { + callback(bitmap, timestamp, screen) + } + } + } else { + null + } +} diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt index 7c86a0ad01..15715a0d6b 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt @@ -18,6 +18,8 @@ import io.sentry.SentryEvent import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryOptions import io.sentry.SentryReplayEvent.ReplayType +import io.sentry.SentryReplayOptions +import io.sentry.TypeCheckHint import io.sentry.android.replay.ReplayCache.Companion.ONGOING_SEGMENT import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_BIT_RATE import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_FRAME_RATE @@ -969,6 +971,95 @@ class ReplayIntegrationTest { assertFalse(replay.isDebugMaskingOverlayEnabled) } + @Test + fun `beforeStoreFrame callback is invoked with bitmap in hint`() { + var callbackInvoked = false + var receivedTimestamp = 0L + var receivedScreen: String? = null + var receivedBitmap: Any? = null + + fixture.options.sessionReplay.beforeStoreFrame = + SentryReplayOptions.BeforeStoreFrameCallback { hint, frameTimestamp, screenName -> + callbackInvoked = true + receivedTimestamp = frameTimestamp + receivedScreen = screenName + receivedBitmap = hint.getAs(TypeCheckHint.REPLAY_FRAME_BITMAP, Bitmap::class.java) + } + + val captureStrategy = + mock { + doAnswer { + ((it.arguments[1] as ReplayCache.(frameTimestamp: Long) -> Unit)).invoke( + fixture.replayCache, + 1720693523997, + ) + } + .whenever(mock) + .onScreenshotRecorded(anyOrNull(), any()) + } + val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) + + fixture.scopes.configureScope { it.screen = "MainActivity" } + replay.register(fixture.scopes, fixture.options) + replay.start() + + replay.onScreenshotRecorded(mock()) + + assertTrue(callbackInvoked) + assertEquals(1720693523997, receivedTimestamp) + assertEquals("MainActivity", receivedScreen) + assertTrue(receivedBitmap is Bitmap) + } + + @Test + fun `beforeStoreFrame callback exception does not prevent frame storage`() { + fixture.options.sessionReplay.beforeStoreFrame = + SentryReplayOptions.BeforeStoreFrameCallback { _, _, _ -> throw RuntimeException("test") } + + val captureStrategy = + mock { + doAnswer { + ((it.arguments[1] as ReplayCache.(frameTimestamp: Long) -> Unit)).invoke( + fixture.replayCache, + 1720693523997, + ) + } + .whenever(mock) + .onScreenshotRecorded(anyOrNull(), any()) + } + val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) + + replay.register(fixture.scopes, fixture.options) + replay.start() + + replay.onScreenshotRecorded(mock()) + + verify(fixture.replayCache).addFrame(any(), any(), anyOrNull()) + } + + @Test + fun `beforeStoreFrame callback is not invoked when null`() { + val captureStrategy = + mock { + doAnswer { + ((it.arguments[1] as ReplayCache.(frameTimestamp: Long) -> Unit)).invoke( + fixture.replayCache, + 1720693523997, + ) + } + .whenever(mock) + .onScreenshotRecorded(anyOrNull(), any()) + } + val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) + + replay.register(fixture.scopes, fixture.options) + replay.start() + + replay.onScreenshotRecorded(mock()) + + verify(fixture.replayCache).addFrame(any(), any(), anyOrNull()) + } + private fun getSessionCaptureStrategy(options: SentryOptions): SessionCaptureStrategy = SessionCaptureStrategy( options, diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index a433abbb37..ce187ef100 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -4055,6 +4055,7 @@ public final class io/sentry/SentryReplayOptions : io/sentry/SentryMaskingOption public fun addMaskViewClass (Ljava/lang/String;)V public fun addUnmaskViewClass (Ljava/lang/String;)V public fun getBeforeErrorSampling ()Lio/sentry/SentryReplayOptions$BeforeErrorSamplingCallback; + public fun getBeforeStoreFrame ()Lio/sentry/SentryReplayOptions$BeforeStoreFrameCallback; public fun getErrorReplayDuration ()J public fun getFrameRate ()I public fun getNetworkDetailAllowUrls ()Ljava/util/List; @@ -4076,6 +4077,7 @@ public final class io/sentry/SentryReplayOptions : io/sentry/SentryMaskingOption public fun isSessionReplayForErrorsEnabled ()Z public fun isTrackConfiguration ()Z public fun setBeforeErrorSampling (Lio/sentry/SentryReplayOptions$BeforeErrorSamplingCallback;)V + public fun setBeforeStoreFrame (Lio/sentry/SentryReplayOptions$BeforeStoreFrameCallback;)V public fun setCaptureSurfaceViews (Z)V public fun setDebug (Z)V public fun setMaskAllImages (Z)V @@ -4098,6 +4100,10 @@ public abstract interface class io/sentry/SentryReplayOptions$BeforeErrorSamplin public abstract fun execute (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Z } +public abstract interface class io/sentry/SentryReplayOptions$BeforeStoreFrameCallback { + public abstract fun execute (Lio/sentry/Hint;JLjava/lang/String;)V +} + public final class io/sentry/SentryReplayOptions$SentryReplayQuality : java/lang/Enum { public static final field HIGH Lio/sentry/SentryReplayOptions$SentryReplayQuality; public static final field LOW Lio/sentry/SentryReplayOptions$SentryReplayQuality; @@ -4644,6 +4650,7 @@ public final class io/sentry/TypeCheckHint { public static final field OKHTTP_RESPONSE Ljava/lang/String; public static final field OPEN_FEIGN_REQUEST Ljava/lang/String; public static final field OPEN_FEIGN_RESPONSE Ljava/lang/String; + public static final field REPLAY_FRAME_BITMAP Ljava/lang/String; public static final field SENTRY_DART_SDK_NAME Ljava/lang/String; public static final field SENTRY_DOTNET_SDK_NAME Ljava/lang/String; public static final field SENTRY_EVENT_DROP_REASON Ljava/lang/String; diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index 6eb4a58e1c..ae1057336c 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -17,6 +17,30 @@ public final class SentryReplayOptions extends SentryMaskingOptions { + /** + * Callback that is invoked right before a replay frame is stored to disk. This allows + * intercepting frames for testing (e.g., screenshot comparison tests) or custom processing. The + * callback receives the frame after masking has been applied. + * + *

The frame bitmap is passed via a {@link Hint} using the key {@link + * TypeCheckHint#REPLAY_FRAME_BITMAP}. On Android, retrieve it with: {@code hint.getAs( + * TypeCheckHint.REPLAY_FRAME_BITMAP, Bitmap.class)}. + * + *

The callback runs on a background thread (replay executor). Do not recycle the bitmap — it + * may be reused by the replay system. + */ + @ApiStatus.Experimental + public interface BeforeStoreFrameCallback { + /** + * Called before a replay frame is stored to disk. + * + * @param hint contains the frame bitmap under {@link TypeCheckHint#REPLAY_FRAME_BITMAP} + * @param frameTimestamp the timestamp (in milliseconds since epoch) when the frame was captured + * @param screenName the current screen name, or {@code null} if unknown + */ + void execute(@NotNull Hint hint, long frameTimestamp, @Nullable String screenName); + } + /** * Callback that is called before the error sample rate is checked for session replay. If the * callback returns {@code false}, the replay will not be captured for this error event, and the @@ -211,6 +235,12 @@ public enum SentryReplayQuality { */ private @Nullable BeforeErrorSamplingCallback beforeErrorSampling; + /** + * A callback that is invoked right before a replay frame is stored to disk. Can be used for + * screenshot snapshot testing or custom frame processing. + */ + @ApiStatus.Experimental private @Nullable BeforeStoreFrameCallback beforeStoreFrame; + public SentryReplayOptions(final boolean empty, final @Nullable SdkVersion sdkVersion) { if (!empty) { // Add default mask classes directly without setting usingCustomMasking flag @@ -550,4 +580,25 @@ public void setBeforeErrorSampling( final @Nullable BeforeErrorSamplingCallback beforeErrorSampling) { this.beforeErrorSampling = beforeErrorSampling; } + + /** + * Gets the callback that is invoked before a replay frame is stored to disk. + * + * @return the callback, or {@code null} if not set + */ + @ApiStatus.Experimental + public @Nullable BeforeStoreFrameCallback getBeforeStoreFrame() { + return beforeStoreFrame; + } + + /** + * Sets the callback that is invoked before a replay frame is stored to disk. The frame bitmap is + * passed via a {@link Hint} using the key {@link TypeCheckHint#REPLAY_FRAME_BITMAP}. + * + * @param beforeStoreFrame the callback, or {@code null} to clear + */ + @ApiStatus.Experimental + public void setBeforeStoreFrame(final @Nullable BeforeStoreFrameCallback beforeStoreFrame) { + this.beforeStoreFrame = beforeStoreFrame; + } } diff --git a/sentry/src/main/java/io/sentry/TypeCheckHint.java b/sentry/src/main/java/io/sentry/TypeCheckHint.java index 189050570b..e435fedc1f 100644 --- a/sentry/src/main/java/io/sentry/TypeCheckHint.java +++ b/sentry/src/main/java/io/sentry/TypeCheckHint.java @@ -140,4 +140,7 @@ public final class TypeCheckHint { /** Used for Ktor Request breadcrumbs. */ public static final String KTOR_CLIENT_REQUEST = "ktorClient:request"; + + /** Used for Session Replay frame bitmaps in the beforeStoreFrame callback. */ + public static final String REPLAY_FRAME_BITMAP = "replay:frameBitmap"; } From 4b5fb3a9480ea0734bf3aff132da468d3972fb4f Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Fri, 8 May 2026 10:23:36 +0200 Subject: [PATCH 02/12] feat(replay): Add replay snapshot UI test with Sauce Labs collection (JAVA-504) Add ReplaySnapshotTest that uses the beforeStoreFrame callback to capture masked replay frames during a Compose UI test. Frames are written to the Downloads/sauce_labs_custom_screenshots/ directory, which is the standard path Sauce Labs collects screenshots from. CI changes: - Add *.png to Sauce Labs artifact match patterns - Upload collected replay snapshots via sentry-cli build snapshots Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/integration-tests-ui.yml | 22 +++++++ .sauce/sentry-uitest-android-ui.yml | 1 + .../uitest/android/ReplaySnapshotTest.kt | 61 +++++++++++++++++++ 3 files changed, 84 insertions(+) create mode 100644 sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplaySnapshotTest.kt diff --git a/.github/workflows/integration-tests-ui.yml b/.github/workflows/integration-tests-ui.yml index 0549577f62..5206a17336 100644 --- a/.github/workflows/integration-tests-ui.yml +++ b/.github/workflows/integration-tests-ui.yml @@ -73,6 +73,28 @@ jobs: if: env.SAUCE_USERNAME != null + - name: Install Sentry CLI + if: ${{ !cancelled() && env.SAUCE_USERNAME != null }} + run: curl -sL https://sentry.io/get-cli/ | bash + + - name: Upload Replay Snapshots to Sentry + if: ${{ !cancelled() && env.SAUCE_USERNAME != null }} + run: | + shopt -s globstar nullglob + pngs=(artifacts/**/*.png) + if [ ${#pngs[@]} -gt 0 ]; then + mkdir -p replay-snapshots + cp "${pngs[@]}" replay-snapshots/ + sentry-cli build snapshots ./replay-snapshots \ + --app-id sentry-android-replay + else + echo "No replay snapshot files found, skipping upload" + fi + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: sentry-sdks + SENTRY_PROJECT: sentry-android + - name: Upload test results to Codecov if: ${{ !cancelled() }} uses: codecov/test-results-action@0fa95f0e1eeaafde2c782583b36b28ad0d8c77d3 diff --git a/.sauce/sentry-uitest-android-ui.yml b/.sauce/sentry-uitest-android-ui.yml index 8d84f865c9..a00ee10614 100644 --- a/.sauce/sentry-uitest-android-ui.yml +++ b/.sauce/sentry-uitest-android-ui.yml @@ -32,4 +32,5 @@ artifacts: when: always match: - junit.xml + - "*.png" directory: ./artifacts/ diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplaySnapshotTest.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplaySnapshotTest.kt new file mode 100644 index 0000000000..8ffb606d93 --- /dev/null +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplaySnapshotTest.kt @@ -0,0 +1,61 @@ +package io.sentry.uitest.android + +import android.graphics.Bitmap +import android.os.Environment +import androidx.lifecycle.Lifecycle +import androidx.test.core.app.launchActivity +import io.sentry.android.replay.beforeStoreFrame +import java.io.File +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertTrue + +class ReplaySnapshotTest : BaseUiTest() { + + @Test + fun captureComposeReplayFrameSnapshots() { + val snapshotsDir = + File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), + "sauce_labs_custom_screenshots", + ) + .apply { + deleteRecursively() + mkdirs() + } + val frameReceived = CountDownLatch(1) + val capturedScreens = CopyOnWriteArrayList() + + val activityScenario = launchActivity() + activityScenario.moveToState(Lifecycle.State.RESUMED) + + initSentry { + it.sessionReplay.sessionSampleRate = 1.0 + it.sessionReplay.beforeStoreFrame { + frameBitmap: Bitmap, + frameTimestamp: Long, + screenName: String? -> + val name = screenName ?: "unknown" + if (!capturedScreens.contains(name)) { + val file = File(snapshotsDir, "${name}_$frameTimestamp.png") + file.outputStream().use { out -> + frameBitmap.compress(Bitmap.CompressFormat.PNG, 100, out) + } + capturedScreens.add(name) + } + frameReceived.countDown() + } + } + + assertTrue(frameReceived.await(10, TimeUnit.SECONDS), "Expected at least one replay frame") + assertTrue(capturedScreens.isNotEmpty(), "Expected at least one screen captured") + + val files = snapshotsDir.listFiles()?.filter { it.extension == "png" } ?: emptyList() + assertTrue(files.isNotEmpty(), "Expected snapshot PNG files on disk") + assertTrue(files.all { it.length() > 0 }, "Snapshot files should not be empty") + + activityScenario.moveToState(Lifecycle.State.DESTROYED) + } +} From 9d6f12a9c1fa2e76fa90438b9ba2fd8b0f609555 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Fri, 8 May 2026 11:20:51 +0200 Subject: [PATCH 03/12] fix(replay): Use Java API in snapshot test to avoid extension dep (JAVA-504) The Kotlin extension `beforeStoreFrame` comes from `sentry-android-replay` which may not resolve in the UI test module. Use the Java callback API directly instead. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../uitest/android/ReplaySnapshotTest.kt | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplaySnapshotTest.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplaySnapshotTest.kt index 8ffb606d93..6abb31615b 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplaySnapshotTest.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplaySnapshotTest.kt @@ -4,7 +4,8 @@ import android.graphics.Bitmap import android.os.Environment import androidx.lifecycle.Lifecycle import androidx.test.core.app.launchActivity -import io.sentry.android.replay.beforeStoreFrame +import io.sentry.SentryReplayOptions +import io.sentry.TypeCheckHint import java.io.File import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.CountDownLatch @@ -33,20 +34,22 @@ class ReplaySnapshotTest : BaseUiTest() { initSentry { it.sessionReplay.sessionSampleRate = 1.0 - it.sessionReplay.beforeStoreFrame { - frameBitmap: Bitmap, - frameTimestamp: Long, - screenName: String? -> - val name = screenName ?: "unknown" - if (!capturedScreens.contains(name)) { - val file = File(snapshotsDir, "${name}_$frameTimestamp.png") - file.outputStream().use { out -> - frameBitmap.compress(Bitmap.CompressFormat.PNG, 100, out) + it.sessionReplay.setBeforeStoreFrame( + SentryReplayOptions.BeforeStoreFrameCallback { hint, frameTimestamp, screenName -> + val frameBitmap = + hint.getAs(TypeCheckHint.REPLAY_FRAME_BITMAP, Bitmap::class.java) + ?: return@BeforeStoreFrameCallback + val name = screenName ?: "unknown" + if (!capturedScreens.contains(name)) { + val file = File(snapshotsDir, "${name}_$frameTimestamp.png") + file.outputStream().use { out -> + frameBitmap.compress(Bitmap.CompressFormat.PNG, 100, out) + } + capturedScreens.add(name) } - capturedScreens.add(name) + frameReceived.countDown() } - frameReceived.countDown() - } + ) } assertTrue(frameReceived.await(10, TimeUnit.SECONDS), "Expected at least one replay frame") From 240dd96b477f9497af0c4b1e3b8647f41793a0f2 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Mon, 11 May 2026 10:02:33 +0200 Subject: [PATCH 04/12] fix(replay): Skip snapshot test on GH emulators and add changelog (JAVA-504) GH Actions emulators don't support screenshot capture for replay, so the ReplaySnapshotTest needs the same assumeThat guard used by ReplayTest. Also adds a changelog entry for the beforeStoreFrame callback. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 1 + .../io/sentry/uitest/android/ReplaySnapshotTest.kt | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bfb5194769..9b1bd32885 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Features - Add support to configure reporting historical ANRs via `AndroidManifest.xml` using the `io.sentry.anr.report-historical` attribute ([#5387](https://github.com/getsentry/sentry-java/pull/5387)) +- Session Replay: Add `beforeStoreFrame` callback ([#5386](https://github.com/getsentry/sentry-java/pull/5386)) ### Dependencies diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplaySnapshotTest.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplaySnapshotTest.kt index 6abb31615b..4e19a24077 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplaySnapshotTest.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplaySnapshotTest.kt @@ -12,9 +12,19 @@ import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import kotlin.test.Test import kotlin.test.assertTrue +import org.hamcrest.CoreMatchers.`is` +import org.junit.Assume.assumeThat +import org.junit.Before class ReplaySnapshotTest : BaseUiTest() { + @Before + fun setup() { + // GH Actions emulators don't support capturing screenshots for replay + @Suppress("KotlinConstantConditions") + assumeThat(BuildConfig.ENVIRONMENT != "github", `is`(true)) + } + @Test fun captureComposeReplayFrameSnapshots() { val snapshotsDir = From 1f6b03ceb1f2608558877681b6d67da9fec3bccc Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 12 May 2026 11:13:04 +0200 Subject: [PATCH 05/12] Apply suggestion from @markushi Co-authored-by: Markus Hintersteiner --- 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 ae1057336c..4d44394847 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -29,7 +29,7 @@ public final class SentryReplayOptions extends SentryMaskingOptions { *

The callback runs on a background thread (replay executor). Do not recycle the bitmap — it * may be reused by the replay system. */ - @ApiStatus.Experimental + @ApiStatus.Internal public interface BeforeStoreFrameCallback { /** * Called before a replay frame is stored to disk. From d2b2259bb166922ba7abdf924727ef57e4a990d0 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Wed, 13 May 2026 09:11:09 +0200 Subject: [PATCH 06/12] refactor(replay): Replace beforeStoreFrame with ReplaySnapshotObserver (JAVA-504) Move the frame observer API from the core sentry module to sentry-android-replay so it can use Bitmap directly instead of the Hint indirection. The new ReplaySnapshotObserver fun interface lives in the replay module and is set on ReplayIntegration. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 2 +- .../uitest/android/ReplaySnapshotTest.kt | 34 +++++-------- .../api/sentry-android-replay.api | 5 +- .../android/replay/ReplayIntegration.kt | 14 +++-- .../android/replay/SessionReplayOptions.kt | 28 +++------- .../android/replay/ReplayIntegrationTest.kt | 30 +++++------ sentry/api/sentry.api | 7 --- .../java/io/sentry/SentryReplayOptions.java | 51 ------------------- .../main/java/io/sentry/TypeCheckHint.java | 3 -- 9 files changed, 46 insertions(+), 128 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b1bd32885..3d89a278da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ### Features - Add support to configure reporting historical ANRs via `AndroidManifest.xml` using the `io.sentry.anr.report-historical` attribute ([#5387](https://github.com/getsentry/sentry-java/pull/5387)) -- Session Replay: Add `beforeStoreFrame` callback ([#5386](https://github.com/getsentry/sentry-java/pull/5386)) +- Session Replay: Add `ReplaySnapshotObserver` for observing captured replay frames ([#5386](https://github.com/getsentry/sentry-java/pull/5386)) ### Dependencies diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplaySnapshotTest.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplaySnapshotTest.kt index 4e19a24077..dc3f77d114 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplaySnapshotTest.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplaySnapshotTest.kt @@ -4,8 +4,9 @@ import android.graphics.Bitmap import android.os.Environment import androidx.lifecycle.Lifecycle import androidx.test.core.app.launchActivity -import io.sentry.SentryReplayOptions -import io.sentry.TypeCheckHint +import io.sentry.Sentry +import io.sentry.android.replay.ReplayIntegration +import io.sentry.android.replay.ReplaySnapshotObserver import java.io.File import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.CountDownLatch @@ -42,24 +43,17 @@ class ReplaySnapshotTest : BaseUiTest() { val activityScenario = launchActivity() activityScenario.moveToState(Lifecycle.State.RESUMED) - initSentry { - it.sessionReplay.sessionSampleRate = 1.0 - it.sessionReplay.setBeforeStoreFrame( - SentryReplayOptions.BeforeStoreFrameCallback { hint, frameTimestamp, screenName -> - val frameBitmap = - hint.getAs(TypeCheckHint.REPLAY_FRAME_BITMAP, Bitmap::class.java) - ?: return@BeforeStoreFrameCallback - val name = screenName ?: "unknown" - if (!capturedScreens.contains(name)) { - val file = File(snapshotsDir, "${name}_$frameTimestamp.png") - file.outputStream().use { out -> - frameBitmap.compress(Bitmap.CompressFormat.PNG, 100, out) - } - capturedScreens.add(name) - } - frameReceived.countDown() - } - ) + initSentry { it.sessionReplay.sessionSampleRate = 1.0 } + + val integration = Sentry.getCurrentScopes().options.replayController as? ReplayIntegration + integration?.snapshotObserver = ReplaySnapshotObserver { bitmap, frameTimestamp, screenName -> + val name = screenName ?: "unknown" + if (!capturedScreens.contains(name)) { + val file = File(snapshotsDir, "${name}_$frameTimestamp.png") + file.outputStream().use { out -> bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) } + capturedScreens.add(name) + } + frameReceived.countDown() } assertTrue(frameReceived.await(10, TimeUnit.SECONDS), "Expected at least one replay frame") diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index a159a90448..7a288128f7 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -82,6 +82,10 @@ public final class io/sentry/android/replay/ReplayIntegration : io/sentry/IConne public fun stop ()V } +public abstract interface class io/sentry/android/replay/ReplaySnapshotObserver { + public abstract fun onSnapshotCaptured (Landroid/graphics/Bitmap;JLjava/lang/String;)V +} + public abstract interface class io/sentry/android/replay/ScreenshotRecorderCallback { public abstract fun onScreenshotRecorded (Landroid/graphics/Bitmap;)V public abstract fun onScreenshotRecorded (Ljava/io/File;J)V @@ -116,7 +120,6 @@ public final class io/sentry/android/replay/SentryReplayModifiers { } public final class io/sentry/android/replay/SessionReplayOptionsKt { - public static final fun beforeStoreFrame (Lio/sentry/SentryReplayOptions;Lkotlin/jvm/functions/Function3;)V public static final fun getMaskAllImages (Lio/sentry/SentryReplayOptions;)Z public static final fun getMaskAllText (Lio/sentry/SentryReplayOptions;)Z public static final fun setMaskAllImages (Lio/sentry/SentryReplayOptions;Z)V 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 d473fd4fa4..b44d05c2e6 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 @@ -7,7 +7,6 @@ import android.view.MotionEvent import io.sentry.Breadcrumb import io.sentry.DataCategory.All import io.sentry.DataCategory.Replay -import io.sentry.Hint import io.sentry.IConnectionStatusProvider.ConnectionStatus import io.sentry.IConnectionStatusProvider.ConnectionStatus.DISCONNECTED import io.sentry.IConnectionStatusProvider.IConnectionStatusObserver @@ -21,7 +20,6 @@ import io.sentry.SentryLevel.DEBUG import io.sentry.SentryLevel.ERROR import io.sentry.SentryLevel.INFO import io.sentry.SentryOptions -import io.sentry.TypeCheckHint import io.sentry.android.replay.ReplayState.CLOSED import io.sentry.android.replay.ReplayState.PAUSED import io.sentry.android.replay.ReplayState.RESUMED @@ -125,6 +123,8 @@ public class ReplayIntegration( private val lifecycleLock = AutoClosableReentrantLock() private val lifecycle = ReplayLifecycle() + @Volatile internal var snapshotObserver: ReplaySnapshotObserver? = null + override fun register(scopes: IScopes, options: SentryOptions) { this.options = options @@ -311,14 +311,12 @@ public class ReplayIntegration( var screen: String? = null scopes?.configureScope { screen = it.screen?.substringAfterLast('.') } captureStrategy?.onScreenshotRecorded(bitmap) { frameTimeStamp -> - val callback = options.sessionReplay.beforeStoreFrame - if (callback != null) { + val observer = snapshotObserver + if (observer != null) { try { - val hint = Hint() - hint.set(TypeCheckHint.REPLAY_FRAME_BITMAP, bitmap) - callback.execute(hint, frameTimeStamp, screen) + observer.onSnapshotCaptured(bitmap, frameTimeStamp, screen) } catch (e: Throwable) { - options.logger.log(ERROR, "Error in beforeStoreFrame callback", e) + options.logger.log(ERROR, "Error in ReplaySnapshotObserver", e) } } addFrame(bitmap, frameTimeStamp, screen) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/SessionReplayOptions.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/SessionReplayOptions.kt index db44e256aa..5af81add8c 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/SessionReplayOptions.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/SessionReplayOptions.kt @@ -2,7 +2,6 @@ package io.sentry.android.replay import android.graphics.Bitmap import io.sentry.SentryReplayOptions -import io.sentry.TypeCheckHint // since we don't have getters for maskAllText and maskAllimages, they won't be accessible as // properties in Kotlin, therefore we create these extensions where a getter is dummy, but a setter @@ -33,26 +32,15 @@ public var SentryReplayOptions.maskAllImages: Boolean set(value) = setMaskAllImages(value) /** - * Sets a callback that is invoked right before a replay frame is stored to disk. The callback - * receives the frame bitmap (with masking applied), the timestamp, and the current screen name. + * Observer that is notified when a replay snapshot is captured. The snapshot bitmap has masking + * already applied. * - * The callback runs on a background thread (the replay executor). Do not recycle the bitmap — it - * may be reused by the replay system. + * **Bitmap lifecycle:** The bitmap is owned by the replay system and may be reused. Do not store a + * reference to it or access it after this method returns — copy the pixel data (e.g., compress to a + * file) within this method if you need it later. Do not recycle the bitmap. * - * @param callback the callback to invoke, or null to clear + * The callback runs on a background thread (the replay executor). */ -public fun SentryReplayOptions.beforeStoreFrame( - callback: ((frameBitmap: Bitmap, frameTimestamp: Long, screenName: String?) -> Unit)? -) { - beforeStoreFrame = - if (callback != null) { - SentryReplayOptions.BeforeStoreFrameCallback { hint, timestamp, screen -> - val bitmap = hint.getAs(TypeCheckHint.REPLAY_FRAME_BITMAP, Bitmap::class.java) - if (bitmap != null) { - callback(bitmap, timestamp, screen) - } - } - } else { - null - } +public fun interface ReplaySnapshotObserver { + public fun onSnapshotCaptured(bitmap: Bitmap, frameTimestamp: Long, screenName: String?) } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt index 15715a0d6b..c6ea1bf42a 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt @@ -18,8 +18,6 @@ import io.sentry.SentryEvent import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryOptions import io.sentry.SentryReplayEvent.ReplayType -import io.sentry.SentryReplayOptions -import io.sentry.TypeCheckHint import io.sentry.android.replay.ReplayCache.Companion.ONGOING_SEGMENT import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_BIT_RATE import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_FRAME_RATE @@ -972,19 +970,11 @@ class ReplayIntegrationTest { } @Test - fun `beforeStoreFrame callback is invoked with bitmap in hint`() { + fun `snapshot observer is invoked with bitmap and metadata`() { var callbackInvoked = false var receivedTimestamp = 0L var receivedScreen: String? = null - var receivedBitmap: Any? = null - - fixture.options.sessionReplay.beforeStoreFrame = - SentryReplayOptions.BeforeStoreFrameCallback { hint, frameTimestamp, screenName -> - callbackInvoked = true - receivedTimestamp = frameTimestamp - receivedScreen = screenName - receivedBitmap = hint.getAs(TypeCheckHint.REPLAY_FRAME_BITMAP, Bitmap::class.java) - } + var receivedBitmap: Bitmap? = null val captureStrategy = mock { @@ -1003,6 +993,13 @@ class ReplayIntegrationTest { replay.register(fixture.scopes, fixture.options) replay.start() + replay.snapshotObserver = ReplaySnapshotObserver { bitmap, frameTimestamp, screenName -> + callbackInvoked = true + receivedTimestamp = frameTimestamp + receivedScreen = screenName + receivedBitmap = bitmap + } + replay.onScreenshotRecorded(mock()) assertTrue(callbackInvoked) @@ -1012,10 +1009,7 @@ class ReplayIntegrationTest { } @Test - fun `beforeStoreFrame callback exception does not prevent frame storage`() { - fixture.options.sessionReplay.beforeStoreFrame = - SentryReplayOptions.BeforeStoreFrameCallback { _, _, _ -> throw RuntimeException("test") } - + fun `snapshot observer exception does not prevent frame storage`() { val captureStrategy = mock { doAnswer { @@ -1032,13 +1026,15 @@ class ReplayIntegrationTest { replay.register(fixture.scopes, fixture.options) replay.start() + replay.snapshotObserver = ReplaySnapshotObserver { _, _, _ -> throw RuntimeException("test") } + replay.onScreenshotRecorded(mock()) verify(fixture.replayCache).addFrame(any(), any(), anyOrNull()) } @Test - fun `beforeStoreFrame callback is not invoked when null`() { + fun `snapshot observer is not invoked when null`() { val captureStrategy = mock { doAnswer { diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index ce187ef100..a433abbb37 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -4055,7 +4055,6 @@ public final class io/sentry/SentryReplayOptions : io/sentry/SentryMaskingOption public fun addMaskViewClass (Ljava/lang/String;)V public fun addUnmaskViewClass (Ljava/lang/String;)V public fun getBeforeErrorSampling ()Lio/sentry/SentryReplayOptions$BeforeErrorSamplingCallback; - public fun getBeforeStoreFrame ()Lio/sentry/SentryReplayOptions$BeforeStoreFrameCallback; public fun getErrorReplayDuration ()J public fun getFrameRate ()I public fun getNetworkDetailAllowUrls ()Ljava/util/List; @@ -4077,7 +4076,6 @@ public final class io/sentry/SentryReplayOptions : io/sentry/SentryMaskingOption public fun isSessionReplayForErrorsEnabled ()Z public fun isTrackConfiguration ()Z public fun setBeforeErrorSampling (Lio/sentry/SentryReplayOptions$BeforeErrorSamplingCallback;)V - public fun setBeforeStoreFrame (Lio/sentry/SentryReplayOptions$BeforeStoreFrameCallback;)V public fun setCaptureSurfaceViews (Z)V public fun setDebug (Z)V public fun setMaskAllImages (Z)V @@ -4100,10 +4098,6 @@ public abstract interface class io/sentry/SentryReplayOptions$BeforeErrorSamplin public abstract fun execute (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Z } -public abstract interface class io/sentry/SentryReplayOptions$BeforeStoreFrameCallback { - public abstract fun execute (Lio/sentry/Hint;JLjava/lang/String;)V -} - public final class io/sentry/SentryReplayOptions$SentryReplayQuality : java/lang/Enum { public static final field HIGH Lio/sentry/SentryReplayOptions$SentryReplayQuality; public static final field LOW Lio/sentry/SentryReplayOptions$SentryReplayQuality; @@ -4650,7 +4644,6 @@ public final class io/sentry/TypeCheckHint { public static final field OKHTTP_RESPONSE Ljava/lang/String; public static final field OPEN_FEIGN_REQUEST Ljava/lang/String; public static final field OPEN_FEIGN_RESPONSE Ljava/lang/String; - public static final field REPLAY_FRAME_BITMAP Ljava/lang/String; public static final field SENTRY_DART_SDK_NAME Ljava/lang/String; public static final field SENTRY_DOTNET_SDK_NAME Ljava/lang/String; public static final field SENTRY_EVENT_DROP_REASON Ljava/lang/String; diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index 4d44394847..6eb4a58e1c 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -17,30 +17,6 @@ public final class SentryReplayOptions extends SentryMaskingOptions { - /** - * Callback that is invoked right before a replay frame is stored to disk. This allows - * intercepting frames for testing (e.g., screenshot comparison tests) or custom processing. The - * callback receives the frame after masking has been applied. - * - *

The frame bitmap is passed via a {@link Hint} using the key {@link - * TypeCheckHint#REPLAY_FRAME_BITMAP}. On Android, retrieve it with: {@code hint.getAs( - * TypeCheckHint.REPLAY_FRAME_BITMAP, Bitmap.class)}. - * - *

The callback runs on a background thread (replay executor). Do not recycle the bitmap — it - * may be reused by the replay system. - */ - @ApiStatus.Internal - public interface BeforeStoreFrameCallback { - /** - * Called before a replay frame is stored to disk. - * - * @param hint contains the frame bitmap under {@link TypeCheckHint#REPLAY_FRAME_BITMAP} - * @param frameTimestamp the timestamp (in milliseconds since epoch) when the frame was captured - * @param screenName the current screen name, or {@code null} if unknown - */ - void execute(@NotNull Hint hint, long frameTimestamp, @Nullable String screenName); - } - /** * Callback that is called before the error sample rate is checked for session replay. If the * callback returns {@code false}, the replay will not be captured for this error event, and the @@ -235,12 +211,6 @@ public enum SentryReplayQuality { */ private @Nullable BeforeErrorSamplingCallback beforeErrorSampling; - /** - * A callback that is invoked right before a replay frame is stored to disk. Can be used for - * screenshot snapshot testing or custom frame processing. - */ - @ApiStatus.Experimental private @Nullable BeforeStoreFrameCallback beforeStoreFrame; - public SentryReplayOptions(final boolean empty, final @Nullable SdkVersion sdkVersion) { if (!empty) { // Add default mask classes directly without setting usingCustomMasking flag @@ -580,25 +550,4 @@ public void setBeforeErrorSampling( final @Nullable BeforeErrorSamplingCallback beforeErrorSampling) { this.beforeErrorSampling = beforeErrorSampling; } - - /** - * Gets the callback that is invoked before a replay frame is stored to disk. - * - * @return the callback, or {@code null} if not set - */ - @ApiStatus.Experimental - public @Nullable BeforeStoreFrameCallback getBeforeStoreFrame() { - return beforeStoreFrame; - } - - /** - * Sets the callback that is invoked before a replay frame is stored to disk. The frame bitmap is - * passed via a {@link Hint} using the key {@link TypeCheckHint#REPLAY_FRAME_BITMAP}. - * - * @param beforeStoreFrame the callback, or {@code null} to clear - */ - @ApiStatus.Experimental - public void setBeforeStoreFrame(final @Nullable BeforeStoreFrameCallback beforeStoreFrame) { - this.beforeStoreFrame = beforeStoreFrame; - } } diff --git a/sentry/src/main/java/io/sentry/TypeCheckHint.java b/sentry/src/main/java/io/sentry/TypeCheckHint.java index e435fedc1f..189050570b 100644 --- a/sentry/src/main/java/io/sentry/TypeCheckHint.java +++ b/sentry/src/main/java/io/sentry/TypeCheckHint.java @@ -140,7 +140,4 @@ public final class TypeCheckHint { /** Used for Ktor Request breadcrumbs. */ public static final String KTOR_CLIENT_REQUEST = "ktorClient:request"; - - /** Used for Session Replay frame bitmaps in the beforeStoreFrame callback. */ - public static final String REPLAY_FRAME_BITMAP = "replay:frameBitmap"; } From 2b576be30f2610780dcbfee7c7f0d5c2221a4101 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Wed, 13 May 2026 09:38:10 +0200 Subject: [PATCH 07/12] fix(replay): Mark ReplaySnapshotObserver as experimental and use Set in test (JAVA-504) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../java/io/sentry/uitest/android/ReplaySnapshotTest.kt | 7 +++---- .../java/io/sentry/android/replay/SessionReplayOptions.kt | 2 ++ 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplaySnapshotTest.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplaySnapshotTest.kt index dc3f77d114..e3473635bb 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplaySnapshotTest.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplaySnapshotTest.kt @@ -8,7 +8,7 @@ import io.sentry.Sentry import io.sentry.android.replay.ReplayIntegration import io.sentry.android.replay.ReplaySnapshotObserver import java.io.File -import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CopyOnWriteArraySet import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import kotlin.test.Test @@ -38,7 +38,7 @@ class ReplaySnapshotTest : BaseUiTest() { mkdirs() } val frameReceived = CountDownLatch(1) - val capturedScreens = CopyOnWriteArrayList() + val capturedScreens = CopyOnWriteArraySet() val activityScenario = launchActivity() activityScenario.moveToState(Lifecycle.State.RESUMED) @@ -48,10 +48,9 @@ class ReplaySnapshotTest : BaseUiTest() { val integration = Sentry.getCurrentScopes().options.replayController as? ReplayIntegration integration?.snapshotObserver = ReplaySnapshotObserver { bitmap, frameTimestamp, screenName -> val name = screenName ?: "unknown" - if (!capturedScreens.contains(name)) { + if (capturedScreens.add(name)) { val file = File(snapshotsDir, "${name}_$frameTimestamp.png") file.outputStream().use { out -> bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) } - capturedScreens.add(name) } frameReceived.countDown() } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/SessionReplayOptions.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/SessionReplayOptions.kt index 5af81add8c..0f45239baa 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/SessionReplayOptions.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/SessionReplayOptions.kt @@ -40,6 +40,8 @@ public var SentryReplayOptions.maskAllImages: Boolean * file) within this method if you need it later. Do not recycle the bitmap. * * The callback runs on a background thread (the replay executor). + * + * This API is experimental and may change without notice. */ public fun interface ReplaySnapshotObserver { public fun onSnapshotCaptured(bitmap: Bitmap, frameTimestamp: Long, screenName: String?) From f2c0c494d89a1001a24eb256a41b0fa44d71972b Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Wed, 13 May 2026 09:42:28 +0200 Subject: [PATCH 08/12] fix(replay): Add @ApiStatus.Experimental to ReplaySnapshotObserver (JAVA-504) Co-Authored-By: Claude Opus 4.6 (1M context) --- sentry-android-replay/build.gradle.kts | 3 +++ .../java/io/sentry/android/replay/SessionReplayOptions.kt | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/sentry-android-replay/build.gradle.kts b/sentry-android-replay/build.gradle.kts index 60d38c0ae0..b53c603e08 100644 --- a/sentry-android-replay/build.gradle.kts +++ b/sentry-android-replay/build.gradle.kts @@ -61,6 +61,8 @@ android { buildFeatures { buildConfig = true } + configurations.all { resolutionStrategy.force(libs.jetbrains.annotations.get()) } + androidComponents.beforeVariants { it.enable = !Config.Android.shouldSkipDebugVariant(it.buildType) } @@ -71,6 +73,7 @@ kotlin { explicitApi() } dependencies { api(projects.sentry) + compileOnly(libs.jetbrains.annotations) compileOnly(libs.androidx.compose.ui.replay) implementation(kotlin(Config.kotlinStdLib, Config.kotlinStdLibVersionAndroid)) // tests diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/SessionReplayOptions.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/SessionReplayOptions.kt index 0f45239baa..c8199590eb 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/SessionReplayOptions.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/SessionReplayOptions.kt @@ -2,6 +2,7 @@ package io.sentry.android.replay import android.graphics.Bitmap import io.sentry.SentryReplayOptions +import org.jetbrains.annotations.ApiStatus // since we don't have getters for maskAllText and maskAllimages, they won't be accessible as // properties in Kotlin, therefore we create these extensions where a getter is dummy, but a setter @@ -40,9 +41,8 @@ public var SentryReplayOptions.maskAllImages: Boolean * file) within this method if you need it later. Do not recycle the bitmap. * * The callback runs on a background thread (the replay executor). - * - * This API is experimental and may change without notice. */ +@ApiStatus.Experimental public fun interface ReplaySnapshotObserver { public fun onSnapshotCaptured(bitmap: Bitmap, frameTimestamp: Long, screenName: String?) } From f39d8f5b59ea583994608584201981d6d839b927 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Wed, 13 May 2026 09:55:33 +0200 Subject: [PATCH 09/12] fix(replay): Make snapshotObserver public for cross-module access (JAVA-504) Co-Authored-By: Claude Opus 4.6 (1M context) --- sentry-android-replay/api/sentry-android-replay.api | 2 ++ .../src/main/java/io/sentry/android/replay/ReplayIntegration.kt | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index 7a288128f7..52a0547c9f 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -65,6 +65,7 @@ public final class io/sentry/android/replay/ReplayIntegration : io/sentry/IConne public fun getBreadcrumbConverter ()Lio/sentry/ReplayBreadcrumbConverter; public final fun getReplayCacheDir ()Ljava/io/File; public fun getReplayId ()Lio/sentry/protocol/SentryId; + public final fun getSnapshotObserver ()Lio/sentry/android/replay/ReplaySnapshotObserver; public fun isDebugMaskingOverlayEnabled ()Z public fun isRecording ()Z public final fun onConfigurationChanged (Lio/sentry/android/replay/ScreenshotRecorderConfig;)V @@ -78,6 +79,7 @@ public final class io/sentry/android/replay/ReplayIntegration : io/sentry/IConne public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V public fun resume ()V public fun setBreadcrumbConverter (Lio/sentry/ReplayBreadcrumbConverter;)V + public final fun setSnapshotObserver (Lio/sentry/android/replay/ReplaySnapshotObserver;)V public fun start ()V public fun stop ()V } 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 b44d05c2e6..8e4a4d28e7 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 @@ -123,7 +123,7 @@ public class ReplayIntegration( private val lifecycleLock = AutoClosableReentrantLock() private val lifecycle = ReplayLifecycle() - @Volatile internal var snapshotObserver: ReplaySnapshotObserver? = null + @Volatile public var snapshotObserver: ReplaySnapshotObserver? = null override fun register(scopes: IScopes, options: SentryOptions) { this.options = options From 172023056b04856da1402149731e2eeb21093abe Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Wed, 13 May 2026 10:23:32 +0200 Subject: [PATCH 10/12] fix(replay): Exclude ReplaySnapshotTest when integrations disabled (JAVA-504) Move ReplaySnapshotTest to a conditional androidTestReplay source set so it's only compiled when APPLY_SENTRY_INTEGRATIONS is true. The test imports replay classes that aren't on the classpath otherwise. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../sentry-uitest-android/build.gradle.kts | 4 ++++ .../java/io/sentry/uitest/android/ReplaySnapshotTest.kt | 0 2 files changed, 4 insertions(+) rename sentry-android-integration-tests/sentry-uitest-android/src/{androidTest => androidTestReplay}/java/io/sentry/uitest/android/ReplaySnapshotTest.kt (100%) diff --git a/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts b/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts index 5258a33f92..1d725b0b59 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts +++ b/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts @@ -83,6 +83,10 @@ android { val applySentryIntegrations = System.getenv("APPLY_SENTRY_INTEGRATIONS")?.toBoolean() ?: true +if (applySentryIntegrations) { + android.sourceSets["androidTest"].java.srcDirs("src/androidTestReplay/java") +} + dependencies { implementation( kotlin(Config.kotlinStdLib, org.jetbrains.kotlin.config.KotlinCompilerVersion.VERSION) diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplaySnapshotTest.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTestReplay/java/io/sentry/uitest/android/ReplaySnapshotTest.kt similarity index 100% rename from sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplaySnapshotTest.kt rename to sentry-android-integration-tests/sentry-uitest-android/src/androidTestReplay/java/io/sentry/uitest/android/ReplaySnapshotTest.kt From 4a829fe19875959c91caf42e606a3c9f30af2488 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Wed, 13 May 2026 13:24:36 +0200 Subject: [PATCH 11/12] fix(replay): Copy bitmap before passing to ReplaySnapshotObserver (JAVA-504) Consumers of the observer API receive a copy of the bitmap instead of the replay system's shared instance. This eliminates race conditions and crashes when consumers store or use the bitmap asynchronously. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../uitest/android/ReplaySnapshotTest.kt | 1 + .../sentry/android/replay/ReplayIntegration.kt | 12 ++++++++---- .../android/replay/SessionReplayOptions.kt | 5 ++--- .../android/replay/ReplayIntegrationTest.kt | 18 +++++++++++++++--- 4 files changed, 26 insertions(+), 10 deletions(-) diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTestReplay/java/io/sentry/uitest/android/ReplaySnapshotTest.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTestReplay/java/io/sentry/uitest/android/ReplaySnapshotTest.kt index e3473635bb..5e253c6959 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/androidTestReplay/java/io/sentry/uitest/android/ReplaySnapshotTest.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTestReplay/java/io/sentry/uitest/android/ReplaySnapshotTest.kt @@ -52,6 +52,7 @@ class ReplaySnapshotTest : BaseUiTest() { val file = File(snapshotsDir, "${name}_$frameTimestamp.png") file.outputStream().use { out -> bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) } } + bitmap.recycle() frameReceived.countDown() } 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 8e4a4d28e7..29505f65de 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 @@ -313,10 +313,14 @@ public class ReplayIntegration( captureStrategy?.onScreenshotRecorded(bitmap) { frameTimeStamp -> val observer = snapshotObserver if (observer != null) { - try { - observer.onSnapshotCaptured(bitmap, frameTimeStamp, screen) - } catch (e: Throwable) { - options.logger.log(ERROR, "Error in ReplaySnapshotObserver", e) + val copy = bitmap.copy(bitmap.config!!, false) + if (copy != null) { + try { + observer.onSnapshotCaptured(copy, frameTimeStamp, screen) + } catch (e: Throwable) { + options.logger.log(ERROR, "Error in ReplaySnapshotObserver", e) + copy.recycle() + } } } addFrame(bitmap, frameTimeStamp, screen) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/SessionReplayOptions.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/SessionReplayOptions.kt index c8199590eb..017261d808 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/SessionReplayOptions.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/SessionReplayOptions.kt @@ -36,9 +36,8 @@ public var SentryReplayOptions.maskAllImages: Boolean * Observer that is notified when a replay snapshot is captured. The snapshot bitmap has masking * already applied. * - * **Bitmap lifecycle:** The bitmap is owned by the replay system and may be reused. Do not store a - * reference to it or access it after this method returns — copy the pixel data (e.g., compress to a - * file) within this method if you need it later. Do not recycle the bitmap. + * **Bitmap lifecycle:** The bitmap is a copy owned by the caller. You may store it or use it on + * another thread. Call [Bitmap.recycle] when you no longer need it to free native memory promptly. * * The callback runs on a background thread (the replay executor). */ diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt index c6ea1bf42a..f7eb4caad9 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt @@ -63,6 +63,7 @@ import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argThat import org.mockito.kotlin.check import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -1000,12 +1001,18 @@ class ReplayIntegrationTest { receivedBitmap = bitmap } - replay.onScreenshotRecorded(mock()) + val copyBitmap = mock() + val sourceBitmap = + mock { + on { config } doReturn ARGB_8888 + on { copy(any(), any()) } doReturn copyBitmap + } + replay.onScreenshotRecorded(sourceBitmap) assertTrue(callbackInvoked) assertEquals(1720693523997, receivedTimestamp) assertEquals("MainActivity", receivedScreen) - assertTrue(receivedBitmap is Bitmap) + assertEquals(copyBitmap, receivedBitmap) } @Test @@ -1028,7 +1035,12 @@ class ReplayIntegrationTest { replay.snapshotObserver = ReplaySnapshotObserver { _, _, _ -> throw RuntimeException("test") } - replay.onScreenshotRecorded(mock()) + val sourceBitmap = + mock { + on { config } doReturn ARGB_8888 + on { copy(any(), any()) } doReturn mock() + } + replay.onScreenshotRecorded(sourceBitmap) verify(fixture.replayCache).addFrame(any(), any(), anyOrNull()) } From 23af62eb0d674dacdf15f9cfb980c9cff654def3 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Wed, 13 May 2026 16:54:25 +0200 Subject: [PATCH 12/12] refactor(replay): Move ReplaySnapshotObserver to SentryReplayOptions with Hint API (JAVA-504) Move ReplaySnapshotObserver from the replay module to SentryReplayOptions in the core module and change the callback signature to use Hint instead of Bitmap. The bitmap is now accessible via TypeCheckHint.REPLAY_FRAME_BITMAP. This allows configuring the observer during Sentry.init{} alongside other replay options, removing the need to cast replayController to ReplayIntegration. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../uitest/android/ReplaySnapshotTest.kt | 31 +++++++------ .../api/sentry-android-replay.api | 6 --- .../android/replay/ReplayIntegration.kt | 10 +++-- .../android/replay/SessionReplayOptions.kt | 16 ------- .../android/replay/ReplayIntegrationTest.kt | 18 +++++--- sentry/api/sentry.api | 7 +++ .../java/io/sentry/SentryReplayOptions.java | 45 +++++++++++++++++++ .../main/java/io/sentry/TypeCheckHint.java | 3 ++ 8 files changed, 89 insertions(+), 47 deletions(-) diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTestReplay/java/io/sentry/uitest/android/ReplaySnapshotTest.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTestReplay/java/io/sentry/uitest/android/ReplaySnapshotTest.kt index 5e253c6959..ea368eb0cb 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/androidTestReplay/java/io/sentry/uitest/android/ReplaySnapshotTest.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTestReplay/java/io/sentry/uitest/android/ReplaySnapshotTest.kt @@ -4,9 +4,8 @@ import android.graphics.Bitmap import android.os.Environment import androidx.lifecycle.Lifecycle import androidx.test.core.app.launchActivity -import io.sentry.Sentry -import io.sentry.android.replay.ReplayIntegration -import io.sentry.android.replay.ReplaySnapshotObserver +import io.sentry.SentryReplayOptions +import io.sentry.TypeCheckHint import java.io.File import java.util.concurrent.CopyOnWriteArraySet import java.util.concurrent.CountDownLatch @@ -43,17 +42,21 @@ class ReplaySnapshotTest : BaseUiTest() { val activityScenario = launchActivity() activityScenario.moveToState(Lifecycle.State.RESUMED) - initSentry { it.sessionReplay.sessionSampleRate = 1.0 } - - val integration = Sentry.getCurrentScopes().options.replayController as? ReplayIntegration - integration?.snapshotObserver = ReplaySnapshotObserver { bitmap, frameTimestamp, screenName -> - val name = screenName ?: "unknown" - if (capturedScreens.add(name)) { - val file = File(snapshotsDir, "${name}_$frameTimestamp.png") - file.outputStream().use { out -> bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) } - } - bitmap.recycle() - frameReceived.countDown() + initSentry { + it.sessionReplay.sessionSampleRate = 1.0 + it.sessionReplay.snapshotObserver = + SentryReplayOptions.ReplaySnapshotObserver { hint, frameTimestamp, screenName -> + val bitmap = + hint.getAs(TypeCheckHint.REPLAY_FRAME_BITMAP, Bitmap::class.java) + ?: return@ReplaySnapshotObserver + val name = screenName ?: "unknown" + if (capturedScreens.add(name)) { + val file = File(snapshotsDir, "${name}_$frameTimestamp.png") + file.outputStream().use { out -> bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) } + } + bitmap.recycle() + frameReceived.countDown() + } } assertTrue(frameReceived.await(10, TimeUnit.SECONDS), "Expected at least one replay frame") diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index 52a0547c9f..aeabe9c05c 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -65,7 +65,6 @@ public final class io/sentry/android/replay/ReplayIntegration : io/sentry/IConne public fun getBreadcrumbConverter ()Lio/sentry/ReplayBreadcrumbConverter; public final fun getReplayCacheDir ()Ljava/io/File; public fun getReplayId ()Lio/sentry/protocol/SentryId; - public final fun getSnapshotObserver ()Lio/sentry/android/replay/ReplaySnapshotObserver; public fun isDebugMaskingOverlayEnabled ()Z public fun isRecording ()Z public final fun onConfigurationChanged (Lio/sentry/android/replay/ScreenshotRecorderConfig;)V @@ -79,15 +78,10 @@ public final class io/sentry/android/replay/ReplayIntegration : io/sentry/IConne public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V public fun resume ()V public fun setBreadcrumbConverter (Lio/sentry/ReplayBreadcrumbConverter;)V - public final fun setSnapshotObserver (Lio/sentry/android/replay/ReplaySnapshotObserver;)V public fun start ()V public fun stop ()V } -public abstract interface class io/sentry/android/replay/ReplaySnapshotObserver { - public abstract fun onSnapshotCaptured (Landroid/graphics/Bitmap;JLjava/lang/String;)V -} - public abstract interface class io/sentry/android/replay/ScreenshotRecorderCallback { public abstract fun onScreenshotRecorded (Landroid/graphics/Bitmap;)V public abstract fun onScreenshotRecorded (Ljava/io/File;J)V 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 29505f65de..d41a76fb19 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 @@ -7,6 +7,7 @@ import android.view.MotionEvent import io.sentry.Breadcrumb import io.sentry.DataCategory.All import io.sentry.DataCategory.Replay +import io.sentry.Hint import io.sentry.IConnectionStatusProvider.ConnectionStatus import io.sentry.IConnectionStatusProvider.ConnectionStatus.DISCONNECTED import io.sentry.IConnectionStatusProvider.IConnectionStatusObserver @@ -20,6 +21,7 @@ import io.sentry.SentryLevel.DEBUG import io.sentry.SentryLevel.ERROR import io.sentry.SentryLevel.INFO import io.sentry.SentryOptions +import io.sentry.TypeCheckHint import io.sentry.android.replay.ReplayState.CLOSED import io.sentry.android.replay.ReplayState.PAUSED import io.sentry.android.replay.ReplayState.RESUMED @@ -123,8 +125,6 @@ public class ReplayIntegration( private val lifecycleLock = AutoClosableReentrantLock() private val lifecycle = ReplayLifecycle() - @Volatile public var snapshotObserver: ReplaySnapshotObserver? = null - override fun register(scopes: IScopes, options: SentryOptions) { this.options = options @@ -311,12 +311,14 @@ public class ReplayIntegration( var screen: String? = null scopes?.configureScope { screen = it.screen?.substringAfterLast('.') } captureStrategy?.onScreenshotRecorded(bitmap) { frameTimeStamp -> - val observer = snapshotObserver + val observer = options.sessionReplay.snapshotObserver if (observer != null) { val copy = bitmap.copy(bitmap.config!!, false) if (copy != null) { try { - observer.onSnapshotCaptured(copy, frameTimeStamp, screen) + val hint = Hint() + hint.set(TypeCheckHint.REPLAY_FRAME_BITMAP, copy) + observer.onSnapshotCaptured(hint, frameTimeStamp, screen) } catch (e: Throwable) { options.logger.log(ERROR, "Error in ReplaySnapshotObserver", e) copy.recycle() diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/SessionReplayOptions.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/SessionReplayOptions.kt index 017261d808..f4723f1a49 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/SessionReplayOptions.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/SessionReplayOptions.kt @@ -1,8 +1,6 @@ package io.sentry.android.replay -import android.graphics.Bitmap import io.sentry.SentryReplayOptions -import org.jetbrains.annotations.ApiStatus // since we don't have getters for maskAllText and maskAllimages, they won't be accessible as // properties in Kotlin, therefore we create these extensions where a getter is dummy, but a setter @@ -31,17 +29,3 @@ public var SentryReplayOptions.maskAllImages: Boolean @Deprecated("Getter is unsupported.", level = DeprecationLevel.ERROR) get() = error("Getter not supported") set(value) = setMaskAllImages(value) - -/** - * Observer that is notified when a replay snapshot is captured. The snapshot bitmap has masking - * already applied. - * - * **Bitmap lifecycle:** The bitmap is a copy owned by the caller. You may store it or use it on - * another thread. Call [Bitmap.recycle] when you no longer need it to free native memory promptly. - * - * The callback runs on a background thread (the replay executor). - */ -@ApiStatus.Experimental -public fun interface ReplaySnapshotObserver { - public fun onSnapshotCaptured(bitmap: Bitmap, frameTimestamp: Long, screenName: String?) -} diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt index f7eb4caad9..4ee4adc823 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt @@ -18,6 +18,8 @@ import io.sentry.SentryEvent import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryOptions import io.sentry.SentryReplayEvent.ReplayType +import io.sentry.SentryReplayOptions +import io.sentry.TypeCheckHint import io.sentry.android.replay.ReplayCache.Companion.ONGOING_SEGMENT import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_BIT_RATE import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_FRAME_RATE @@ -994,12 +996,13 @@ class ReplayIntegrationTest { replay.register(fixture.scopes, fixture.options) replay.start() - replay.snapshotObserver = ReplaySnapshotObserver { bitmap, frameTimestamp, screenName -> - callbackInvoked = true - receivedTimestamp = frameTimestamp - receivedScreen = screenName - receivedBitmap = bitmap - } + fixture.options.sessionReplay.snapshotObserver = + SentryReplayOptions.ReplaySnapshotObserver { hint, frameTimestamp, screenName -> + callbackInvoked = true + receivedTimestamp = frameTimestamp + receivedScreen = screenName + receivedBitmap = hint.getAs(TypeCheckHint.REPLAY_FRAME_BITMAP, Bitmap::class.java) + } val copyBitmap = mock() val sourceBitmap = @@ -1033,7 +1036,8 @@ class ReplayIntegrationTest { replay.register(fixture.scopes, fixture.options) replay.start() - replay.snapshotObserver = ReplaySnapshotObserver { _, _, _ -> throw RuntimeException("test") } + fixture.options.sessionReplay.snapshotObserver = + SentryReplayOptions.ReplaySnapshotObserver { _, _, _ -> throw RuntimeException("test") } val sourceBitmap = mock { diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index a433abbb37..42027d5381 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -4069,6 +4069,7 @@ public final class io/sentry/SentryReplayOptions : io/sentry/SentryMaskingOption public fun getSessionDuration ()J public fun getSessionSampleRate ()Ljava/lang/Double; public fun getSessionSegmentDuration ()J + public fun getSnapshotObserver ()Lio/sentry/SentryReplayOptions$ReplaySnapshotObserver; public fun isCaptureSurfaceViews ()Z public fun isDebug ()Z public fun isNetworkCaptureBodies ()Z @@ -4090,6 +4091,7 @@ public final class io/sentry/SentryReplayOptions : io/sentry/SentryMaskingOption public fun setScreenshotStrategy (Lio/sentry/ScreenshotStrategyType;)V public fun setSdkVersion (Lio/sentry/protocol/SdkVersion;)V public fun setSessionSampleRate (Ljava/lang/Double;)V + public fun setSnapshotObserver (Lio/sentry/SentryReplayOptions$ReplaySnapshotObserver;)V public fun setTrackConfiguration (Z)V public fun trackCustomMasking ()V } @@ -4098,6 +4100,10 @@ public abstract interface class io/sentry/SentryReplayOptions$BeforeErrorSamplin public abstract fun execute (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Z } +public abstract interface class io/sentry/SentryReplayOptions$ReplaySnapshotObserver { + public abstract fun onSnapshotCaptured (Lio/sentry/Hint;JLjava/lang/String;)V +} + public final class io/sentry/SentryReplayOptions$SentryReplayQuality : java/lang/Enum { public static final field HIGH Lio/sentry/SentryReplayOptions$SentryReplayQuality; public static final field LOW Lio/sentry/SentryReplayOptions$SentryReplayQuality; @@ -4644,6 +4650,7 @@ public final class io/sentry/TypeCheckHint { public static final field OKHTTP_RESPONSE Ljava/lang/String; public static final field OPEN_FEIGN_REQUEST Ljava/lang/String; public static final field OPEN_FEIGN_RESPONSE Ljava/lang/String; + public static final field REPLAY_FRAME_BITMAP Ljava/lang/String; public static final field SENTRY_DART_SDK_NAME Ljava/lang/String; public static final field SENTRY_DOTNET_SDK_NAME Ljava/lang/String; public static final field SENTRY_EVENT_DROP_REASON Ljava/lang/String; diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index 6eb4a58e1c..dfa5ed8e39 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -36,6 +36,28 @@ public interface BeforeErrorSamplingCallback { boolean execute(@NotNull SentryEvent event, @NotNull Hint hint); } + /** + * Observer that is notified when a replay snapshot is captured. The snapshot bitmap (with masking + * applied) is passed via a {@link Hint} using the key {@link TypeCheckHint#REPLAY_FRAME_BITMAP}. + * + *

On Android, retrieve the bitmap with: {@code hint.getAs(TypeCheckHint.REPLAY_FRAME_BITMAP, + * Bitmap.class)}. + * + *

The callback runs on a background thread (replay executor). The bitmap is a copy owned by + * the caller. Call {@code Bitmap.recycle()} when done to free native memory. + */ + @ApiStatus.Experimental + public interface ReplaySnapshotObserver { + /** + * Called when a replay snapshot is captured. + * + * @param hint contains the frame bitmap under {@link TypeCheckHint#REPLAY_FRAME_BITMAP} + * @param frameTimestamp the timestamp (in milliseconds since epoch) when the frame was captured + * @param screenName the current screen name, or {@code null} if unknown + */ + void onSnapshotCaptured(@NotNull Hint hint, long frameTimestamp, @Nullable String screenName); + } + private static final String CUSTOM_MASKING_INTEGRATION_NAME = "ReplayCustomMasking"; private volatile boolean customMaskingTracked = false; @@ -211,6 +233,8 @@ public enum SentryReplayQuality { */ private @Nullable BeforeErrorSamplingCallback beforeErrorSampling; + @ApiStatus.Experimental private @Nullable ReplaySnapshotObserver snapshotObserver; + public SentryReplayOptions(final boolean empty, final @Nullable SdkVersion sdkVersion) { if (!empty) { // Add default mask classes directly without setting usingCustomMasking flag @@ -550,4 +574,25 @@ public void setBeforeErrorSampling( final @Nullable BeforeErrorSamplingCallback beforeErrorSampling) { this.beforeErrorSampling = beforeErrorSampling; } + + /** + * Gets the observer that is notified when a replay snapshot is captured. + * + * @return the observer, or {@code null} if not set + */ + @ApiStatus.Experimental + public @Nullable ReplaySnapshotObserver getSnapshotObserver() { + return snapshotObserver; + } + + /** + * Sets the observer that is notified when a replay snapshot is captured. The frame bitmap is + * passed via a {@link Hint} using the key {@link TypeCheckHint#REPLAY_FRAME_BITMAP}. + * + * @param snapshotObserver the observer, or {@code null} to clear + */ + @ApiStatus.Experimental + public void setSnapshotObserver(final @Nullable ReplaySnapshotObserver snapshotObserver) { + this.snapshotObserver = snapshotObserver; + } } diff --git a/sentry/src/main/java/io/sentry/TypeCheckHint.java b/sentry/src/main/java/io/sentry/TypeCheckHint.java index 189050570b..e8b6eee405 100644 --- a/sentry/src/main/java/io/sentry/TypeCheckHint.java +++ b/sentry/src/main/java/io/sentry/TypeCheckHint.java @@ -140,4 +140,7 @@ public final class TypeCheckHint { /** Used for Ktor Request breadcrumbs. */ public static final String KTOR_CLIENT_REQUEST = "ktorClient:request"; + + /** Used for Session Replay frame bitmaps in the ReplaySnapshotObserver callback. */ + public static final String REPLAY_FRAME_BITMAP = "replay:frameBitmap"; }