From fd190c870850177249721f91bf15902399678336 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 19 Aug 2026 13:18:38 +0200 Subject: [PATCH 01/15] feat(replay): Add manual replay control API Expose start, buffering, pause, resume, stop, and flush operations through Sentry.replay(). Keep lifecycle pauses distinct from explicit user pauses. Foregrounding therefore does not resume sensitive-screen recording unexpectedly. Refs JAVA-325 Co-Authored-By: OpenAI Codex --- .../sentry/android/core/LifecycleWatcher.java | 11 +- .../io/sentry/android/core/SentryAndroid.java | 2 +- .../android/core/LifecycleWatcherTest.kt | 45 ++---- .../api/sentry-android-replay.api | 4 + .../android/replay/ReplayIntegration.kt | 59 +++++--- .../android/replay/ReplayIntegrationTest.kt | 130 +++++++++++++++++- .../sentry/android/replay/ReplaySmokeTest.kt | 2 +- sentry/api/sentry.api | 16 ++- .../src/main/java/io/sentry/IReplayApi.java | 32 +++++ .../java/io/sentry/NoOpReplayController.java | 12 ++ .../main/java/io/sentry/ReplayController.java | 16 ++- sentry/src/test/java/io/sentry/SentryTest.kt | 19 ++- 12 files changed, 267 insertions(+), 81 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java b/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java index de1c40c570c..107017ffd61 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java @@ -76,14 +76,15 @@ private void startSession() { }); final long lastUpdatedSession = this.lastUpdatedSession.get(); - if (lastUpdatedSession == 0L - || (lastUpdatedSession + sessionIntervalMillis) <= currentTimeMillis) { + final boolean startNewSession = + lastUpdatedSession == 0L + || (lastUpdatedSession + sessionIntervalMillis) <= currentTimeMillis; + if (startNewSession) { if (enableSessionTracking) { scopes.startSession(); } - scopes.getOptions().getReplayController().start(); } - scopes.getOptions().getReplayController().resume(); + scopes.getOptions().getReplayController().onAppForegrounded(startNewSession); this.lastUpdatedSession.set(currentTimeMillis); } @@ -94,7 +95,7 @@ public void onBackground() { final long currentTimeMillis = currentDateProvider.getCurrentTimeMillis(); this.lastUpdatedSession.set(currentTimeMillis); - scopes.getOptions().getReplayController().pause(); + scopes.getOptions().getReplayController().onAppBackgrounded(); scheduleEndSession(); addAppBreadcrumb("background"); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java index ab18a5827b9..82916a248e6 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java @@ -203,7 +203,7 @@ public static void init( scopes.startSession(); } } - scopes.getOptions().getReplayController().start(); + scopes.getOptions().getReplayController().onAppForegrounded(true); } } catch (IllegalAccessException e) { logger.log(SentryLevel.FATAL, "Fatal error during SentryAndroid.init(...)", e); diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt index ce518eabb05..3ceeeef8c38 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt @@ -77,7 +77,7 @@ class LifecycleWatcherTest { val watcher = fixture.getSUT(enableAppLifecycleBreadcrumbs = false) watcher.onForeground() verify(fixture.scopes).startSession() - verify(fixture.replayController).start() + verify(fixture.replayController).onAppForegrounded(true) } @Test @@ -87,7 +87,7 @@ class LifecycleWatcherTest { watcher.onForeground() watcher.onForeground() verify(fixture.scopes, times(2)).startSession() - verify(fixture.replayController, times(2)).start() + verify(fixture.replayController, times(2)).onAppForegrounded(true) } @Test @@ -97,7 +97,8 @@ class LifecycleWatcherTest { watcher.onForeground() watcher.onForeground() verify(fixture.scopes).startSession() - verify(fixture.replayController).start() + verify(fixture.replayController).onAppForegrounded(true) + verify(fixture.replayController).onAppForegrounded(false) } @Test @@ -214,7 +215,7 @@ class LifecycleWatcherTest { watcher.onForeground() verify(fixture.scopes, never()).startSession() - verify(fixture.replayController, never()).start() + verify(fixture.replayController).onAppForegrounded(false) } @Test @@ -243,35 +244,7 @@ class LifecycleWatcherTest { watcher.onForeground() verify(fixture.scopes).startSession() - verify(fixture.replayController).start() - } - - @Test - fun `if the hub has already a fresh session running, resumes replay to invalidate isManualPause flag`() { - val watcher = - fixture.getSUT( - enableAppLifecycleBreadcrumbs = false, - session = - Session( - State.Ok, - DateUtils.getCurrentDateTime(), - DateUtils.getCurrentDateTime(), - 0, - "abc", - "3c1ffc32-f68f-4af2-a1ee-dd72f4d62d17", - true, - 0, - 10.0, - null, - null, - null, - "release", - null, - ), - ) - - watcher.onForeground() - verify(fixture.replayController).resume() + verify(fixture.replayController).onAppForegrounded(true) } @Test @@ -280,13 +253,13 @@ class LifecycleWatcherTest { val watcher = fixture.getSUT(sessionIntervalMillis = 500L, enableAppLifecycleBreadcrumbs = false) watcher.onForeground() - verify(fixture.replayController).start() + verify(fixture.replayController).onAppForegrounded(true) watcher.onBackground() - verify(fixture.replayController).pause() + verify(fixture.replayController).onAppBackgrounded() watcher.onForeground() - verify(fixture.replayController, times(2)).resume() + verify(fixture.replayController).onAppForegrounded(false) watcher.onBackground() verify(fixture.replayController, timeout(10000)).stop() diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index 0e4ce0461b0..b16b83278ff 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -62,11 +62,14 @@ public final class io/sentry/android/replay/ReplayIntegration : io/sentry/IConne public fun close ()V public fun disableDebugMaskingOverlay ()V public fun enableDebugMaskingOverlay ()V + public fun flush ()V public fun getBreadcrumbConverter ()Lio/sentry/ReplayBreadcrumbConverter; public final fun getReplayCacheDir ()Ljava/io/File; public fun getReplayId ()Lio/sentry/protocol/SentryId; public fun isDebugMaskingOverlayEnabled ()Z public fun isRecording ()Z + public fun onAppBackgrounded ()V + public fun onAppForegrounded (Z)V public final fun onConfigurationChanged (Lio/sentry/android/replay/ScreenshotRecorderConfig;)V public fun onConnectionStatusChanged (Lio/sentry/IConnectionStatusProvider$ConnectionStatus;)V public fun onRateLimitChanged (Lio/sentry/transport/RateLimiter;)V @@ -81,6 +84,7 @@ public final class io/sentry/android/replay/ReplayIntegration : io/sentry/IConne public fun resume ()V public fun setBreadcrumbConverter (Lio/sentry/ReplayBreadcrumbConverter;)V public fun start ()V + public fun startBuffering ()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 d13565624bb..6416a8b3ed6 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 @@ -141,14 +141,6 @@ public class ReplayIntegration( return } - if ( - !options.sessionReplay.isSessionReplayEnabled && - !options.sessionReplay.isSessionReplayForErrorsEnabled - ) { - options.logger.log(INFO, "Session replay is disabled, no sample rate specified") - return - } - this.scopes = scopes recorder = recorderProvider?.invoke() @@ -167,10 +159,35 @@ public class ReplayIntegration( override fun isRecording(): Boolean = state.get().isRecording override fun start() { - enqueueOnMainThread { startInternal() } + enqueueOnMainThread { startInternal(isFullSession = true) } + } + + override fun startBuffering() { + enqueueOnMainThread { startInternal(isFullSession = false) } + } + + override fun onAppForegrounded(startNewSession: Boolean) { + enqueueOnMainThread { + if (startNewSession) { + val isFullSession = sample(options.sessionReplay.sessionSampleRate) + if (!isFullSession && !options.sessionReplay.isSessionReplayForErrorsEnabled) { + options.logger.log( + INFO, + "Session replay is not started, full session was not sampled and onErrorSampleRate is not specified", + ) + } else { + startInternal(isFullSession) + } + } + resumeInternal() + } } - private fun startInternal() { + override fun onAppBackgrounded() { + enqueueOnMainThread { pauseInternal() } + } + + private fun startInternal(isFullSession: Boolean) { if (!isEnabled.get()) { return } @@ -184,15 +201,7 @@ public class ReplayIntegration( return } - val isFullSession = sample(options.sessionReplay.sessionSampleRate) - if (!isFullSession && !options.sessionReplay.isSessionReplayForErrorsEnabled) { - options.logger.log( - INFO, - "Session replay is not started, full session was not sampled and onErrorSampleRate is not specified", - ) - return - } - + isManualPause = false val strategy = replayCaptureStrategyProvider?.invoke(isFullSession) ?: if (isFullSession) { @@ -339,6 +348,17 @@ public class ReplayIntegration( override fun getReplayId(): SentryId = state.get().replayId + override fun flush() { + enqueueOnMainThread { + val current = state.get() + if (!current.isRecording) { + startInternal(isFullSession = true) + } else { + captureReplayInternal(current.generation, current.replayId, false) + } + } + } + override fun setBreadcrumbConverter(converter: ReplayBreadcrumbConverter) { replayBreadcrumbConverter = converter } @@ -404,6 +424,7 @@ public class ReplayIntegration( recorder?.stop() gestureRecorder?.stop() current.captureStrategy?.stop() + isManualPause = false state.set( current.copy( lifecycleState = STOPPED, 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 8f2994fc649..9459ed0e18a 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 @@ -188,12 +188,12 @@ class ReplayIntegrationTest { } @Test - fun `when no sample rate is set, does not register`() { + fun `when no sample rate is set, still registers`() { val replay = fixture.getSut(context, 0.0, 0.0) replay.register(fixture.scopes, fixture.options) - assertFalse(replay.isEnabled.get()) + assertTrue(replay.isEnabled.get()) } @Test @@ -269,7 +269,7 @@ class ReplayIntegrationTest { } @Test - fun `does not start replay when session is not sampled`() { + fun `automatic start does not start replay when session is not sampled`() { val captureStrategy = mock() val replay = fixture.getSut( @@ -280,14 +280,14 @@ class ReplayIntegrationTest { ) replay.register(fixture.scopes, fixture.options) - replay.start() + replay.onAppForegrounded(true) verify(captureStrategy, never()) .start(eq(0), argThat { this != SentryId.EMPTY_ID }, anyOrNull()) } @Test - fun `still starts replay when errorsSampleRate is set`() { + fun `automatic start still starts replay when errorsSampleRate is set`() { val captureStrategy = mock() val replay = fixture.getSut( @@ -297,12 +297,56 @@ class ReplayIntegrationTest { ) replay.register(fixture.scopes, fixture.options) - replay.start() + replay.onAppForegrounded(true) verify(captureStrategy, times(1)) .start(eq(0), argThat { this != SentryId.EMPTY_ID }, anyOrNull()) } + @Test + fun `manual start forces session mode without sample rates`() { + val captureStrategy = mock() + var isFullSession: Boolean? = null + val replay = + fixture.getSut( + context, + sessionSampleRate = 0.0, + onErrorSampleRate = 0.0, + replayCaptureStrategyProvider = { + isFullSession = it + captureStrategy + }, + ) + + replay.register(fixture.scopes, fixture.options) + replay.start() + + assertThat(replay.isRecording).isTrue() + assertThat(isFullSession).isTrue() + } + + @Test + fun `manual startBuffering forces buffer mode without sample rates`() { + val captureStrategy = mock() + var isFullSession: Boolean? = null + val replay = + fixture.getSut( + context, + sessionSampleRate = 0.0, + onErrorSampleRate = 0.0, + replayCaptureStrategyProvider = { + isFullSession = it + captureStrategy + }, + ) + + replay.register(fixture.scopes, fixture.options) + replay.startBuffering() + + assertThat(replay.isRecording).isTrue() + assertThat(isFullSession).isFalse() + } + @Test fun `calls recorder start`() { val recorder = mock() @@ -345,6 +389,36 @@ class ReplayIntegrationTest { verify(recorder).resume() } + @Test + fun `manual pause is not cleared when app returns to foreground`() { + val captureStrategy = mock() + val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) + + replay.register(fixture.scopes, fixture.options) + replay.start() + replay.pause() + replay.start() + replay.onAppForegrounded(false) + + verify(captureStrategy, never()).resume() + + replay.resume() + verify(captureStrategy).resume() + } + + @Test + fun `app foreground resumes an automatic background pause`() { + val captureStrategy = mock() + val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) + + replay.register(fixture.scopes, fixture.options) + replay.start() + replay.onAppBackgrounded() + replay.onAppForegrounded(false) + + verify(captureStrategy).resume() + } + @Test fun `captureReplay does nothing when not recording`() { val captureStrategy = mock() @@ -393,6 +467,50 @@ class ReplayIntegrationTest { verify(captureStrategy).convert() } + @Test + fun `flush captures a manual buffer without error sampling`() { + val replayId = SentryId() + val captureStrategy = mock() + whenever(captureStrategy.currentReplayId).thenReturn(replayId) + whenever(captureStrategy.convert()).thenReturn(captureStrategy) + val replay = + fixture.getSut( + context, + sessionSampleRate = 0.0, + onErrorSampleRate = 0.0, + replayCaptureStrategyProvider = { captureStrategy }, + ) + + replay.register(fixture.scopes, fixture.options) + replay.startBuffering() + replay.flush() + + verify(captureStrategy).captureReplay(eq(false), any()) + verify(captureStrategy).convert() + } + + @Test + fun `flush starts a session when replay is stopped`() { + val captureStrategy = mock() + var isFullSession: Boolean? = null + val replay = + fixture.getSut( + context, + sessionSampleRate = 0.0, + onErrorSampleRate = 0.0, + replayCaptureStrategyProvider = { + isFullSession = it + captureStrategy + }, + ) + + replay.register(fixture.scopes, fixture.options) + replay.flush() + + assertThat(replay.isRecording).isTrue() + assertThat(isFullSession).isTrue() + } + @Test fun `captureReplay returns replay id and sets scope before queued capture`() { val replayId = SentryId() diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt index b84b1b53347..0a8076f20f6 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt @@ -184,7 +184,7 @@ class ReplaySmokeTest { val controller = buildActivity(ExampleActivity::class.java, null).setup() controller.create().start().resume() - replay.start() + replay.onAppForegrounded(true) // wait for windows to be registered in our listeners shadowOf(Looper.getMainLooper()).idle() diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index fa876b3312f..661a5e3af66 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -903,6 +903,12 @@ public abstract interface class io/sentry/IProfileConverter { public abstract interface class io/sentry/IReplayApi { public abstract fun disableDebugMaskingOverlay ()V public abstract fun enableDebugMaskingOverlay ()V + public abstract fun flush ()V + public abstract fun pause ()V + public abstract fun resume ()V + public abstract fun start ()V + public abstract fun startBuffering ()V + public abstract fun stop ()V } public abstract interface class io/sentry/IScope { @@ -1716,17 +1722,21 @@ public final class io/sentry/NoOpReplayController : io/sentry/ReplayController { public fun captureReplay (Ljava/lang/Boolean;)Lio/sentry/protocol/SentryId; public fun disableDebugMaskingOverlay ()V public fun enableDebugMaskingOverlay ()V + public fun flush ()V public fun getBreadcrumbConverter ()Lio/sentry/ReplayBreadcrumbConverter; public static fun getInstance ()Lio/sentry/NoOpReplayController; public fun getReplayId ()Lio/sentry/protocol/SentryId; public fun isDebugMaskingOverlayEnabled ()Z public fun isRecording ()Z + public fun onAppBackgrounded ()V + public fun onAppForegrounded (Z)V public fun pause ()V public fun registerSegmentName (Ljava/lang/String;)V public fun registerTraceId (Lio/sentry/protocol/SentryId;)V public fun resume ()V public fun setBreadcrumbConverter (Lio/sentry/ReplayBreadcrumbConverter;)V public fun start ()V + public fun startBuffering ()V public fun stop ()V } @@ -2372,13 +2382,11 @@ public abstract interface class io/sentry/ReplayController : io/sentry/IReplayAp public abstract fun getReplayId ()Lio/sentry/protocol/SentryId; public abstract fun isDebugMaskingOverlayEnabled ()Z public abstract fun isRecording ()Z - public abstract fun pause ()V + public abstract fun onAppBackgrounded ()V + public abstract fun onAppForegrounded (Z)V public abstract fun registerSegmentName (Ljava/lang/String;)V public abstract fun registerTraceId (Lio/sentry/protocol/SentryId;)V - public abstract fun resume ()V public abstract fun setBreadcrumbConverter (Lio/sentry/ReplayBreadcrumbConverter;)V - public abstract fun start ()V - public abstract fun stop ()V } public final class io/sentry/ReplayRecording : io/sentry/JsonSerializable, io/sentry/JsonUnknown { diff --git a/sentry/src/main/java/io/sentry/IReplayApi.java b/sentry/src/main/java/io/sentry/IReplayApi.java index f1dd003b525..d31a24b0b21 100644 --- a/sentry/src/main/java/io/sentry/IReplayApi.java +++ b/sentry/src/main/java/io/sentry/IReplayApi.java @@ -1,7 +1,39 @@ package io.sentry; +/** + * Controls Session Replay. Methods may be called from any thread and return before the requested + * operation completes. + */ public interface IReplayApi { + /** Starts a new replay session. Does nothing if a replay is already being recorded. */ + void start(); + + /** + * Starts replay buffering. The rolling buffer is sent when {@link #flush()} is called or an error + * is captured. After the buffer is sent, recording continues in session mode unless the process + * is terminating. + */ + void startBuffering(); + + /** Stops the current replay. A subsequent {@link #start()} begins a new replay session. */ + void stop(); + + /** + * Pauses the current replay until {@link #resume()} is called. This can be used to avoid + * recording sensitive screens, such as PIN entry. + */ + void pause(); + + /** Resumes a replay paused with {@link #pause()}. */ + void resume(); + + /** + * Flushes replay data. A buffering replay continues in session mode after the buffer is sent. If + * replay is not recording, starts a new replay session. + */ + void flush(); + /** * Draws a masking overlay on top of the screen to help visualize which parts of the screen are * masked by Session Replay. This is only useful for debugging purposes and should not be used in diff --git a/sentry/src/main/java/io/sentry/NoOpReplayController.java b/sentry/src/main/java/io/sentry/NoOpReplayController.java index 3f1e88b822b..8e010d197c2 100644 --- a/sentry/src/main/java/io/sentry/NoOpReplayController.java +++ b/sentry/src/main/java/io/sentry/NoOpReplayController.java @@ -17,6 +17,9 @@ private NoOpReplayController() {} @Override public void start() {} + @Override + public void startBuffering() {} + @Override public void stop() {} @@ -26,6 +29,15 @@ public void pause() {} @Override public void resume() {} + @Override + public void flush() {} + + @Override + public void onAppForegrounded(boolean startNewReplay) {} + + @Override + public void onAppBackgrounded() {} + @Override public boolean isRecording() { return false; diff --git a/sentry/src/main/java/io/sentry/ReplayController.java b/sentry/src/main/java/io/sentry/ReplayController.java index 630c0da3d50..811208bfa99 100644 --- a/sentry/src/main/java/io/sentry/ReplayController.java +++ b/sentry/src/main/java/io/sentry/ReplayController.java @@ -7,13 +7,17 @@ @ApiStatus.Internal public interface ReplayController extends IReplayApi { - void start(); - - void stop(); - - void pause(); + /** + * Handles app foregrounding. When a new app session begins, starts a sampled replay unless one is + * already recording. An existing replay is never restarted or replaced. + */ + void onAppForegrounded(boolean startNewSession); - void resume(); + /** + * Handles app backgrounding with a temporary lifecycle pause. Unlike {@link #pause()}, this pause + * is automatically resumed on foreground and does not override an explicit user pause. + */ + void onAppBackgrounded(); boolean isRecording(); diff --git a/sentry/src/test/java/io/sentry/SentryTest.kt b/sentry/src/test/java/io/sentry/SentryTest.kt index 98cda8e9d82..00f5f89db39 100644 --- a/sentry/src/test/java/io/sentry/SentryTest.kt +++ b/sentry/src/test/java/io/sentry/SentryTest.kt @@ -1505,16 +1505,29 @@ class SentryTest { } @Test - fun `replay debug masking is forwarded to replay controller`() { + fun `replay API is forwarded to replay controller`() { val replayController = mock() initForTest { it.dsn = dsn it.setReplayController(replayController) } - Sentry.replay().enableDebugMaskingOverlay() - verify(replayController).enableDebugMaskingOverlay() + Sentry.replay().start() + Sentry.replay().startBuffering() + Sentry.replay().pause() + Sentry.replay().resume() + Sentry.replay().flush() + Sentry.replay().stop() + + verify(replayController).start() + verify(replayController).startBuffering() + verify(replayController).pause() + verify(replayController).resume() + verify(replayController).flush() + verify(replayController).stop() + Sentry.replay().enableDebugMaskingOverlay() Sentry.replay().disableDebugMaskingOverlay() + verify(replayController).enableDebugMaskingOverlay() verify(replayController).disableDebugMaskingOverlay() } From 1fa4956df62ed93adf56725c0ca516e71ec23652 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 19 Aug 2026 13:38:07 +0200 Subject: [PATCH 02/15] changelog --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 878238076f3..d32c6598613 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ ## Unreleased +### Features + +- Add manual Session Replay controls through `Sentry.replay()` ([#5978](https://github.com/getsentry/sentry-java/pull/5978)) + - Explicit `start()` and `startBuffering()` calls bypass the configured replay sample rates; sampling still controls automatic startup. + - `start()` starts a full-session replay and does nothing if one is already recording. + - `startBuffering()` keeps a rolling buffer that is sent on `flush()` or an error, then continues in session mode. + - `stop()` ends the current replay; the next `start()` creates a new replay session. + - `pause()` suspends recording until `resume()` and remains paused across background and foreground transitions. + - `resume()` continues the same manually paused replay. + - `flush()` sends the current replay data, or starts a full-session replay when recording is stopped. + ### Fixes - Prevent duplicated breadcrumbs on tombstone-merged native crash events ([#5888](https://github.com/getsentry/sentry-java/pull/5888)) From 4ce579aae86216e2d4c3b96433fe8c1f5dd7ce95 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 19 Aug 2026 15:55:26 +0200 Subject: [PATCH 03/15] fix(replay): Ignore foreground before registration A foreground callback can run before ReplayIntegration registers and initializes its options. Ignore lifecycle callbacks until the integration is enabled to avoid crashing during SDK initialization. Refs JAVA-325 Co-Authored-By: Codex --- .../java/io/sentry/android/replay/ReplayIntegration.kt | 3 +++ .../io/sentry/android/replay/ReplayIntegrationTest.kt | 9 +++++++++ 2 files changed, 12 insertions(+) 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 6416a8b3ed6..851ffec476b 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 @@ -168,6 +168,9 @@ public class ReplayIntegration( override fun onAppForegrounded(startNewSession: Boolean) { enqueueOnMainThread { + if (!isEnabled.get()) { + return@enqueueOnMainThread + } if (startNewSession) { val isFullSession = sample(options.sessionReplay.sessionSampleRate) if (!isFullSession && !options.sessionReplay.isSessionReplayForErrorsEnabled) { 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 9459ed0e18a..743139e4d5b 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 @@ -225,6 +225,15 @@ class ReplayIntegrationTest { verify(captureStrategy, never()).start(any(), any(), anyOrNull()) } + @Test + fun `foreground before register does nothing`() { + val replay = fixture.getSut(context) + + replay.onAppForegrounded(true) + + assertThat(replay.isRecording).isFalse() + } + @Test fun `start sets isRecording to true`() { val captureStrategy = mock() From f9062f52ac5f1a219950fbef9ad0f129dbb545d1 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 19 Aug 2026 20:28:44 +0200 Subject: [PATCH 04/15] fix(replay): Bypass sampling for manual buffers Track whether a buffered replay was started automatically so only automatic buffers apply per-error sampling. Manually started buffers now capture on errors as documented. Refs JAVA-325 Co-Authored-By: Codex --- .../android/replay/ReplayIntegration.kt | 18 ++++++++----- .../android/replay/ReplayIntegrationTest.kt | 26 +++++++++++++++++-- 2 files changed, 36 insertions(+), 8 deletions(-) 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 851ffec476b..ad85a80adf3 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 @@ -159,11 +159,11 @@ public class ReplayIntegration( override fun isRecording(): Boolean = state.get().isRecording override fun start() { - enqueueOnMainThread { startInternal(isFullSession = true) } + enqueueOnMainThread { startInternal(isFullSession = true, shouldSampleOnError = false) } } override fun startBuffering() { - enqueueOnMainThread { startInternal(isFullSession = false) } + enqueueOnMainThread { startInternal(isFullSession = false, shouldSampleOnError = false) } } override fun onAppForegrounded(startNewSession: Boolean) { @@ -179,7 +179,7 @@ public class ReplayIntegration( "Session replay is not started, full session was not sampled and onErrorSampleRate is not specified", ) } else { - startInternal(isFullSession) + startInternal(isFullSession, shouldSampleOnError = !isFullSession) } } resumeInternal() @@ -190,7 +190,7 @@ public class ReplayIntegration( enqueueOnMainThread { pauseInternal() } } - private fun startInternal(isFullSession: Boolean) { + private fun startInternal(isFullSession: Boolean, shouldSampleOnError: Boolean) { if (!isEnabled.get()) { return } @@ -235,6 +235,7 @@ public class ReplayIntegration( lifecycleState = STARTED, replayId = replayId ?: SentryId.EMPTY_ID, captureStrategy = strategy, + shouldSampleOnError = shouldSampleOnError, ) ) @@ -279,7 +280,11 @@ public class ReplayIntegration( return SentryId.EMPTY_ID } - if (current.isBuffering && !sample(options.sessionReplay.onErrorSampleRate)) { + if ( + current.isBuffering && + current.shouldSampleOnError && + !sample(options.sessionReplay.onErrorSampleRate) + ) { options.logger.log( INFO, "Replay wasn't sampled by onErrorSampleRate, not capturing for event", @@ -355,7 +360,7 @@ public class ReplayIntegration( enqueueOnMainThread { val current = state.get() if (!current.isRecording) { - startInternal(isFullSession = true) + startInternal(isFullSession = true, shouldSampleOnError = false) } else { captureReplayInternal(current.generation, current.replayId, false) } @@ -729,6 +734,7 @@ public class ReplayIntegration( val lifecycleState: ReplayLifecycleState = ReplayLifecycleState.INITIAL, val replayId: SentryId = SentryId.EMPTY_ID, val captureStrategy: CaptureStrategy? = null, + val shouldSampleOnError: Boolean = false, ) { val isBuffering: Boolean get() = captureStrategy is BufferCaptureStrategy 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 743139e4d5b..01055fd27bc 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 @@ -576,16 +576,38 @@ class ReplayIntegrationTest { fixture.getSut( context, sessionSampleRate = 0.0, - onErrorSampleRate = 0.0, + onErrorSampleRate = 1.0, replayCaptureStrategyProvider = { captureStrategy }, ) replay.register(fixture.scopes, fixture.options) - replay.start() + replay.onAppForegrounded(true) + fixture.options.sessionReplay.onErrorSampleRate = 0.0 assertThat(replay.captureReplay(false)).isEqualTo(SentryId.EMPTY_ID) verify(captureStrategy, never()).captureReplay(any(), any()) } + @Test + fun `manual buffer capture bypasses error sampling`() { + val replayId = SentryId() + val captureStrategy = mock() + whenever(captureStrategy.currentReplayId).thenReturn(replayId) + whenever(captureStrategy.convert()).thenReturn(captureStrategy) + val replay = + fixture.getSut( + context, + sessionSampleRate = 0.0, + onErrorSampleRate = 0.0, + replayCaptureStrategyProvider = { captureStrategy }, + ) + replay.register(fixture.scopes, fixture.options) + replay.startBuffering() + + assertThat(replay.captureReplay(false)).isEqualTo(replayId) + verify(captureStrategy).captureReplay(eq(false), any()) + verify(captureStrategy).convert() + } + @Test fun `capture queued after stop cannot resurrect replay`() { val replayId = SentryId() From dc8092f21d6bd2faf4c2b6ef3d50eabf1c1f311f Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 20 Aug 2026 11:04:37 +0200 Subject: [PATCH 05/15] revert: fix(replay): Bypass sampling for manual buffers This reverts commit 80732082d22c023b5ee206da7ca1331d789bfae0. Reason: Match Sentry JavaScript by applying onErrorSampleRate to all buffered replay captures. Refs JAVA-325 Co-Authored-By: Codex --- .../android/replay/ReplayIntegration.kt | 18 +++++-------- .../android/replay/ReplayIntegrationTest.kt | 26 ++----------------- 2 files changed, 8 insertions(+), 36 deletions(-) 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 ad85a80adf3..851ffec476b 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 @@ -159,11 +159,11 @@ public class ReplayIntegration( override fun isRecording(): Boolean = state.get().isRecording override fun start() { - enqueueOnMainThread { startInternal(isFullSession = true, shouldSampleOnError = false) } + enqueueOnMainThread { startInternal(isFullSession = true) } } override fun startBuffering() { - enqueueOnMainThread { startInternal(isFullSession = false, shouldSampleOnError = false) } + enqueueOnMainThread { startInternal(isFullSession = false) } } override fun onAppForegrounded(startNewSession: Boolean) { @@ -179,7 +179,7 @@ public class ReplayIntegration( "Session replay is not started, full session was not sampled and onErrorSampleRate is not specified", ) } else { - startInternal(isFullSession, shouldSampleOnError = !isFullSession) + startInternal(isFullSession) } } resumeInternal() @@ -190,7 +190,7 @@ public class ReplayIntegration( enqueueOnMainThread { pauseInternal() } } - private fun startInternal(isFullSession: Boolean, shouldSampleOnError: Boolean) { + private fun startInternal(isFullSession: Boolean) { if (!isEnabled.get()) { return } @@ -235,7 +235,6 @@ public class ReplayIntegration( lifecycleState = STARTED, replayId = replayId ?: SentryId.EMPTY_ID, captureStrategy = strategy, - shouldSampleOnError = shouldSampleOnError, ) ) @@ -280,11 +279,7 @@ public class ReplayIntegration( return SentryId.EMPTY_ID } - if ( - current.isBuffering && - current.shouldSampleOnError && - !sample(options.sessionReplay.onErrorSampleRate) - ) { + if (current.isBuffering && !sample(options.sessionReplay.onErrorSampleRate)) { options.logger.log( INFO, "Replay wasn't sampled by onErrorSampleRate, not capturing for event", @@ -360,7 +355,7 @@ public class ReplayIntegration( enqueueOnMainThread { val current = state.get() if (!current.isRecording) { - startInternal(isFullSession = true, shouldSampleOnError = false) + startInternal(isFullSession = true) } else { captureReplayInternal(current.generation, current.replayId, false) } @@ -734,7 +729,6 @@ public class ReplayIntegration( val lifecycleState: ReplayLifecycleState = ReplayLifecycleState.INITIAL, val replayId: SentryId = SentryId.EMPTY_ID, val captureStrategy: CaptureStrategy? = null, - val shouldSampleOnError: Boolean = false, ) { val isBuffering: Boolean get() = captureStrategy is BufferCaptureStrategy 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 01055fd27bc..743139e4d5b 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 @@ -576,38 +576,16 @@ class ReplayIntegrationTest { fixture.getSut( context, sessionSampleRate = 0.0, - onErrorSampleRate = 1.0, + onErrorSampleRate = 0.0, replayCaptureStrategyProvider = { captureStrategy }, ) replay.register(fixture.scopes, fixture.options) - replay.onAppForegrounded(true) - fixture.options.sessionReplay.onErrorSampleRate = 0.0 + replay.start() assertThat(replay.captureReplay(false)).isEqualTo(SentryId.EMPTY_ID) verify(captureStrategy, never()).captureReplay(any(), any()) } - @Test - fun `manual buffer capture bypasses error sampling`() { - val replayId = SentryId() - val captureStrategy = mock() - whenever(captureStrategy.currentReplayId).thenReturn(replayId) - whenever(captureStrategy.convert()).thenReturn(captureStrategy) - val replay = - fixture.getSut( - context, - sessionSampleRate = 0.0, - onErrorSampleRate = 0.0, - replayCaptureStrategyProvider = { captureStrategy }, - ) - replay.register(fixture.scopes, fixture.options) - replay.startBuffering() - - assertThat(replay.captureReplay(false)).isEqualTo(replayId) - verify(captureStrategy).captureReplay(eq(false), any()) - verify(captureStrategy).convert() - } - @Test fun `capture queued after stop cannot resurrect replay`() { val replayId = SentryId() From 92fce6dd53b1529d65a77af66f1c2102b0b7adc9 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 24 Aug 2026 11:26:12 +0200 Subject: [PATCH 06/15] fix(replay): Ignore lifecycle callbacks before registration Drop foreground and background callbacks received before Replay is registered instead of leaving stale work on the main queue. Clarify the manual replay API documentation. Refs JAVA-325 Co-Authored-By: Codex --- .../io/sentry/android/replay/ReplayIntegration.kt | 9 ++++++--- .../sentry/android/replay/ReplayIntegrationTest.kt | 8 +++++--- sentry/src/main/java/io/sentry/IReplayApi.java | 13 ++++++++----- 3 files changed, 19 insertions(+), 11 deletions(-) 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 851ffec476b..92bb4335e70 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 @@ -167,10 +167,10 @@ public class ReplayIntegration( } override fun onAppForegrounded(startNewSession: Boolean) { + if (!isEnabled.get()) { + return + } enqueueOnMainThread { - if (!isEnabled.get()) { - return@enqueueOnMainThread - } if (startNewSession) { val isFullSession = sample(options.sessionReplay.sessionSampleRate) if (!isFullSession && !options.sessionReplay.isSessionReplayForErrorsEnabled) { @@ -187,6 +187,9 @@ public class ReplayIntegration( } override fun onAppBackgrounded() { + if (!isEnabled.get()) { + return + } enqueueOnMainThread { pauseInternal() } } 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 743139e4d5b..6566b1c592c 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 @@ -226,12 +226,14 @@ class ReplayIntegrationTest { } @Test - fun `foreground before register does nothing`() { - val replay = fixture.getSut(context) + fun `lifecycle callbacks before register are not enqueued`() { + val mainLooperHandler = mock() + val replay = fixture.getSut(context, mainLooperHandler = mainLooperHandler) replay.onAppForegrounded(true) + replay.onAppBackgrounded() - assertThat(replay.isRecording).isFalse() + verify(mainLooperHandler, never()).post(any()) } @Test diff --git a/sentry/src/main/java/io/sentry/IReplayApi.java b/sentry/src/main/java/io/sentry/IReplayApi.java index d31a24b0b21..c2944d4b632 100644 --- a/sentry/src/main/java/io/sentry/IReplayApi.java +++ b/sentry/src/main/java/io/sentry/IReplayApi.java @@ -11,17 +11,20 @@ public interface IReplayApi { /** * Starts replay buffering. The rolling buffer is sent when {@link #flush()} is called or an error - * is captured. After the buffer is sent, recording continues in session mode unless the process - * is terminating. + * is captured and selected by {@link SentryReplayOptions#getOnErrorSampleRate()}. After the + * buffer is sent, recording continues in session mode unless the process is terminating. */ void startBuffering(); - /** Stops the current replay. A subsequent {@link #start()} begins a new replay session. */ + /** + * Stops the current replay in either session or buffer mode. A subsequent {@link #start()} begins + * a new replay session. + */ void stop(); /** - * Pauses the current replay until {@link #resume()} is called. This can be used to avoid - * recording sensitive screens, such as PIN entry. + * Pauses the current replay in either session or buffer mode until {@link #resume()} is called. + * This can be used to avoid recording sensitive screens, such as PIN entry. */ void pause(); From 119ba0b690f99b5b5a92b51fcac62387253d16ac Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 24 Aug 2026 12:10:40 +0200 Subject: [PATCH 07/15] fix(replay): Preserve queued foreground startup Check Replay registration when the foreground callback executes so AppState catch-up can start Replay after registration. Cover both callback orderings with tests. Refs JAVA-325 Co-Authored-By: Codex --- .../android/replay/ReplayIntegration.kt | 9 +++------ .../android/replay/ReplayIntegrationTest.kt | 19 ++++++++++++++----- 2 files changed, 17 insertions(+), 11 deletions(-) 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 92bb4335e70..851ffec476b 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 @@ -167,10 +167,10 @@ public class ReplayIntegration( } override fun onAppForegrounded(startNewSession: Boolean) { - if (!isEnabled.get()) { - return - } enqueueOnMainThread { + if (!isEnabled.get()) { + return@enqueueOnMainThread + } if (startNewSession) { val isFullSession = sample(options.sessionReplay.sessionSampleRate) if (!isFullSession && !options.sessionReplay.isSessionReplayForErrorsEnabled) { @@ -187,9 +187,6 @@ public class ReplayIntegration( } override fun onAppBackgrounded() { - if (!isEnabled.get()) { - return - } enqueueOnMainThread { pauseInternal() } } 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 6566b1c592c..05a4d85ea4c 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 @@ -226,14 +226,23 @@ class ReplayIntegrationTest { } @Test - fun `lifecycle callbacks before register are not enqueued`() { - val mainLooperHandler = mock() - val replay = fixture.getSut(context, mainLooperHandler = mainLooperHandler) + fun `foreground before register does nothing`() { + val replay = fixture.getSut(context) replay.onAppForegrounded(true) - replay.onAppBackgrounded() - verify(mainLooperHandler, never()).post(any()) + assertThat(replay.isRecording).isFalse() + } + + @Test + fun `foreground queued before register starts replay after register`() { + val replay = fixture.getSut(context, mainLooperHandler = MainLooperHandler()) + + replay.onAppForegrounded(true) + replay.register(fixture.scopes, fixture.options) + shadowOf(Looper.getMainLooper()).idle() + + assertThat(replay.isRecording).isTrue() } @Test From 716ddfb68ea6a1dc26f2173f4f3fc7f73b7e79a0 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 26 Aug 2026 14:24:32 +0200 Subject: [PATCH 08/15] feat(samples): Add manual replay controls Expose each manual Session Replay operation on the Android sample replay screen for interactive testing and demonstration. Refs JAVA-325 Co-Authored-By: Codex --- .../io/sentry/samples/android/MainActivity.kt | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt index b38f17ed64c..eb18b961534 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt @@ -580,6 +580,48 @@ fun SessionReplayScreen() { horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { + item { + SentryTraced("start_replay") { + OutlinedButton(onClick = { Sentry.replay().start() }, modifier = Modifier) { + Text("Start Replay", maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } + item { + SentryTraced("start_replay_buffering") { + OutlinedButton(onClick = { Sentry.replay().startBuffering() }, modifier = Modifier) { + Text("Start Replay Buffering", maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } + item { + SentryTraced("pause_replay") { + OutlinedButton(onClick = { Sentry.replay().pause() }, modifier = Modifier) { + Text("Pause Replay", maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } + item { + SentryTraced("resume_replay") { + OutlinedButton(onClick = { Sentry.replay().resume() }, modifier = Modifier) { + Text("Resume Replay", maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } + item { + SentryTraced("flush_replay") { + OutlinedButton(onClick = { Sentry.replay().flush() }, modifier = Modifier) { + Text("Flush Replay", maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } + item { + SentryTraced("stop_replay") { + OutlinedButton(onClick = { Sentry.replay().stop() }, modifier = Modifier) { + Text("Stop Replay", maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } item { SentryTraced("enable_replay_debug") { OutlinedButton( From 8de091cfe5ea54ebc7241a14255e1730be046feb Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 26 Aug 2026 16:30:29 +0200 Subject: [PATCH 09/15] fix(replay): Flush sessions and restart expired replays Make explicit replay flushes send the current segment in both capture modes. Restart automatic replay recording when a new app session begins, and document how flush differs from event-triggered capture. Refs JAVA-325 Co-Authored-By: OpenAI Codex --- .../android/replay/ReplayIntegration.kt | 47 ++++++++++--------- .../replay/capture/BufferCaptureStrategy.kt | 2 + .../android/replay/capture/CaptureStrategy.kt | 7 +++ .../replay/capture/SessionCaptureStrategy.kt | 18 +++++-- .../android/replay/ReplayIntegrationTest.kt | 36 +++++++++++++- .../capture/SessionCaptureStrategyTest.kt | 14 ++++++ .../src/main/java/io/sentry/IReplayApi.java | 5 +- .../main/java/io/sentry/ReplayController.java | 10 ++-- 8 files changed, 107 insertions(+), 32 deletions(-) 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 851ffec476b..bb0bf1bcca9 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 @@ -52,6 +52,7 @@ import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion import io.sentry.util.Random import java.io.Closeable import java.io.File +import java.util.Date import java.util.LinkedList import java.util.concurrent.CountDownLatch import java.util.concurrent.Executors @@ -172,6 +173,7 @@ public class ReplayIntegration( return@enqueueOnMainThread } if (startNewSession) { + stopInternal() val isFullSession = sample(options.sessionReplay.sessionSampleRate) if (!isFullSession && !options.sessionReplay.isSessionReplayForErrorsEnabled) { options.logger.log( @@ -294,16 +296,18 @@ public class ReplayIntegration( current.captureStrategy?.captureReplay(true) {} } else { enqueueOnMainThread { - captureReplayInternal(current.generation, current.replayId, false) + captureCurrentReplay(current.generation, current.replayId) { strategy, onSegmentSent -> + strategy.captureReplay(false, onSegmentSent) + } } } return current.replayId } - private fun captureReplayInternal( + private fun captureCurrentReplay( expectedGeneration: Long, expectedReplayId: SentryId, - isTerminating: Boolean, + capture: (CaptureStrategy, (Date) -> Unit) -> Unit, ) { val current = state.get() val strategy = current.captureStrategy @@ -315,30 +319,27 @@ public class ReplayIntegration( } options.logger.log( INFO, - "Replay was stopped or restarted before capture could run, not capturing for event", + "Replay was stopped or restarted before capture could run, not capturing replay", ) return } var activeStrategy: CaptureStrategy = strategy - strategy.captureReplay( - isTerminating, - onSegmentSent = { newTimestamp -> - enqueueOnMainThread { - val latest = state.get() - // The flush completes asynchronously; ignore it if this replay was stopped, restarted, - // or handed to another strategy in the meantime. - if ( - latest.matches(expectedGeneration, expectedReplayId) && - latest.captureStrategy === activeStrategy - ) { - activeStrategy.currentSegment++ - activeStrategy.segmentTimestamp = newTimestamp - activeStrategy.isFlushed = true - } + capture(strategy) { newTimestamp -> + enqueueOnMainThread { + val latest = state.get() + // The flush completes asynchronously; ignore it if this replay was stopped, restarted, + // or handed to another strategy in the meantime. + if ( + latest.matches(expectedGeneration, expectedReplayId) && + latest.captureStrategy === activeStrategy + ) { + activeStrategy.currentSegment++ + activeStrategy.segmentTimestamp = newTimestamp + activeStrategy.isFlushed = true } - }, - ) + } + } activeStrategy = strategy.convert() val replayId: SentryId? = activeStrategy.currentReplayId state.set( @@ -357,7 +358,9 @@ public class ReplayIntegration( if (!current.isRecording) { startInternal(isFullSession = true) } else { - captureReplayInternal(current.generation, current.replayId, false) + captureCurrentReplay(current.generation, current.replayId) { strategy, onSegmentSent -> + strategy.flush(onSegmentSent) + } } } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt index d20735e4128..b8845f74848 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt @@ -123,6 +123,8 @@ internal class BufferCaptureStrategy( } } + override fun flush(onSegmentSent: (Date) -> Unit) = captureReplay(false, onSegmentSent) + override fun onScreenshotRecorded( bitmap: Bitmap?, store: ReplayCache.(frameTimestamp: Long) -> Unit, diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt index 780cdd92481..fc8b8d13065 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt @@ -39,8 +39,15 @@ internal interface CaptureStrategy { fun resume() + /** + * Captures replay data for an event. Session mode may defer sending until its normal segment + * boundary because the replay is already being uploaded continuously. + */ fun captureReplay(isTerminating: Boolean, onSegmentSent: (Date) -> Unit) + /** Explicitly sends the current replay data to Sentry in either session or buffer mode. */ + fun flush(onSegmentSent: (Date) -> Unit) + fun onScreenshotRecorded( bitmap: Bitmap? = null, store: ReplayCache.(frameTimestamp: Long) -> Unit, diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt index df6e09b5358..45cffa55731 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt @@ -21,9 +21,10 @@ import java.util.concurrent.ScheduledExecutorService * `sessionSegmentDuration`, until the 1h `sessionDuration` deadline. Used when the session is * sampled by `sessionSampleRate`. * - * [captureReplay] is a no-op here — there is no buffer to flush, the segment covering the error is - * sent like any other. Because envelopes are in flight the whole time, `ReplayIntegration` pauses - * this strategy while offline or rate-limited so the envelope cache doesn't overflow. + * Event-triggered [captureReplay] is a no-op here — there is no buffer to flush, so the segment + * covering the error is sent like any other. An explicit [flush] still sends the current segment. + * Because envelopes are in flight the whole time, `ReplayIntegration` pauses this strategy while + * offline or rate-limited so the envelope cache doesn't overflow. * * See [BufferCaptureStrategy] for the on-error counterpart. */ @@ -91,6 +92,17 @@ internal class SessionCaptureStrategy( this.isTerminating.set(isTerminating) } + override fun flush(onSegmentSent: (Date) -> Unit) { + createCurrentSegment("flush") { segment -> + if (segment is ReplaySegment.Created) { + segment.capture(scopes) + currentSegment++ + segmentTimestamp = segment.replay.timestamp + isFlushed = true + } + } + } + override fun onScreenshotRecorded( bitmap: Bitmap?, store: ReplayCache.(frameTimestamp: Long) -> Unit, 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 05a4d85ea4c..aed9f7c8e6f 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 @@ -439,6 +439,27 @@ class ReplayIntegrationTest { verify(captureStrategy).resume() } + @Test + fun `new app session replaces an automatically paused replay`() { + val firstStrategy = mock() + val secondStrategy = mock() + val strategies = ArrayDeque(listOf(firstStrategy, secondStrategy)) + val replay = + fixture.getSut( + context, + replayCaptureStrategyProvider = { strategies.removeFirst() }, + ) + + replay.register(fixture.scopes, fixture.options) + replay.onAppForegrounded(true) + replay.onAppBackgrounded() + replay.onAppForegrounded(true) + + verify(firstStrategy).stop() + verify(firstStrategy, never()).resume() + verify(secondStrategy).start(any(), any(), anyOrNull()) + } + @Test fun `captureReplay does nothing when not recording`() { val captureStrategy = mock() @@ -505,10 +526,23 @@ class ReplayIntegrationTest { replay.startBuffering() replay.flush() - verify(captureStrategy).captureReplay(eq(false), any()) + verify(captureStrategy).flush(any()) verify(captureStrategy).convert() } + @Test + fun `flush captures the current session segment`() { + val captureStrategy = mock() + whenever(captureStrategy.convert()).thenReturn(captureStrategy) + val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) + + replay.register(fixture.scopes, fixture.options) + replay.start() + replay.flush() + + verify(captureStrategy).flush(any()) + } + @Test fun `flush starts a session when replay is stopped`() { val captureStrategy = mock() diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt index 3e5198bea01..82ed689a52a 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt @@ -251,6 +251,20 @@ class SessionCaptureStrategyTest { verify(fixture.scopes, never()).captureReplay(any(), any()) } + @Test + fun `flush creates and captures current segment`() { + val strategy = fixture.getSut() + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) + + strategy.flush {} + + verify(fixture.scopes) + .captureReplay(argThat { event -> event is SentryReplayEvent && event.segmentId == 0 }, any()) + assertEquals(1, strategy.currentSegment) + assertTrue(strategy.isFlushed) + } + @Test fun `when process is crashing, onScreenshotRecorded does not create new segment`() { val now = diff --git a/sentry/src/main/java/io/sentry/IReplayApi.java b/sentry/src/main/java/io/sentry/IReplayApi.java index c2944d4b632..fa9aecc4463 100644 --- a/sentry/src/main/java/io/sentry/IReplayApi.java +++ b/sentry/src/main/java/io/sentry/IReplayApi.java @@ -32,8 +32,9 @@ public interface IReplayApi { void resume(); /** - * Flushes replay data. A buffering replay continues in session mode after the buffer is sent. If - * replay is not recording, starts a new replay session. + * Immediately sends the current replay data to Sentry in either session or buffer mode. A + * buffering replay continues in session mode after the buffer is sent. If replay is not + * recording, starts a new replay session. */ void flush(); diff --git a/sentry/src/main/java/io/sentry/ReplayController.java b/sentry/src/main/java/io/sentry/ReplayController.java index 811208bfa99..1da55558ca9 100644 --- a/sentry/src/main/java/io/sentry/ReplayController.java +++ b/sentry/src/main/java/io/sentry/ReplayController.java @@ -8,8 +8,8 @@ @ApiStatus.Internal public interface ReplayController extends IReplayApi { /** - * Handles app foregrounding. When a new app session begins, starts a sampled replay unless one is - * already recording. An existing replay is never restarted or replaced. + * Handles app foregrounding. When a new app session begins, stops any previous replay and starts + * a newly sampled one. */ void onAppForegrounded(boolean startNewSession); @@ -22,8 +22,10 @@ public interface ReplayController extends IReplayApi { boolean isRecording(); /** - * Captures the buffered replay and returns its ID, or {@link SentryId#EMPTY_ID} if no replay was - * captured. + * Captures replay data for an event and returns its ID, or {@link SentryId#EMPTY_ID} if no replay + * was captured. In buffer mode, sends the buffered replay and continues in session mode. In + * session mode, the replay is already uploaded continuously, so this does not force an immediate + * segment upload; use {@link #flush()} for that. */ @NotNull SentryId captureReplay(@Nullable Boolean isTerminating); From de61eebb6e6c3342d3bba6a19f5e7467ed2ce647 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 26 Aug 2026 17:13:51 +0200 Subject: [PATCH 10/15] fix(replay): Serialize session flush timeline Read session timeline state on the replay executor so queued natural segment boundaries cannot make an explicit flush stale. Document and test that a new app session replaces a manually paused replay. Refs JAVA-325 Co-Authored-By: Codex --- .../replay/capture/SessionCaptureStrategy.kt | 12 +++---- .../android/replay/ReplayIntegrationTest.kt | 21 +++++++++++++ .../capture/SessionCaptureStrategyTest.kt | 31 +++++++++++++++++++ .../src/main/java/io/sentry/IReplayApi.java | 5 +-- 4 files changed, 61 insertions(+), 8 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt index 45cffa55731..7c76a2a6e0c 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt @@ -191,17 +191,17 @@ internal class SessionCaptureStrategy( return } - val now = dateProvider.currentTimeMillis - val currentSegmentTimestamp = segmentTimestamp ?: return - val duration = now - currentSegmentTimestamp.time - val replayId = currentReplayId replayExecutor.submit( ReplayRunnable("$TAG.$taskName") { + // Read the segment timeline on the replay executor so a queued natural segment boundary + // cannot make this snapshot stale. + val now = dateProvider.currentTimeMillis + val currentSegmentTimestamp = segmentTimestamp ?: return@ReplayRunnable val segment = createSegmentInternal( - duration, + now - currentSegmentTimestamp.time, currentSegmentTimestamp, - replayId, + currentReplayId, currentSegment, currentConfig.recordingHeight, currentConfig.recordingWidth, 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 aed9f7c8e6f..0b6544e626d 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 @@ -460,6 +460,27 @@ class ReplayIntegrationTest { verify(secondStrategy).start(any(), any(), anyOrNull()) } + @Test + fun `new app session replaces a manually paused replay`() { + val firstStrategy = mock() + val secondStrategy = mock() + val strategies = ArrayDeque(listOf(firstStrategy, secondStrategy)) + val replay = + fixture.getSut( + context, + replayCaptureStrategyProvider = { strategies.removeFirst() }, + ) + + replay.register(fixture.scopes, fixture.options) + replay.onAppForegrounded(true) + replay.pause() + replay.onAppForegrounded(true) + + verify(firstStrategy).pause() + verify(firstStrategy).stop() + verify(secondStrategy).start(any(), any(), anyOrNull()) + } + @Test fun `captureReplay does nothing when not recording`() { val captureStrategy = mock() diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt index 82ed689a52a..349cda9872f 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt @@ -265,6 +265,37 @@ class SessionCaptureStrategyTest { assertTrue(strategy.isFlushed) } + @Test + fun `flush uses the timeline after queued segment boundaries`() { + val tasks = mutableListOf() + val segmentIds = mutableListOf() + val now = System.currentTimeMillis() + fixture.options.sessionReplay.sessionSegmentDuration * 2 + val replayExecutor = + mock { + doAnswer { + tasks += it.arguments[0] as Runnable + null + } + .whenever(mock) + .submit(any()) + } + doAnswer { + segmentIds += (it.arguments[0] as SentryReplayEvent).segmentId + SentryId.EMPTY_ID + } + .whenever(fixture.scopes) + .captureReplay(any(), any()) + val strategy = fixture.getSut(dateProvider = { now }, replayExecutor = replayExecutor) + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) + + strategy.onScreenshotRecorded(mock()) {} + strategy.flush {} + tasks.forEach(Runnable::run) + + assertThat(segmentIds).containsExactly(0, 1).inOrder() + } + @Test fun `when process is crashing, onScreenshotRecorded does not create new segment`() { val now = diff --git a/sentry/src/main/java/io/sentry/IReplayApi.java b/sentry/src/main/java/io/sentry/IReplayApi.java index fa9aecc4463..40b9040fc1b 100644 --- a/sentry/src/main/java/io/sentry/IReplayApi.java +++ b/sentry/src/main/java/io/sentry/IReplayApi.java @@ -23,8 +23,9 @@ public interface IReplayApi { void stop(); /** - * Pauses the current replay in either session or buffer mode until {@link #resume()} is called. - * This can be used to avoid recording sensitive screens, such as PIN entry. + * Pauses the current replay in either session or buffer mode. Recording resumes when {@link + * #resume()} is called or a new replay session starts. This can be used to avoid recording + * sensitive screens, such as PIN entry. */ void pause(); From ff3b7aeea2e32b7b1e885c0c47f14461486bc69a Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 26 Aug 2026 18:21:25 +0200 Subject: [PATCH 11/15] fix(replay): Preserve session flush cutoff time Keep the invocation timestamp when queuing segment work so executor delays do not extend replay segments. Continue reading the mutable segment cursor on the replay executor to avoid stale boundaries. Refs JAVA-325 Co-Authored-By: Codex --- .../replay/capture/SessionCaptureStrategy.kt | 11 +++--- .../capture/SessionCaptureStrategyTest.kt | 37 +++++++++++++++++++ 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt index 7c76a2a6e0c..fe7e8330422 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt @@ -191,17 +191,18 @@ internal class SessionCaptureStrategy( return } + val requestedAt = dateProvider.currentTimeMillis + val replayId = currentReplayId replayExecutor.submit( ReplayRunnable("$TAG.$taskName") { - // Read the segment timeline on the replay executor so a queued natural segment boundary - // cannot make this snapshot stale. - val now = dateProvider.currentTimeMillis + // Keep the request time so executor delays do not extend the segment, but read the mutable + // timeline here so a queued natural boundary cannot make it stale. val currentSegmentTimestamp = segmentTimestamp ?: return@ReplayRunnable val segment = createSegmentInternal( - now - currentSegmentTimestamp.time, + requestedAt - currentSegmentTimestamp.time, currentSegmentTimestamp, - currentReplayId, + replayId, currentSegment, currentConfig.recordingHeight, currentConfig.recordingWidth, diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt index 349cda9872f..cad0a416c92 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt @@ -50,6 +50,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.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify @@ -296,6 +297,42 @@ class SessionCaptureStrategyTest { assertThat(segmentIds).containsExactly(0, 1).inOrder() } + @Test + fun `flush uses request time when executor is delayed`() { + val tasks = mutableListOf() + var now = System.currentTimeMillis() + fixture.options.sessionReplay.sessionSegmentDuration + val replayExecutor = + mock { + doAnswer { + tasks += it.arguments[0] as Runnable + null + } + .whenever(mock) + .submit(any()) + } + val strategy = fixture.getSut(dateProvider = { now }, replayExecutor = replayExecutor) + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) + val segmentStart = strategy.segmentTimestamp!!.time + val requestedAt = now + + strategy.flush {} + now += fixture.options.sessionReplay.sessionSegmentDuration + tasks.forEach(Runnable::run) + + verify(fixture.replayCache) + .createVideoOf( + eq(requestedAt - segmentStart), + eq(segmentStart), + eq(0), + any(), + any(), + any(), + any(), + any(), + ) + } + @Test fun `when process is crashing, onScreenshotRecorded does not create new segment`() { val now = From 59505b3647d75f6c4d6da2a6fc69b4339e46c7bb Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 26 Aug 2026 19:00:28 +0200 Subject: [PATCH 12/15] fix(replay): Preserve manual pause across sessions Keep an explicit replay pause when automatic app-session rollover replaces the active replay. Require resume before the replacement replay records, and document the process-local lifetime. Refs JAVA-325 Co-Authored-By: Codex --- CHANGELOG.md | 2 +- .../java/io/sentry/android/replay/ReplayIntegration.kt | 5 +++++ .../java/io/sentry/android/replay/ReplayIntegrationTest.kt | 7 ++++++- sentry/src/main/java/io/sentry/IReplayApi.java | 6 +++--- sentry/src/main/java/io/sentry/ReplayController.java | 2 +- 5 files changed, 16 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d32c6598613..71689dc8f45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ - `start()` starts a full-session replay and does nothing if one is already recording. - `startBuffering()` keeps a rolling buffer that is sent on `flush()` or an error, then continues in session mode. - `stop()` ends the current replay; the next `start()` creates a new replay session. - - `pause()` suspends recording until `resume()` and remains paused across background and foreground transitions. + - `pause()` suspends recording until `resume()` and remains paused across background and foreground transitions and automatic replay restarts in the same process. - `resume()` continues the same manually paused replay. - `flush()` sends the current replay data, or starts a full-session replay when recording is stopped. 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 bb0bf1bcca9..207dab525fc 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 @@ -173,6 +173,7 @@ public class ReplayIntegration( return@enqueueOnMainThread } if (startNewSession) { + val wasManuallyPaused = isManualPause stopInternal() val isFullSession = sample(options.sessionReplay.sessionSampleRate) if (!isFullSession && !options.sessionReplay.isSessionReplayForErrorsEnabled) { @@ -183,6 +184,10 @@ public class ReplayIntegration( } else { startInternal(isFullSession) } + isManualPause = wasManuallyPaused + if (wasManuallyPaused) { + pauseInternal() + } } resumeInternal() } 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 0b6544e626d..30fa400ecea 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 @@ -461,7 +461,7 @@ class ReplayIntegrationTest { } @Test - fun `new app session replaces a manually paused replay`() { + fun `new app session replaces a manually paused replay and stays paused`() { val firstStrategy = mock() val secondStrategy = mock() val strategies = ArrayDeque(listOf(firstStrategy, secondStrategy)) @@ -479,6 +479,11 @@ class ReplayIntegrationTest { verify(firstStrategy).pause() verify(firstStrategy).stop() verify(secondStrategy).start(any(), any(), anyOrNull()) + verify(secondStrategy).pause() + verify(secondStrategy, never()).resume() + + replay.resume() + verify(secondStrategy).resume() } @Test diff --git a/sentry/src/main/java/io/sentry/IReplayApi.java b/sentry/src/main/java/io/sentry/IReplayApi.java index 40b9040fc1b..e15bcdaa542 100644 --- a/sentry/src/main/java/io/sentry/IReplayApi.java +++ b/sentry/src/main/java/io/sentry/IReplayApi.java @@ -23,9 +23,9 @@ public interface IReplayApi { void stop(); /** - * Pauses the current replay in either session or buffer mode. Recording resumes when {@link - * #resume()} is called or a new replay session starts. This can be used to avoid recording - * sensitive screens, such as PIN entry. + * Pauses replay recording in either session or buffer mode until {@link #resume()} is called. If + * the SDK automatically starts a new replay session in the same process, the new replay remains + * paused. This can be used to avoid recording sensitive screens, such as PIN entry. */ void pause(); diff --git a/sentry/src/main/java/io/sentry/ReplayController.java b/sentry/src/main/java/io/sentry/ReplayController.java index 1da55558ca9..6fa50153b84 100644 --- a/sentry/src/main/java/io/sentry/ReplayController.java +++ b/sentry/src/main/java/io/sentry/ReplayController.java @@ -9,7 +9,7 @@ public interface ReplayController extends IReplayApi { /** * Handles app foregrounding. When a new app session begins, stops any previous replay and starts - * a newly sampled one. + * a newly sampled one while preserving an explicit user pause. */ void onAppForegrounded(boolean startNewSession); From 69a9fb0f5ed01136cdec034611c299ddf1184b8c Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 26 Aug 2026 19:46:05 +0200 Subject: [PATCH 13/15] fix(replay): Preserve capture timeline ordering Serialize resume timeline updates behind queued replay work and use frame capture timestamps for session boundaries and deadlines. This prevents pause/resume races and stops executor backlog from counting as recorded time. Co-Authored-By: OpenAI Codex --- .../replay/capture/BaseCaptureStrategy.kt | 4 +- .../replay/capture/SessionCaptureStrategy.kt | 8 +- .../capture/SessionCaptureStrategyTest.kt | 80 +++++++++++++++---- 3 files changed, 74 insertions(+), 18 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index deb51ecc006..17aefb3ebd7 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -113,7 +113,9 @@ internal abstract class BaseCaptureStrategy( } override fun resume() { - segmentTimestamp = DateUtils.getCurrentDateTime() + val resumedAt = DateUtils.getCurrentDateTime() + // Keep the timeline update behind queued frames and pause/flush segment creation. + replayExecutor.submit(ReplayRunnable("$TAG.resume") { segmentTimestamp = resumedAt }) } override fun pause() = Unit diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt index fe7e8330422..4fd3c910e22 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt @@ -135,8 +135,10 @@ internal class SessionCaptureStrategy( return@ReplayRunnable } - val now = dateProvider.currentTimeMillis - if ((now - currentSegmentTimestamp.time >= options.sessionReplay.sessionSegmentDuration)) { + if ( + frameTimestamp - currentSegmentTimestamp.time >= + options.sessionReplay.sessionSegmentDuration + ) { val segment = createSegmentInternal( options.sessionReplay.sessionSegmentDuration, @@ -156,7 +158,7 @@ internal class SessionCaptureStrategy( } } - if ((now - replayStartTimestamp.get() >= options.sessionReplay.sessionDuration)) { + if (frameTimestamp - replayStartTimestamp.get() >= options.sessionReplay.sessionDuration) { options.replayController.stop() options.logger.log(INFO, "Session replay deadline exceeded (1h), stopping recording") } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt index cad0a416c92..3d876b8f971 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt @@ -333,6 +333,69 @@ class SessionCaptureStrategyTest { ) } + @Test + fun `resume updates the timeline after a queued pause`() { + val tasks = mutableListOf() + val now = System.currentTimeMillis() + val replayExecutor = + mock { + doAnswer { + tasks += it.arguments[0] as Runnable + null + } + .whenever(mock) + .submit(any()) + } + val strategy = fixture.getSut(dateProvider = { now }, replayExecutor = replayExecutor) + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) + val segmentStart = Date(now - fixture.options.sessionReplay.sessionSegmentDuration) + strategy.segmentTimestamp = segmentStart + + strategy.pause() + strategy.resume() + tasks.forEach(Runnable::run) + + verify(fixture.replayCache) + .createVideoOf( + eq(now - segmentStart.time), + eq(segmentStart.time), + eq(0), + any(), + any(), + any(), + any(), + any(), + ) + assertThat(strategy.segmentTimestamp).isNotEqualTo(segmentStart) + } + + @Test + fun `executor delay does not trigger a segment boundary`() { + val tasks = mutableListOf() + var now = System.currentTimeMillis() + val replayExecutor = + mock { + doAnswer { + tasks += it.arguments[0] as Runnable + null + } + .whenever(mock) + .submit(any()) + } + val strategy = fixture.getSut(dateProvider = { now }, replayExecutor = replayExecutor) + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) + now += fixture.options.sessionReplay.sessionSegmentDuration - 1 + + strategy.onScreenshotRecorded(mock()) {} + now += fixture.options.sessionReplay.sessionDuration + tasks.forEach(Runnable::run) + + verify(fixture.scopes, never()).captureReplay(any(), any()) + verify(fixture.options.replayController, never()).stop() + } + @Test fun `when process is crashing, onScreenshotRecorded does not create new segment`() { val now = @@ -379,22 +442,11 @@ class SessionCaptureStrategyTest { @Test fun `onScreenshotRecorded stops replay when replay duration exceeded`() { - val now = System.currentTimeMillis() + (fixture.options.sessionReplay.sessionDuration * 2) - var count = 0 - val strategy = - fixture.getSut( - dateProvider = { - // we only need to fake value for the 3rd call (first two is for replayStartTimestamp and - // frameTimestamp) - if (count++ == 2) { - now - } else { - System.currentTimeMillis() - } - } - ) + var now = System.currentTimeMillis() + val strategy = fixture.getSut(dateProvider = { now }) strategy.start() strategy.onConfigurationChanged(mock()) + now += fixture.options.sessionReplay.sessionDuration * 2 strategy.onScreenshotRecorded(mock()) {} From ef582b556386e313cb09306068c47e695fb8df8f Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 26 Aug 2026 23:31:30 +0200 Subject: [PATCH 14/15] fix(replay): Preserve pause before recorder configuration Keep pause state while a replacement replay waits for its first recorder configuration. This prevents a newly created capturer from recording before the user explicitly resumes. Refs JAVA-325 Co-Authored-By: OpenAI Codex --- .../sentry/android/replay/WindowRecorder.kt | 9 +++++ .../android/replay/WindowRecorderTest.kt | 40 +++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 sentry-android-replay/src/test/java/io/sentry/android/replay/WindowRecorderTest.kt diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt index 19c61900889..53eb1f7525a 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt @@ -32,6 +32,7 @@ internal class WindowRecorder( ) : Recorder, OnRootViewsChangedListener, ExecutorProvider { private val isRecording = AtomicBoolean(false) + private val isPaused = AtomicBoolean(false) private val rootViews = ArrayList>() private var lastKnownWindowSize: Point = Point() private val rootLayoutListeners = WeakHashMap() @@ -211,6 +212,7 @@ internal class WindowRecorder( } override fun start() { + isPaused.set(false) isRecording.getAndSet(true) } @@ -239,6 +241,11 @@ internal class WindowRecorder( // Remove any existing callbacks to prevent concurrent capture loops mainLooperHandler.removeCallbacks(capturer) + if (isPaused.get()) { + capturer?.pause() + return + } + val posted = mainLooperHandler.postDelayed( capturer, @@ -253,10 +260,12 @@ internal class WindowRecorder( } override fun resume() { + isPaused.set(false) capturer?.resume() } override fun pause() { + isPaused.set(true) capturer?.pause() } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/WindowRecorderTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/WindowRecorderTest.kt new file mode 100644 index 00000000000..1f38280f4ff --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/WindowRecorderTest.kt @@ -0,0 +1,40 @@ +package io.sentry.android.replay + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.SentryOptions +import io.sentry.android.replay.util.MainLooperHandler +import java.util.concurrent.ScheduledExecutorService +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [26]) +class WindowRecorderTest { + @Test + fun `configuration does not start capture while paused`() { + val mainLooperHandler = mock() + val recorder = + WindowRecorder( + SentryOptions(), + windowCallback = mock(), + mainLooperHandler = mainLooperHandler, + replayExecutor = mock(), + ) + + recorder.start() + recorder.pause() + recorder.onConfigurationChanged(ScreenshotRecorderConfig(100, 200, 1f, 1f, 1, 20_000)) + + verify(mainLooperHandler, never()).postDelayed(anyOrNull(), any()) + + recorder.resume() + + verify(mainLooperHandler).post(any()) + } +} From c8c78014ad0549151305e3131d675ff85a6bd05a Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 27 Aug 2026 00:45:39 +0200 Subject: [PATCH 15/15] fix(replay): Preserve manual pause when app session ends Route lifecycle-driven replay shutdown through a dedicated internal callback so it keeps an explicit user pause. Public stop still clears the pause state. Replace the recorder-level workaround with a regression test covering timeout, foreground, and explicit resume. Refs JAVA-325 Co-Authored-By: Codex --- .../sentry/android/core/LifecycleWatcher.java | 2 +- .../android/core/LifecycleWatcherTest.kt | 6 +-- .../api/sentry-android-replay.api | 1 + .../android/replay/ReplayIntegration.kt | 10 ++++- .../sentry/android/replay/WindowRecorder.kt | 9 ----- .../android/replay/ReplayIntegrationTest.kt | 23 +++++++++++ .../android/replay/WindowRecorderTest.kt | 40 ------------------- sentry/api/sentry.api | 2 + .../java/io/sentry/NoOpReplayController.java | 3 ++ .../main/java/io/sentry/ReplayController.java | 3 ++ 10 files changed, 44 insertions(+), 55 deletions(-) delete mode 100644 sentry-android-replay/src/test/java/io/sentry/android/replay/WindowRecorderTest.kt diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java b/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java index 107017ffd61..ca874e714e1 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java @@ -109,7 +109,7 @@ private void scheduleEndSession() { if (enableSessionTracking) { scopes.endSession(); } - scopes.getOptions().getReplayController().stop(); + scopes.getOptions().getReplayController().onAppSessionEnded(); scopes.getOptions().getContinuousProfiler().close(false); }; diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt index 3ceeeef8c38..5f14e029d0e 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt @@ -107,7 +107,7 @@ class LifecycleWatcherTest { watcher.onForeground() watcher.onBackground() verify(fixture.scopes, timeout(10000)).endSession() - verify(fixture.replayController, timeout(10000)).stop() + verify(fixture.replayController, timeout(10000)).onAppSessionEnded() verify(fixture.continuousProfiler, timeout(10000)).close(eq(false)) } @@ -124,7 +124,7 @@ class LifecycleWatcherTest { assertNull(watcher.endSessionFuture) verify(fixture.scopes, never()).endSession() - verify(fixture.replayController, never()).stop() + verify(fixture.replayController, never()).onAppSessionEnded() } @Test @@ -262,6 +262,6 @@ class LifecycleWatcherTest { verify(fixture.replayController).onAppForegrounded(false) watcher.onBackground() - verify(fixture.replayController, timeout(10000)).stop() + verify(fixture.replayController, timeout(10000)).onAppSessionEnded() } } diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index b16b83278ff..91c91778f05 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -70,6 +70,7 @@ public final class io/sentry/android/replay/ReplayIntegration : io/sentry/IConne public fun isRecording ()Z public fun onAppBackgrounded ()V public fun onAppForegrounded (Z)V + public fun onAppSessionEnded ()V public final fun onConfigurationChanged (Lio/sentry/android/replay/ScreenshotRecorderConfig;)V public fun onConnectionStatusChanged (Lio/sentry/IConnectionStatusProvider$ConnectionStatus;)V public fun onRateLimitChanged (Lio/sentry/transport/RateLimiter;)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 207dab525fc..edb3f9c03a7 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 @@ -197,6 +197,10 @@ public class ReplayIntegration( enqueueOnMainThread { pauseInternal() } } + override fun onAppSessionEnded() { + enqueueOnMainThread { stopInternal(resetManualPause = false) } + } + private fun startInternal(isFullSession: Boolean) { if (!isEnabled.get()) { return @@ -424,7 +428,7 @@ public class ReplayIntegration( enqueueOnMainThread { stopInternal() } } - private fun stopInternal() { + private fun stopInternal(resetManualPause: Boolean = true) { val current = state.get() if (!isEnabled.get() || !current.lifecycleState.isAllowed(STOPPED)) { return @@ -435,7 +439,9 @@ public class ReplayIntegration( recorder?.stop() gestureRecorder?.stop() current.captureStrategy?.stop() - isManualPause = false + if (resetManualPause) { + isManualPause = false + } state.set( current.copy( lifecycleState = STOPPED, diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt index 53eb1f7525a..19c61900889 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt @@ -32,7 +32,6 @@ internal class WindowRecorder( ) : Recorder, OnRootViewsChangedListener, ExecutorProvider { private val isRecording = AtomicBoolean(false) - private val isPaused = AtomicBoolean(false) private val rootViews = ArrayList>() private var lastKnownWindowSize: Point = Point() private val rootLayoutListeners = WeakHashMap() @@ -212,7 +211,6 @@ internal class WindowRecorder( } override fun start() { - isPaused.set(false) isRecording.getAndSet(true) } @@ -241,11 +239,6 @@ internal class WindowRecorder( // Remove any existing callbacks to prevent concurrent capture loops mainLooperHandler.removeCallbacks(capturer) - if (isPaused.get()) { - capturer?.pause() - return - } - val posted = mainLooperHandler.postDelayed( capturer, @@ -260,12 +253,10 @@ internal class WindowRecorder( } override fun resume() { - isPaused.set(false) capturer?.resume() } override fun pause() { - isPaused.set(true) capturer?.pause() } 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 30fa400ecea..932f26a2b6d 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 @@ -486,6 +486,29 @@ class ReplayIntegrationTest { verify(secondStrategy).resume() } + @Test + fun `new app session stays paused after the previous app session ends`() { + val firstStrategy = mock() + val secondStrategy = mock() + val strategies = ArrayDeque(listOf(firstStrategy, secondStrategy)) + val replay = + fixture.getSut( + context, + replayCaptureStrategyProvider = { strategies.removeFirst() }, + ) + + replay.register(fixture.scopes, fixture.options) + replay.onAppForegrounded(true) + replay.pause() + replay.onAppSessionEnded() + replay.onAppForegrounded(true) + + verify(firstStrategy).stop() + verify(secondStrategy).start(any(), any(), anyOrNull()) + verify(secondStrategy).pause() + verify(secondStrategy, never()).resume() + } + @Test fun `captureReplay does nothing when not recording`() { val captureStrategy = mock() diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/WindowRecorderTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/WindowRecorderTest.kt deleted file mode 100644 index 1f38280f4ff..00000000000 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/WindowRecorderTest.kt +++ /dev/null @@ -1,40 +0,0 @@ -package io.sentry.android.replay - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import io.sentry.SentryOptions -import io.sentry.android.replay.util.MainLooperHandler -import java.util.concurrent.ScheduledExecutorService -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.kotlin.any -import org.mockito.kotlin.anyOrNull -import org.mockito.kotlin.mock -import org.mockito.kotlin.never -import org.mockito.kotlin.verify -import org.robolectric.annotation.Config - -@RunWith(AndroidJUnit4::class) -@Config(sdk = [26]) -class WindowRecorderTest { - @Test - fun `configuration does not start capture while paused`() { - val mainLooperHandler = mock() - val recorder = - WindowRecorder( - SentryOptions(), - windowCallback = mock(), - mainLooperHandler = mainLooperHandler, - replayExecutor = mock(), - ) - - recorder.start() - recorder.pause() - recorder.onConfigurationChanged(ScreenshotRecorderConfig(100, 200, 1f, 1f, 1, 20_000)) - - verify(mainLooperHandler, never()).postDelayed(anyOrNull(), any()) - - recorder.resume() - - verify(mainLooperHandler).post(any()) - } -} diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 661a5e3af66..ff75e82d0a2 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -1730,6 +1730,7 @@ public final class io/sentry/NoOpReplayController : io/sentry/ReplayController { public fun isRecording ()Z public fun onAppBackgrounded ()V public fun onAppForegrounded (Z)V + public fun onAppSessionEnded ()V public fun pause ()V public fun registerSegmentName (Ljava/lang/String;)V public fun registerTraceId (Lio/sentry/protocol/SentryId;)V @@ -2384,6 +2385,7 @@ public abstract interface class io/sentry/ReplayController : io/sentry/IReplayAp public abstract fun isRecording ()Z public abstract fun onAppBackgrounded ()V public abstract fun onAppForegrounded (Z)V + public abstract fun onAppSessionEnded ()V public abstract fun registerSegmentName (Ljava/lang/String;)V public abstract fun registerTraceId (Lio/sentry/protocol/SentryId;)V public abstract fun setBreadcrumbConverter (Lio/sentry/ReplayBreadcrumbConverter;)V diff --git a/sentry/src/main/java/io/sentry/NoOpReplayController.java b/sentry/src/main/java/io/sentry/NoOpReplayController.java index 8e010d197c2..f8ae1716157 100644 --- a/sentry/src/main/java/io/sentry/NoOpReplayController.java +++ b/sentry/src/main/java/io/sentry/NoOpReplayController.java @@ -38,6 +38,9 @@ public void onAppForegrounded(boolean startNewReplay) {} @Override public void onAppBackgrounded() {} + @Override + public void onAppSessionEnded() {} + @Override public boolean isRecording() { return false; diff --git a/sentry/src/main/java/io/sentry/ReplayController.java b/sentry/src/main/java/io/sentry/ReplayController.java index 6fa50153b84..a674b68f5e8 100644 --- a/sentry/src/main/java/io/sentry/ReplayController.java +++ b/sentry/src/main/java/io/sentry/ReplayController.java @@ -19,6 +19,9 @@ public interface ReplayController extends IReplayApi { */ void onAppBackgrounded(); + /** Stops replay when the current app session ends without clearing an explicit user pause. */ + void onAppSessionEnded(); + boolean isRecording(); /**