diff --git a/.github/workflows/integration-tests-ui.yml b/.github/workflows/integration-tests-ui.yml index bbaaa88f53a..3e579ff14c9 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..6abb31615bf --- /dev/null +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplaySnapshotTest.kt @@ -0,0 +1,64 @@ +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.SentryReplayOptions +import io.sentry.TypeCheckHint +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.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() + } + ) + } + + 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) + } +} 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"; }