From d49b64a55b37f001994f7ba3fb9287120b8daece Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Fri, 8 May 2026 10:23:25 +0200 Subject: [PATCH 01/15] 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 aeabe9c05c1..a159a904481 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 d25827e3c7d..d473fd4fa4f 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 f4723f1a496..db44e256aad 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 7c86a0ad010..15715a0d6b3 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 a433abbb37c..ce187ef100e 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 6eb4a58e1c2..ae1057336cb 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 189050570b4..e435fedc1fa 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/15] 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 0549577f629..5206a173362 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 8d84f865c95..a00ee10614b 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 00000000000..8ffb606d93c --- /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/15] 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 8ffb606d93c..6abb31615bf 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/15] 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 bfb51947698..9b1bd328852 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 6abb31615bf..4e19a24077a 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/15] 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 ae1057336cb..4d443948475 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/15] 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 9b1bd328852..3d89a278dab 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 4e19a24077a..dc3f77d1148 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 a159a904481..7a288128f7b 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 d473fd4fa4f..b44d05c2e65 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 db44e256aad..5af81add8c7 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 15715a0d6b3..c6ea1bf42a5 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 ce187ef100e..a433abbb37c 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 4d443948475..6eb4a58e1c2 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 e435fedc1fa..189050570b4 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/15] 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 dc3f77d1148..e3473635bb4 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 5af81add8c7..0f45239baab 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/15] 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 60d38c0ae0a..b53c603e087 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 0f45239baab..c8199590eb0 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/15] 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 7a288128f7b..52a0547c9f3 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 b44d05c2e65..8e4a4d28e75 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/15] 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 5258a33f92a..1d725b0b595 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/15] 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 e3473635bb4..5e253c69599 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 8e4a4d28e75..29505f65de3 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 c8199590eb0..017261d808e 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 c6ea1bf42a5..f7eb4caad94 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/15] 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 5e253c69599..ea368eb0cb5 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 52a0547c9f3..aeabe9c05c1 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 29505f65de3..d41a76fb195 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 017261d808e..f4723f1a496 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 f7eb4caad94..4ee4adc8234 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 a433abbb37c..42027d5381c 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 6eb4a58e1c2..dfa5ed8e39f 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 189050570b4..e8b6eee405f 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"; } From a7a01d9f50d2ad5fa3d6b36666cd68dda04dafed Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 19 May 2026 15:44:51 +0200 Subject: [PATCH 13/15] fix(replay): Remove unnecessary jetbrains-annotations dependency (JAVA-504) Co-Authored-By: Claude Opus 4.6 (1M context) --- sentry-android-replay/build.gradle.kts | 3 --- 1 file changed, 3 deletions(-) diff --git a/sentry-android-replay/build.gradle.kts b/sentry-android-replay/build.gradle.kts index b53c603e087..60d38c0ae0a 100644 --- a/sentry-android-replay/build.gradle.kts +++ b/sentry-android-replay/build.gradle.kts @@ -61,8 +61,6 @@ android { buildFeatures { buildConfig = true } - configurations.all { resolutionStrategy.force(libs.jetbrains.annotations.get()) } - androidComponents.beforeVariants { it.enable = !Config.Android.shouldSkipDebugVariant(it.buildType) } @@ -73,7 +71,6 @@ kotlin { explicitApi() } dependencies { api(projects.sentry) - compileOnly(libs.jetbrains.annotations) compileOnly(libs.androidx.compose.ui.replay) implementation(kotlin(Config.kotlinStdLib, Config.kotlinStdLibVersionAndroid)) // tests From 8a44963c044b037c0ebc5b9d887620ddb5615bfb Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 19 May 2026 15:58:10 +0200 Subject: [PATCH 14/15] refactor(replay): Rename ReplaySnapshotObserver to ReplayFrameObserver (JAVA-504) Rename the interface to ReplayFrameObserver and the callback method to onMaskedFrameCaptured to clarify that frames have masking applied. Also update the changelog with a usage snippet. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 19 +++++++++++- .../uitest/android/ReplaySnapshotTest.kt | 6 ++-- .../android/replay/ReplayIntegration.kt | 6 ++-- .../android/replay/ReplayIntegrationTest.kt | 8 ++--- sentry/api/sentry.api | 8 ++--- .../java/io/sentry/SentryReplayOptions.java | 30 ++++++++++--------- .../main/java/io/sentry/TypeCheckHint.java | 2 +- 7 files changed, 49 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d89a278dab..91a9b631195 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,24 @@ ### 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 `ReplaySnapshotObserver` for observing captured replay frames ([#5386](https://github.com/getsentry/sentry-java/pull/5386)) +- Session Replay: Add `ReplayFrameObserver` for observing captured replay frames ([#5386](https://github.com/getsentry/sentry-java/pull/5386)) + + ```kotlin + SentryAndroid.init(context) { options -> + options.sessionReplay.frameObserver = + SentryReplayOptions.ReplayFrameObserver { hint, frameTimestamp, screenName -> + val bitmap = hint.getAs(TypeCheckHint.REPLAY_FRAME_BITMAP, Bitmap::class.java) + if (bitmap != null) { + try { + // Process the masked replay frame + myAnalyzer.processFrame(bitmap, frameTimestamp, screenName) + } finally { + bitmap.recycle() + } + } + } + } + ``` ### Dependencies 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 ea368eb0cb5..1d82a3f8bc0 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 @@ -44,11 +44,11 @@ class ReplaySnapshotTest : BaseUiTest() { initSentry { it.sessionReplay.sessionSampleRate = 1.0 - it.sessionReplay.snapshotObserver = - SentryReplayOptions.ReplaySnapshotObserver { hint, frameTimestamp, screenName -> + it.sessionReplay.frameObserver = + SentryReplayOptions.ReplayFrameObserver { hint, frameTimestamp, screenName -> val bitmap = hint.getAs(TypeCheckHint.REPLAY_FRAME_BITMAP, Bitmap::class.java) - ?: return@ReplaySnapshotObserver + ?: return@ReplayFrameObserver val name = screenName ?: "unknown" if (capturedScreens.add(name)) { val file = File(snapshotsDir, "${name}_$frameTimestamp.png") 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 d41a76fb195..e487f95942e 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 @@ -311,16 +311,16 @@ public class ReplayIntegration( var screen: String? = null scopes?.configureScope { screen = it.screen?.substringAfterLast('.') } captureStrategy?.onScreenshotRecorded(bitmap) { frameTimeStamp -> - val observer = options.sessionReplay.snapshotObserver + val observer = options.sessionReplay.frameObserver if (observer != null) { val copy = bitmap.copy(bitmap.config!!, false) if (copy != null) { try { val hint = Hint() hint.set(TypeCheckHint.REPLAY_FRAME_BITMAP, copy) - observer.onSnapshotCaptured(hint, frameTimeStamp, screen) + observer.onMaskedFrameCaptured(hint, frameTimeStamp, screen) } catch (e: Throwable) { - options.logger.log(ERROR, "Error in ReplaySnapshotObserver", e) + options.logger.log(ERROR, "Error in ReplayFrameObserver", e) copy.recycle() } } 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 4ee4adc8234..4183fad10ed 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 @@ -996,8 +996,8 @@ class ReplayIntegrationTest { replay.register(fixture.scopes, fixture.options) replay.start() - fixture.options.sessionReplay.snapshotObserver = - SentryReplayOptions.ReplaySnapshotObserver { hint, frameTimestamp, screenName -> + fixture.options.sessionReplay.frameObserver = + SentryReplayOptions.ReplayFrameObserver { hint, frameTimestamp, screenName -> callbackInvoked = true receivedTimestamp = frameTimestamp receivedScreen = screenName @@ -1036,8 +1036,8 @@ class ReplayIntegrationTest { replay.register(fixture.scopes, fixture.options) replay.start() - fixture.options.sessionReplay.snapshotObserver = - SentryReplayOptions.ReplaySnapshotObserver { _, _, _ -> throw RuntimeException("test") } + fixture.options.sessionReplay.frameObserver = + SentryReplayOptions.ReplayFrameObserver { _, _, _ -> throw RuntimeException("test") } val sourceBitmap = mock { diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 42027d5381c..5cca2a19bcc 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -4056,6 +4056,7 @@ public final class io/sentry/SentryReplayOptions : io/sentry/SentryMaskingOption public fun addUnmaskViewClass (Ljava/lang/String;)V public fun getBeforeErrorSampling ()Lio/sentry/SentryReplayOptions$BeforeErrorSamplingCallback; public fun getErrorReplayDuration ()J + public fun getFrameObserver ()Lio/sentry/SentryReplayOptions$ReplayFrameObserver; public fun getFrameRate ()I public fun getNetworkDetailAllowUrls ()Ljava/util/List; public fun getNetworkDetailDenyUrls ()Ljava/util/List; @@ -4069,7 +4070,6 @@ 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 @@ -4079,6 +4079,7 @@ public final class io/sentry/SentryReplayOptions : io/sentry/SentryMaskingOption public fun setBeforeErrorSampling (Lio/sentry/SentryReplayOptions$BeforeErrorSamplingCallback;)V public fun setCaptureSurfaceViews (Z)V public fun setDebug (Z)V + public fun setFrameObserver (Lio/sentry/SentryReplayOptions$ReplayFrameObserver;)V public fun setMaskAllImages (Z)V public fun setMaskAllText (Z)V public fun setNetworkCaptureBodies (Z)V @@ -4091,7 +4092,6 @@ 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 } @@ -4100,8 +4100,8 @@ 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 abstract interface class io/sentry/SentryReplayOptions$ReplayFrameObserver { + public abstract fun onMaskedFrameCaptured (Lio/sentry/Hint;JLjava/lang/String;)V } public final class io/sentry/SentryReplayOptions$SentryReplayQuality : java/lang/Enum { diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index dfa5ed8e39f..e330c451a01 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -37,8 +37,9 @@ public interface BeforeErrorSamplingCallback { } /** - * 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}. + * Observer that is notified when a masked replay frame is captured. The frame bitmap (with + * masking already 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)}. @@ -47,15 +48,15 @@ public interface BeforeErrorSamplingCallback { * the caller. Call {@code Bitmap.recycle()} when done to free native memory. */ @ApiStatus.Experimental - public interface ReplaySnapshotObserver { + public interface ReplayFrameObserver { /** - * Called when a replay snapshot is captured. + * Called when a masked replay frame 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); + void onMaskedFrameCaptured(@NotNull Hint hint, long frameTimestamp, @Nullable String screenName); } private static final String CUSTOM_MASKING_INTEGRATION_NAME = "ReplayCustomMasking"; @@ -233,7 +234,7 @@ public enum SentryReplayQuality { */ private @Nullable BeforeErrorSamplingCallback beforeErrorSampling; - @ApiStatus.Experimental private @Nullable ReplaySnapshotObserver snapshotObserver; + @ApiStatus.Experimental private @Nullable ReplayFrameObserver frameObserver; public SentryReplayOptions(final boolean empty, final @Nullable SdkVersion sdkVersion) { if (!empty) { @@ -576,23 +577,24 @@ public void setBeforeErrorSampling( } /** - * Gets the observer that is notified when a replay snapshot is captured. + * Gets the observer that is notified when a masked replay frame is captured. * * @return the observer, or {@code null} if not set */ @ApiStatus.Experimental - public @Nullable ReplaySnapshotObserver getSnapshotObserver() { - return snapshotObserver; + public @Nullable ReplayFrameObserver getFrameObserver() { + return frameObserver; } /** - * 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}. + * Sets the observer that is notified when a masked replay frame is captured. The frame bitmap + * (with masking already applied) is passed via a {@link Hint} using the key {@link + * TypeCheckHint#REPLAY_FRAME_BITMAP}. * - * @param snapshotObserver the observer, or {@code null} to clear + * @param frameObserver the observer, or {@code null} to clear */ @ApiStatus.Experimental - public void setSnapshotObserver(final @Nullable ReplaySnapshotObserver snapshotObserver) { - this.snapshotObserver = snapshotObserver; + public void setFrameObserver(final @Nullable ReplayFrameObserver frameObserver) { + this.frameObserver = frameObserver; } } diff --git a/sentry/src/main/java/io/sentry/TypeCheckHint.java b/sentry/src/main/java/io/sentry/TypeCheckHint.java index e8b6eee405f..b3b061e847c 100644 --- a/sentry/src/main/java/io/sentry/TypeCheckHint.java +++ b/sentry/src/main/java/io/sentry/TypeCheckHint.java @@ -141,6 +141,6 @@ 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. */ + /** Used for Session Replay frame bitmaps in the ReplayFrameObserver callback. */ public static final String REPLAY_FRAME_BITMAP = "replay:frameBitmap"; } From 6481cdf5e943d2e941fd310a14fea0738bc17ce0 Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Tue, 19 May 2026 14:01:51 +0000 Subject: [PATCH 15/15] Format code --- sentry/src/main/java/io/sentry/SentryReplayOptions.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index e330c451a01..d1da6510cdb 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -56,7 +56,8 @@ public interface ReplayFrameObserver { * @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 onMaskedFrameCaptured(@NotNull Hint hint, long frameTimestamp, @Nullable String screenName); + void onMaskedFrameCaptured( + @NotNull Hint hint, long frameTimestamp, @Nullable String screenName); } private static final String CUSTOM_MASKING_INTEGRATION_NAME = "ReplayCustomMasking";