diff --git a/CHANGELOG.md b/CHANGELOG.md
index acca00fc3c..2eca028b85 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,7 @@
### Fixes
- Report the number of dropped spans in client reports when a batched span envelope item is discarded, instead of a quantity of 1 (e.g. for Flutter span streaming) ([#6265](https://github.com/getsentry/sentry-java/pull/6265))
+- Session Replay: Keep the flushed video and do not reuse its segment id when `stop()` is called right after `flush()` in buffer mode ([#6340](https://github.com/getsentry/sentry-java/pull/6340))
- Keep sending events from Android 7.1.0 and older devices after Sentry changes its TLS certificate authority ([#6227](https://github.com/getsentry/sentry-java/pull/6227))
- In February 2027, Sentry moves its TLS certificates from DigiCert to Let's Encrypt and Google Trust Services ([announcement](https://sentry.io/changelog/were-changing-our-tls-certificate-authority-in-february-2027)).
- Android 7.1.0 (API 25) and older don't trust the Let's Encrypt root certificate (ISRG Root X1), so without this fix, the SDK on those devices can no longer send events to Sentry after the switch.
diff --git a/sentry-android-replay/detekt-baseline.xml b/sentry-android-replay/detekt-baseline.xml
index 2adcb41234..03a6d77fef 100644
--- a/sentry-android-replay/detekt-baseline.xml
+++ b/sentry-android-replay/detekt-baseline.xml
@@ -43,6 +43,7 @@
FunctionParameterNaming:PixelCopyStrategyTest.kt$DeferredWindowPixelCopyShadow.Companion$_dest: Bitmap
FunctionParameterNaming:PixelCopyStrategyTest.kt$DeferredWindowPixelCopyShadow.Companion$_source: Window
LargeClass:CanvasStrategy.kt$TextIgnoringDelegateCanvas : Canvas
+ LargeClass:ReplayCacheTest.kt$ReplayCacheTest
LargeClass:ReplayIntegrationTest.kt$ReplayIntegrationTest
LongMethod:CaptureStrategy.kt$CaptureStrategy.Companion$@Suppress("NewApi") private fun buildReplay( options: SentryOptions, video: File, currentReplayId: SentryId, segmentTimestamp: Date, segmentId: Int, height: Int, width: Int, frameCount: Int, frameRate: Int, videoDuration: Long, replayType: ReplayType, screenAtStart: String?, breadcrumbs: List<Breadcrumb>, events: Deque<RRWebEvent>, traceIds: List<String>, segmentNames: List<String>, ): ReplaySegment
LongMethod:ComposeViewHierarchyNode.kt$ComposeViewHierarchyNode$private fun fromComposeNode( node: LayoutNode, parent: ViewHierarchyNode?, distance: Int, isComposeRoot: Boolean, options: SentryMaskingOptions, logger: ILogger, ): ViewHierarchyNode?
diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt
index 21d8f88c7f..64aec8a077 100644
--- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt
+++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt
@@ -139,16 +139,17 @@ public class ReplayCache(private val options: SentryOptions, private val replayI
bitRate: Int,
videoFile: File = File(replayCacheDir, "$segmentId.mp4"),
): GeneratedVideo? {
- if (videoFile.exists() && videoFile.length() > 0) {
- videoFile.delete()
- }
// Work on a snapshot of frames to avoid races with writers
val framesSnapshot =
framesLock.acquire().use { if (frames.isEmpty()) mutableListOf() else frames.toMutableList() }
if (framesSnapshot.isEmpty()) {
+ // keep an existing video intact, e.g. one from a flush whose envelope is still queued
options.logger.log(DEBUG, "No captured frames, skipping generating a video segment")
return null
}
+ if (videoFile.exists() && videoFile.length() > 0) {
+ videoFile.delete()
+ }
encoder =
SimpleVideoEncoder(
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 015e162afc..314c66a90f 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
@@ -340,23 +340,17 @@ public class ReplayIntegration(
return
}
- var activeStrategy: CaptureStrategy = strategy
+ // Convert before capturing, so the callback knows which strategy continues the replay.
+ val activeStrategy = strategy.convert()
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
- }
- }
+ // Runs on the replay executor, which the converted strategy shares. Advancing the segment
+ // here lands before any segment the converted strategy creates later (e.g. on stop), so the
+ // flushed segment id is not reused. A stale strategy (stopped or restarted replay) is not
+ // in the state anymore, so updating it has no effect.
+ activeStrategy.currentSegment++
+ activeStrategy.segmentTimestamp = newTimestamp
+ activeStrategy.isFlushed = true
}
- activeStrategy = strategy.convert()
val replayId: SentryId? = activeStrategy.currentReplayId
state.set(
current.copy(
diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt
index 5607ebd87c..aeca0c868d 100644
--- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt
+++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt
@@ -107,6 +107,18 @@ class ReplayCacheTest {
assertNull(video)
}
+ @Test
+ fun `when no frames are provided, does not delete existing video`() {
+ val replayCache = fixture.getSut(tmpDir)
+ val existingVideo =
+ File(replayCache.replayCacheDir, "0.mp4").also { it.writeBytes(byteArrayOf(1, 2, 3)) }
+
+ val video = replayCache.createVideoOf(5000L, 0, 0, 100, 200, 1, 20_000)
+
+ assertNull(video)
+ assertEquals(3, existingVideo.length())
+ }
+
@Test
fun `releases the muxer when the encoder fails to start`() {
ReplayShadowMediaCodec.throwOnStart = true
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 4f4f7ff3c9..d8bff1f0c3 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
@@ -752,6 +752,49 @@ class ReplayIntegrationTest {
assertThat(replay.replayId).isEqualTo(newReplayId)
}
+ @Test
+ fun `flush followed by immediate stop advances the converted strategy segment`() {
+ val replayId = SentryId()
+ var onSegmentSent: ((Date) -> Unit)? = null
+ val bufferStrategy = mock()
+ whenever(bufferStrategy.currentReplayId).thenReturn(replayId)
+ doAnswer {
+ @Suppress("UNCHECKED_CAST")
+ onSegmentSent = it.arguments[0] as (Date) -> Unit
+ }
+ .whenever(bufferStrategy)
+ .flush(any())
+ val sessionStrategy = mock()
+ whenever(sessionStrategy.currentReplayId).thenReturn(replayId)
+ whenever(sessionStrategy.currentSegment).thenReturn(0)
+ whenever(bufferStrategy.convert()).thenReturn(sessionStrategy)
+ val replay =
+ fixture.getSut(
+ context,
+ sessionSampleRate = 0.0,
+ onErrorSampleRate = 0.0,
+ replayCaptureStrategyProvider = { bufferStrategy },
+ mainLooperHandler = MainLooperHandler(),
+ )
+ replay.register(fixture.scopes, fixture.options)
+ replay.startBuffering()
+ shadowOf(Looper.getMainLooper()).idle()
+
+ replay.flush()
+ replay.stop()
+ shadowOf(Looper.getMainLooper()).idle()
+ verify(sessionStrategy).stop()
+
+ // the flushed segment completes on the replay executor after stop ran on the main thread,
+ // but before the stop segment of the converted strategy is created
+ val timestamp = Date()
+ onSegmentSent!!.invoke(timestamp)
+
+ verify(sessionStrategy).currentSegment = 1
+ verify(sessionStrategy).segmentTimestamp = timestamp
+ verify(sessionStrategy).isFlushed = true
+ }
+
@Test
fun `pause does nothing when not recording`() {
val captureStrategy = mock()