From 3ebf05f32f55b6d1be30797db1c1756395817498 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 18 Mar 2026 15:42:31 +0000 Subject: [PATCH] fix(replay): Allow STARTED -> RESUMED state transition in ReplayLifecycle The ReplayLifecycle state machine did not allow transitioning from STARTED to RESUMED. This caused a bug when the app returned to foreground after being in background longer than sessionIntervalMillis: 1. Timer fires -> stop() -> state becomes STOPPED 2. App returns to foreground -> start() -> state becomes STARTED 3. resume() fails because STARTED -> RESUMED was not allowed The fix adds RESUMED as a valid transition from the STARTED state. Co-authored-by: Giancarlo Buenaflor --- .../sentry/android/replay/ReplayLifecycle.kt | 3 ++- .../android/replay/ReplayIntegrationTest.kt | 27 +++++++++++++++++++ .../android/replay/ReplayLifecycleTest.kt | 2 +- 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayLifecycle.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayLifecycle.kt index 38d0ae8bda8..81bc27d26bf 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayLifecycle.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayLifecycle.kt @@ -46,7 +46,8 @@ internal class ReplayLifecycle { when (currentState) { ReplayState.INITIAL -> newState == ReplayState.STARTED || newState == ReplayState.CLOSED ReplayState.STARTED -> - newState == ReplayState.PAUSED || + newState == ReplayState.RESUMED || + newState == ReplayState.PAUSED || newState == ReplayState.STOPPED || newState == ReplayState.CLOSED ReplayState.RESUMED -> diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt index 7c86a0ad010..70424a2a907 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 @@ -420,6 +420,33 @@ class ReplayIntegrationTest { assertFalse(replay.isRecording) } + @Test + fun `after stop, start and resume restarts replay`() { + val captureStrategy = mock() + val recorder = mock() + val replay = + fixture.getSut( + context, + recorderProvider = { recorder }, + replayCaptureStrategyProvider = { captureStrategy }, + ) + + replay.register(fixture.scopes, fixture.options) + replay.start() + replay.pause() + replay.stop() + + assertFalse(replay.isRecording) + + replay.start() + replay.resume() + + assertTrue(replay.isRecording) + verify(captureStrategy, times(2)).start(any(), any(), anyOrNull()) + verify(captureStrategy).resume() + verify(recorder).resume() + } + @Test fun `close cleans up resources`() { val recorder = mock() diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayLifecycleTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayLifecycleTest.kt index 4b5e45d23d7..2c809ab9dd8 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayLifecycleTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayLifecycleTest.kt @@ -29,11 +29,11 @@ class ReplayLifecycleTest { val lifecycle = ReplayLifecycle() lifecycle.currentState = ReplayState.STARTED + assertTrue(lifecycle.isAllowed(ReplayState.RESUMED)) assertTrue(lifecycle.isAllowed(ReplayState.PAUSED)) assertTrue(lifecycle.isAllowed(ReplayState.STOPPED)) assertTrue(lifecycle.isAllowed(ReplayState.CLOSED)) - assertFalse(lifecycle.isAllowed(ReplayState.RESUMED)) assertFalse(lifecycle.isAllowed(ReplayState.INITIAL)) }