@@ -8,6 +8,7 @@ package com.datadog.android.sessionreplay.internal.composition
88
99import com.datadog.android.api.InternalLogger
1010import com.datadog.android.core.internal.utils.executeSafe
11+ import com.datadog.android.internal.time.TimeProvider
1112import com.datadog.android.sessionreplay.internal.async.DataQueueHandler
1213import com.datadog.android.sessionreplay.internal.processor.EnrichedRecord
1314import com.datadog.android.sessionreplay.internal.storage.RecordWriter
@@ -16,12 +17,20 @@ import com.datadog.android.sessionreplay.internal.utils.SessionReplayRumContext
1617import com.datadog.android.sessionreplay.model.MobileSegment
1718import java.util.concurrent.ConcurrentLinkedQueue
1819import java.util.concurrent.ExecutorService
20+ import java.util.concurrent.TimeUnit
1921import java.util.concurrent.atomic.AtomicBoolean
2022
2123internal fun interface SnapshotCompletionProcessor {
2224 fun process (capture : CompletedSnapshotCapture )
2325}
2426
27+ /* * The last accepted snapshot a generation can be diffed against, and when it was last a full one. */
28+ private data class RetainedSnapshotState (
29+ val snapshot : CapturedFullSnapshot ,
30+ val orientation : Int ,
31+ val lastFullSnapshotAtNs : Long
32+ )
33+
2534/* *
2635 * Bundles composition wire-mapping with the view-lifecycle records the player needs around it -
2736 * mirrors legacy `RecordedDataProcessor`'s new-view handling: every genuinely new RUM view opens
@@ -40,18 +49,29 @@ internal fun interface SnapshotCompletionProcessor {
4049 * calls [DataQueueHandler.tryToConsumeItems] - without a caller, those items sat in memory
4150 * forever, so every composition-tree pixel capture's `resourceId` referenced a resource that had
4251 * never actually been persisted anywhere.
52+ *
53+ * Also diffs each completed generation against the last *accepted* one (retained only inside the
54+ * [CaptureGenerationContext.tryAccept]-gated success branch below, so an expired/rejected
55+ * generation never corrupts it) and emits an incremental mutation via [CapturedSnapshotDiffer]
56+ * where possible, falling back to a full snapshot on a new RUM view, a periodic checkpoint, an
57+ * orientation change, or a mutation that unexpectedly fails validation (self-healing rather than
58+ * dropping the generation) - mirroring legacy `RecordedDataProcessor`'s
59+ * `isNewView`/`isTimeForFullSnapshot`/`screenOrientationChanged` gating for this pipeline's state.
4360 */
4461internal class DefaultSnapshotCompletionProcessor (
4562 private val rumContextProvider : RumContextProvider ,
4663 private val recordWriter : RecordWriter ,
4764 private val internalLogger : InternalLogger ,
65+ private val timeProvider : TimeProvider ,
66+ private val orientationProvider : OrientationProvider = DefaultOrientationProvider (),
4867 private val wireMapper : CapturedTreeWireMapper = DefaultCapturedTreeWireMapper (),
4968 private val resourceDataQueueHandler : DataQueueHandler ? = null
5069) : SnapshotCompletionProcessor {
5170
5271 // Only ever read/written from SnapshotCompletionQueue's single draining thread - same
5372 // single-threaded-processor assumption legacy RecordedDataProcessor's own prevRumContext relies on.
5473 private var lastViewContext: SessionReplayRumContext ? = null
74+ private var retained: RetainedSnapshotState ? = null
5575
5676 override fun process (capture : CompletedSnapshotCapture ) {
5777 // Whatever resources this generation's pixel captures resolved were already queued
@@ -66,7 +86,8 @@ internal class DefaultSnapshotCompletionProcessor(
6686 return
6787 }
6888
69- when (val mapping = wireMapper.mapFullSnapshot(capture.snapshot)) {
89+ val currentOrientation = orientationProvider.currentOrientation()
90+ when (val mapping = resolveMapping(capture.snapshot, currentOrientation)) {
7091 is CaptureWireMappingResult .Success -> {
7192 if (capture.generation.tryAccept()) {
7293 writeViewEndRecordIfViewChanged(rumContext, capture.snapshot.timestamp)
@@ -84,6 +105,11 @@ internal class DefaultSnapshotCompletionProcessor(
84105 records = records
85106 )
86107 )
108+ retained = RetainedSnapshotState (
109+ snapshot = capture.snapshot,
110+ orientation = currentOrientation,
111+ lastFullSnapshotAtNs = lastFullSnapshotAtNs(mapping.value)
112+ )
87113 }
88114 }
89115
@@ -125,6 +151,48 @@ internal class DefaultSnapshotCompletionProcessor(
125151 )
126152 )
127153 }
154+
155+ /* * Only a full snapshot resets the periodic-checkpoint clock; a mutation cycle leaves it running. */
156+ private fun lastFullSnapshotAtNs (record : MobileSegment .MobileRecord ): Long =
157+ if (record is MobileSegment .MobileRecord .MobileFullSnapshotRecord ) {
158+ timeProvider.getDeviceElapsedTimeNanos()
159+ } else {
160+ retained?.lastFullSnapshotAtNs ? : timeProvider.getDeviceElapsedTimeNanos()
161+ }
162+
163+ @Suppress(" ReturnCount" )
164+ private fun resolveMapping (
165+ snapshot : CapturedFullSnapshot ,
166+ currentOrientation : Int
167+ ): CaptureWireMappingResult <MobileSegment .MobileRecord > {
168+ val retainedState = retained ? : return wireMapper.mapFullSnapshot(snapshot)
169+ val fullSnapshotRequired = retainedState.snapshot.scope != snapshot.scope ||
170+ currentOrientation != retainedState.orientation ||
171+ isTimeForFullSnapshot(retainedState)
172+ if (fullSnapshotRequired) return wireMapper.mapFullSnapshot(snapshot)
173+
174+ val mutation = CapturedSnapshotDiffer .diff(retainedState.snapshot, snapshot)
175+ ? : return wireMapper.mapFullSnapshot(snapshot)
176+
177+ return when (val mutationMapping = wireMapper.mapMutation(mutation, retainedState.snapshot)) {
178+ is CaptureWireMappingResult .Success -> mutationMapping
179+ is CaptureWireMappingResult .Invalid -> {
180+ internalLogger.log(
181+ InternalLogger .Level .WARN ,
182+ InternalLogger .Target .TELEMETRY ,
183+ { " Computed mutation failed validation, retrying as a full snapshot: ${mutationMapping.failures} " }
184+ )
185+ wireMapper.mapFullSnapshot(snapshot)
186+ }
187+ }
188+ }
189+
190+ private fun isTimeForFullSnapshot (retainedState : RetainedSnapshotState ): Boolean =
191+ timeProvider.getDeviceElapsedTimeNanos() - retainedState.lastFullSnapshotAtNs >= FULL_SNAPSHOT_INTERVAL_NS
192+
193+ private companion object {
194+ val FULL_SNAPSHOT_INTERVAL_NS = TimeUnit .MILLISECONDS .toNanos(3000 )
195+ }
128196}
129197
130198/* *
0 commit comments