From 35e15c52b94d63f1ac55f57391a16e8bea29cef8 Mon Sep 17 00:00:00 2001 From: Jonathan Moskovich <48201295+jonathanmos@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:25:22 +0300 Subject: [PATCH 1/2] PANA-8612: Add snapshot capture orchestration --- detekt_custom_safe_calls_third_party.yml | 4 + .../SessionReplayConfiguration.kt | 3 + .../internal/DefaultRecorderProvider.kt | 105 ++- .../composition/AndroidCaptureExecution.kt | 82 ++ .../AndroidSnapshotCaptureLifecycle.kt | 82 ++ .../composition/CancellableCaptureWork.kt | 15 + .../composition/CaptureGenerationContext.kt | 127 +++ .../internal/composition/CaptureScheduling.kt | 17 + .../CaptureSkippedFrameNotifier.kt | 24 + .../internal/composition/CaptureTimeBudget.kt | 36 + .../internal/composition/CaptureWorkToken.kt | 63 ++ .../internal/composition/CapturedTree.kt | 29 +- .../composition/CompositionCapturePipeline.kt | 40 +- .../composition/CompositionDrawSignals.kt | 129 +++ .../SnapshotCaptureOrchestrator.kt | 247 ++++++ .../composition/SnapshotCompletionQueue.kt | 158 ++++ .../composition/SnapshotProcessing.kt | 63 ++ .../internal/DefaultRecorderProviderTest.kt | 145 ++++ .../internal/SessionReplayFeatureTest.kt | 5 + .../AndroidCaptureExecutionTest.kt | 79 ++ .../AndroidSnapshotCaptureLifecycleTest.kt | 143 ++++ .../CaptureGenerationContextTest.kt | 80 ++ .../CaptureSkippedFrameNotifierTest.kt | 48 ++ .../CompositionCapturePipelineTest.kt | 26 + .../composition/CompositionChangesetTest.kt | 68 ++ .../SnapshotCaptureOrchestratorTest.kt | 761 ++++++++++++++++++ .../SnapshotCompletionQueueTest.kt | 263 ++++++ .../SnapshotOrchestrationIntegrationTest.kt | 118 +++ 28 files changed, 2946 insertions(+), 14 deletions(-) create mode 100644 features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/AndroidCaptureExecution.kt create mode 100644 features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/AndroidSnapshotCaptureLifecycle.kt create mode 100644 features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CancellableCaptureWork.kt create mode 100644 features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CaptureGenerationContext.kt create mode 100644 features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CaptureScheduling.kt create mode 100644 features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CaptureSkippedFrameNotifier.kt create mode 100644 features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CaptureTimeBudget.kt create mode 100644 features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CaptureWorkToken.kt create mode 100644 features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CompositionDrawSignals.kt create mode 100644 features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/SnapshotCaptureOrchestrator.kt create mode 100644 features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/SnapshotCompletionQueue.kt create mode 100644 features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/SnapshotProcessing.kt create mode 100644 features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/AndroidCaptureExecutionTest.kt create mode 100644 features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/AndroidSnapshotCaptureLifecycleTest.kt create mode 100644 features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/CaptureGenerationContextTest.kt create mode 100644 features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/CaptureSkippedFrameNotifierTest.kt create mode 100644 features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/CompositionChangesetTest.kt create mode 100644 features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/SnapshotCaptureOrchestratorTest.kt create mode 100644 features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/SnapshotCompletionQueueTest.kt create mode 100644 features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/SnapshotOrchestrationIntegrationTest.kt diff --git a/detekt_custom_safe_calls_third_party.yml b/detekt_custom_safe_calls_third_party.yml index f752add51b..dd20847d99 100644 --- a/detekt_custom_safe_calls_third_party.yml +++ b/detekt_custom_safe_calls_third_party.yml @@ -264,7 +264,9 @@ datadog: - "java.util.concurrent.atomic.AtomicReference.compareAndSet(kotlin.String?, kotlin.String?)" - "java.util.concurrent.atomic.AtomicReference.compareAndSet(kotlin.collections.Set?, kotlin.collections.Set?)" - "java.util.concurrent.atomic.AtomicReference.compareAndSet(UNKNOWN, UNKNOWN)" + - "java.util.concurrent.atomic.AtomicReference.compareAndSet(com.datadog.android.sessionreplay.internal.composition.CaptureGenerationContext.State?, com.datadog.android.sessionreplay.internal.composition.CaptureGenerationContext.State?)" - "java.util.concurrent.atomic.AtomicReference.constructor()" + - "java.util.concurrent.atomic.AtomicReference.constructor(com.datadog.android.sessionreplay.internal.composition.CaptureGenerationContext.State?)" - "java.util.concurrent.atomic.AtomicReference.constructor(android.app.Application.ActivityLifecycleCallbacks?)" - "java.util.concurrent.atomic.AtomicReference.constructor(com.datadog.android.api.SdkCore?)" - "java.util.concurrent.atomic.AtomicReference.constructor(com.datadog.android.api.feature.FeatureEventReceiver?)" @@ -510,6 +512,7 @@ datadog: - "kotlin.collections.List.filterIsInstance()" - "kotlin.collections.List.filterNot(kotlin.Function1)" - "kotlin.collections.List.filterNotNull(kotlin.Function1)" + - "kotlin.collections.MutableSet.filterNot(kotlin.Function1)" - "kotlin.collections.List.findFirstForType(java.lang.Class)" - "kotlin.collections.List.firstOrNull()" - "kotlin.collections.List.firstOrNull(kotlin.Function1)" @@ -786,6 +789,7 @@ datadog: - "kotlin.collections.listOf(com.datadog.android.sessionreplay.internal.recorder.DefaultOptionSelectorDetector)" - "kotlin.collections.listOf(com.datadog.android.sessionreplay.material.internal.MaterialDrawableToColorMapper)" - "kotlin.collections.listOf(com.datadog.android.sessionreplay.material.internal.MaterialOptionSelectorDetector)" + - "kotlin.collections.listOf(com.datadog.android.sessionreplay.model.MobileSegment.MobileRecord.MobileFullSnapshotRecord)" - "kotlin.collections.listOf(com.datadog.android.sessionreplay.model.MobileSegment.MobileRecord.ViewEndRecord)" - "kotlin.collections.listOf(com.datadog.android.sessionreplay.model.MobileSegment.Wireframe)" - "kotlin.collections.listOf(com.datadog.android.sessionreplay.model.MobileSegment.Wireframe.PlaceholderWireframe)" diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/SessionReplayConfiguration.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/SessionReplayConfiguration.kt index baf9dde5b9..ac03292cfc 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/SessionReplayConfiguration.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/SessionReplayConfiguration.kt @@ -253,6 +253,9 @@ data class SessionReplayConfiguration internal constructor( * * Disabled by default. * + * Heatmap recording ([setHeatmapsEnabled]) is not supported by this pipeline yet; combining + * the two will log a warning and no heatmap data will be recorded. + * * @param enabled whether composition-tree recording should be used. */ @ExperimentalSessionReplayApi diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/DefaultRecorderProvider.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/DefaultRecorderProvider.kt index 0909da02a0..3098a2bf0f 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/DefaultRecorderProvider.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/DefaultRecorderProvider.kt @@ -21,17 +21,37 @@ import android.widget.SeekBar import android.widget.TextView import androidx.appcompat.widget.ActionBarContainer import androidx.appcompat.widget.SwitchCompat +import com.datadog.android.api.InternalLogger import com.datadog.android.api.feature.FeatureSdkCore import com.datadog.android.internal.utils.ImageViewUtils import com.datadog.android.sessionreplay.ImagePrivacy import com.datadog.android.sessionreplay.MapperTypeWrapper import com.datadog.android.sessionreplay.SessionReplayInternalCallback import com.datadog.android.sessionreplay.TextAndInputPrivacy +import com.datadog.android.sessionreplay.internal.composition.ActiveWindowSource +import com.datadog.android.sessionreplay.internal.composition.AndroidSnapshotCaptureLifecycle import com.datadog.android.sessionreplay.internal.composition.CapturePipelineSelector +import com.datadog.android.sessionreplay.internal.composition.CaptureSkippedFrameNotifier +import com.datadog.android.sessionreplay.internal.composition.CaptureTimeBudget +import com.datadog.android.sessionreplay.internal.composition.CapturedSnapshotProducer import com.datadog.android.sessionreplay.internal.composition.CompositionCapturePipeline +import com.datadog.android.sessionreplay.internal.composition.CompositionChangeListener +import com.datadog.android.sessionreplay.internal.composition.CompositionChangeset +import com.datadog.android.sessionreplay.internal.composition.CompositionViewOnDrawInterceptor +import com.datadog.android.sessionreplay.internal.composition.DefaultSnapshotCompletionProcessor +import com.datadog.android.sessionreplay.internal.composition.HandlerCaptureMainThreadExecutor +import com.datadog.android.sessionreplay.internal.composition.HandlerCaptureTaskScheduler +import com.datadog.android.sessionreplay.internal.composition.ImmediateCapturedSnapshotProcessor +import com.datadog.android.sessionreplay.internal.composition.ScheduledExecutorCaptureTaskScheduler +import com.datadog.android.sessionreplay.internal.composition.SnapshotCaptureOrchestrator +import com.datadog.android.sessionreplay.internal.composition.SnapshotCompletionQueue +import com.datadog.android.sessionreplay.internal.composition.TimeBankCaptureTimeBudget +import com.datadog.android.sessionreplay.internal.composition.TimeProviderCaptureTimeProvider import com.datadog.android.sessionreplay.internal.embedded.EmbeddedContentSlotRegistry import com.datadog.android.sessionreplay.internal.recorder.Recorder +import com.datadog.android.sessionreplay.internal.recorder.RecordingTimeBank import com.datadog.android.sessionreplay.internal.recorder.SessionReplayRecorder +import com.datadog.android.sessionreplay.internal.recorder.TimeBank import com.datadog.android.sessionreplay.internal.recorder.mapper.ActionBarContainerMapper import com.datadog.android.sessionreplay.internal.recorder.mapper.ButtonMapper import com.datadog.android.sessionreplay.internal.recorder.mapper.CheckBoxMapper @@ -72,7 +92,11 @@ internal class DefaultRecorderProvider( private val internalCallback: SessionReplayInternalCallback, private val heatmapsEnabled: Boolean, private val compositionTreeRecordingEnabled: Boolean, - private val compositionPipelineFactory: () -> Recorder = { CompositionCapturePipeline() } + private val compositionPipelineFactory: (() -> Recorder)? = null, + private val compositionSnapshotProducerFactory: (ActiveWindowSource) -> CapturedSnapshotProducer = { + CapturedSnapshotProducer { _, _ -> null } + }, + private val recordingTimeBankFactory: () -> TimeBank = { RecordingTimeBank() } ) : RecorderProvider { override fun provideSessionReplayRecorder( @@ -83,10 +107,23 @@ internal class DefaultRecorderProvider( application: Application, embeddedContentSlotRegistry: EmbeddedContentSlotRegistry ): Recorder { + if (heatmapsEnabled && compositionTreeRecordingEnabled) { + sdkCore.internalLogger.log( + InternalLogger.Level.WARN, + InternalLogger.Target.USER, + { HEATMAPS_UNSUPPORTED_WITH_COMPOSITION_RECORDING_MESSAGE } + ) + } val heatmapIdentifierRegistry = if (heatmapsEnabled) LazyHeatmapIdentifierRegistry(sdkCore) else null return CapturePipelineSelector( compositionEnabled = compositionTreeRecordingEnabled, - compositionFactory = compositionPipelineFactory, + compositionFactory = { + compositionPipelineFactory?.invoke() ?: createCompositionPipeline( + recordWriter, + rumContextProvider, + application + ) + }, legacyFactory = { SessionReplayRecorder( application, @@ -111,6 +148,64 @@ internal class DefaultRecorderProvider( ).create() } + private fun createCompositionPipeline( + recordWriter: RecordWriter, + rumContextProvider: RumContextProvider, + application: Application + ): Recorder { + val internalLogger = sdkCore.internalLogger + val timeProvider = TimeProviderCaptureTimeProvider(sdkCore.timeProvider) + val skippedFrameNotifier = CaptureSkippedFrameNotifier(sdkCore) + val windowSource = ActiveWindowSource() + val completionQueue = SnapshotCompletionQueue( + executorService = sdkCore.createSingleThreadExecutorService("sr-composition-processing"), + processor = DefaultSnapshotCompletionProcessor( + rumContextProvider = rumContextProvider, + recordWriter = recordWriter, + internalLogger = internalLogger + ), + internalLogger = internalLogger + ) + val orchestrator = SnapshotCaptureOrchestrator( + producer = compositionSnapshotProducerFactory(windowSource), + processor = ImmediateCapturedSnapshotProcessor(), + consumer = completionQueue, + timeProvider = timeProvider, + captureScheduler = HandlerCaptureTaskScheduler(), + mainThreadExecutor = HandlerCaptureMainThreadExecutor(), + expiryScheduler = ScheduledExecutorCaptureTaskScheduler( + executorService = sdkCore.createScheduledExecutorService("sr-composition-expiry"), + internalLogger = internalLogger + ), + timeBudget = if (dynamicOptimizationEnabled) { + TimeBankCaptureTimeBudget( + recordingTimeBankFactory(), + skippedFrameNotifier::notifySkippedFrame + ) + } else { + CaptureTimeBudget.UNLIMITED + }, + internalLogger = internalLogger + ) + val interceptor = CompositionViewOnDrawInterceptor( + windowSource = windowSource, + onWindowsChanged = CompositionChangeListener { windows -> + orchestrator.requestCapture(CompositionChangeset.of(windows)) + }, + internalLogger = internalLogger + ) + return CompositionCapturePipeline( + orchestrator = orchestrator, + lifecycle = AndroidSnapshotCaptureLifecycle( + application = application, + interceptor = interceptor, + internalLogger = internalLogger, + currentActivity = internalCallback.getCurrentActivity() + ), + completionQueue = completionQueue + ) + } + @Suppress("LongMethod") private fun builtInMappers(): List> { val viewIdentifierResolver: ViewIdentifierResolver = DefaultViewIdentifierResolver @@ -268,4 +363,10 @@ internal class DefaultRecorderProvider( null } } + + internal companion object { + internal const val HEATMAPS_UNSUPPORTED_WITH_COMPOSITION_RECORDING_MESSAGE = "Heatmaps are not " + + "supported by the composition-tree recording pipeline yet. No heatmap data will be recorded " + + "for this session." + } } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/AndroidCaptureExecution.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/AndroidCaptureExecution.kt new file mode 100644 index 0000000000..f6957e3e85 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/AndroidCaptureExecution.kt @@ -0,0 +1,82 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.composition + +import android.os.Handler +import android.os.Looper +import com.datadog.android.api.InternalLogger +import com.datadog.android.core.internal.utils.scheduleSafe +import com.datadog.android.internal.time.TimeProvider +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.TimeUnit + +internal class HandlerCaptureMainThreadExecutor( + private val handler: Handler = Handler(Looper.getMainLooper()) +) : CaptureMainThreadExecutor { + override fun execute(task: () -> Unit): CancellableCaptureWork { + val runnable = Runnable(task) + return if (handler.post(runnable)) { + CancellableCaptureWork { handler.removeCallbacks(runnable) } + } else { + CancellableCaptureWork.NONE + } + } +} + +internal class HandlerCaptureTaskScheduler( + private val handler: Handler = Handler(Looper.getMainLooper()) +) : CaptureTaskScheduler { + override fun schedule(delayNs: Long, task: () -> Unit): CancellableCaptureWork { + val runnable = Runnable(task) + val posted = if (delayNs <= 0) { + handler.post(runnable) + } else { + handler.postDelayed(runnable, delayNs.toDelayMillis()) + } + return if (posted) { + CancellableCaptureWork { handler.removeCallbacks(runnable) } + } else { + CancellableCaptureWork.NONE + } + } + + private fun Long.toDelayMillis(): Long { + val delayMs = TimeUnit.NANOSECONDS.toMillis(this) + return if (delayMs < 1L) 1L else delayMs + } +} + +/** Expiry runs off the main looper so asynchronous generations can time out while UI is busy. */ +internal class ScheduledExecutorCaptureTaskScheduler( + private val executorService: ScheduledExecutorService, + private val internalLogger: InternalLogger +) : CaptureTaskScheduler { + override fun schedule(delayNs: Long, task: () -> Unit): CancellableCaptureWork { + val future = executorService.scheduleSafe( + EXPIRY_CONTEXT, + delayNs, + TimeUnit.NANOSECONDS, + internalLogger, + Runnable(task) + ) ?: return CancellableCaptureWork.NONE + return CancellableCaptureWork { future.cancel(false) } + } + + override fun shutdown() { + executorService.shutdownNow() + } + + private companion object { + const val EXPIRY_CONTEXT = "Session Replay composition generation expiry" + } +} + +internal class TimeProviderCaptureTimeProvider( + private val timeProvider: TimeProvider +) : CaptureTimeProvider { + override fun elapsedRealtimeNanos(): Long = timeProvider.getDeviceElapsedTimeNanos() +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/AndroidSnapshotCaptureLifecycle.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/AndroidSnapshotCaptureLifecycle.kt new file mode 100644 index 0000000000..bdcd49ce82 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/AndroidSnapshotCaptureLifecycle.kt @@ -0,0 +1,82 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.composition + +import android.app.Activity +import android.app.Application +import android.os.Handler +import android.os.Looper +import android.view.View +import android.view.Window +import androidx.annotation.MainThread +import com.datadog.android.api.InternalLogger +import com.datadog.android.sessionreplay.internal.SessionReplayLifecycleCallback +import com.datadog.android.sessionreplay.internal.recorder.WindowInspector +import com.datadog.android.sessionreplay.internal.recorder.callback.OnWindowRefreshedCallback + +internal interface CompositionCaptureLifecycle { + fun registerCallbacks() + fun unregisterCallbacks() + fun start() + fun stop() +} + +internal class AndroidSnapshotCaptureLifecycle( + private val application: Application, + private val interceptor: CompositionViewOnDrawInterceptor, + private val internalLogger: InternalLogger, + currentActivity: Activity? = null, + private val uiHandler: Handler = Handler(Looper.getMainLooper()), + private val windowProvider: () -> List = { + WindowInspector.getGlobalWindowViews(internalLogger) + } +) : CompositionCaptureLifecycle, OnWindowRefreshedCallback { + private val lifecycleCallback = SessionReplayLifecycleCallback(this) + private var isRunning = false + + init { + currentActivity?.let { + lifecycleCallback.setCurrentWindow(it) + lifecycleCallback.registerFragmentLifecycleCallbacks(it) + } + } + + override fun registerCallbacks() { + application.registerActivityLifecycleCallbacks(lifecycleCallback) + } + + override fun unregisterCallbacks() { + application.unregisterActivityLifecycleCallbacks(lifecycleCallback) + } + + @Suppress("ThreadSafety") // Handler posts this block onto the main looper. + override fun start() { + uiHandler.post { + isRunning = true + interceptor.intercept(windowProvider()) + } + } + + @Suppress("ThreadSafety") // Handler posts this block onto the main looper. + override fun stop() { + uiHandler.post { + isRunning = false + interceptor.stop() + } + } + + @MainThread + override fun onWindowsAdded(windows: List) = refreshWindows() + + @MainThread + override fun onWindowsRemoved(windows: List) = refreshWindows() + + @MainThread + private fun refreshWindows() { + if (isRunning) interceptor.intercept(windowProvider()) + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CancellableCaptureWork.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CancellableCaptureWork.kt new file mode 100644 index 0000000000..4e310e4e43 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CancellableCaptureWork.kt @@ -0,0 +1,15 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.composition + +internal fun interface CancellableCaptureWork { + fun cancel() + + companion object { + val NONE = CancellableCaptureWork {} + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CaptureGenerationContext.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CaptureGenerationContext.kt new file mode 100644 index 0000000000..e275ac4eb2 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CaptureGenerationContext.kt @@ -0,0 +1,127 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.composition + +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference + +/** + * The single deadline and cancellation scope shared by every phase of a snapshot generation. + * The deadline is absolute and monotonic; querying it never extends the generation lifetime. + */ +internal class CaptureGenerationContext( + val id: Long, + val startedAtNs: Long, + val deadlineNs: Long, + private val timeProvider: CaptureTimeProvider, + private val mainThreadTimeBudget: CaptureTimeBudget = CaptureTimeBudget.UNLIMITED, + private val workRegistry: GenerationWorkRegistry = GenerationWorkRegistry() +) { + private val state = AtomicReference(State.ACTIVE) + private val mainThreadWorkAllowed = AtomicBoolean(true) + + fun remainingBudgetNs(): Long { + val remaining = deadlineNs - timeProvider.elapsedRealtimeNanos() + if (remaining <= 0L) { + expire() + return 0L + } + return remaining + } + + fun isActive(): Boolean { + if (timeProvider.elapsedRealtimeNanos() >= deadlineNs) expire() + return state.get() == State.ACTIVE + } + + /** Cheap cooperative checkpoint for View/Compose walkers between bounded operations. */ + fun shouldContinue(): Boolean = mainThreadWorkAllowed.get() && isActive() + + /** + * Runs one bounded synchronous capture unit. The caller must invoke this on the main thread. + * Deadline/cancellation are checked on both sides of the adapter call, and only the time spent + * actively executing [block] is charged to the recording time bank. + */ + fun runMainThreadCaptureUnit( + admissionAlreadyGranted: Boolean = false, + block: () -> T + ): MainThreadCaptureResult { + val startedAtNs = timeProvider.elapsedRealtimeNanos() + val wasActive = shouldContinue() + val admitted = wasActive && + (admissionAlreadyGranted || mainThreadTimeBudget.canStart(startedAtNs)) + if (!admitted) { + if (wasActive) mainThreadWorkAllowed.set(false) + return MainThreadCaptureResult.Interrupted + } + val value = try { + block() + } finally { + mainThreadTimeBudget.consume(timeProvider.elapsedRealtimeNanos() - startedAtNs) + } + return if (shouldContinue()) { + MainThreadCaptureResult.Completed(value) + } else { + MainThreadCaptureResult.Interrupted + } + } + + fun createWorkToken(): CaptureWorkToken? { + if (!isActive()) return null + val token = workRegistry.createToken(this) + return token.takeIf { isActive() } ?: run { + token.invalidate() + workRegistry.release(token) + null + } + } + + internal fun track(work: CancellableCaptureWork) { + if (!isActive()) { + work.cancel() + return + } + workRegistry.track(work) + // A concurrent expire()/tryAccept() may run its own invalidateAll() between the isActive() + // check above and this registration, missing `work` entirely. Cancel unconditionally here + // rather than only when release() reports it was still present: every CancellableCaptureWork + // in this codebase tolerates a redundant cancel, so this is safe even if invalidateAll() + // already cancelled the same work. + if (!isActive()) { + workRegistry.release(work) + work.cancel() + } + } + + internal fun expire(): Boolean = transitionTo(State.EXPIRED) + + /** Atomically marks this generation accepted, but only strictly before its deadline. */ + internal fun tryAccept(): Boolean { + val isBeforeDeadline = timeProvider.elapsedRealtimeNanos() < deadlineNs + if (!isBeforeDeadline) expire() + val accepted = isBeforeDeadline && state.compareAndSet(State.ACTIVE, State.ACCEPTED) + if (accepted) workRegistry.invalidateAll() + return accepted + } + + internal fun release(work: CancellableCaptureWork) { + workRegistry.release(work) + } + + private fun transitionTo(newState: State): Boolean { + if (!state.compareAndSet(State.ACTIVE, newState)) return false + workRegistry.invalidateAll() + return true + } + + private enum class State { ACTIVE, EXPIRED, ACCEPTED } +} + +internal sealed interface MainThreadCaptureResult { + data class Completed(val value: T) : MainThreadCaptureResult + object Interrupted : MainThreadCaptureResult +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CaptureScheduling.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CaptureScheduling.kt new file mode 100644 index 0000000000..b8e65dadbc --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CaptureScheduling.kt @@ -0,0 +1,17 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.composition + +internal fun interface CaptureMainThreadExecutor { + fun execute(task: () -> Unit): CancellableCaptureWork +} + +internal fun interface CaptureTaskScheduler { + fun schedule(delayNs: Long, task: () -> Unit): CancellableCaptureWork + + fun shutdown() = Unit +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CaptureSkippedFrameNotifier.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CaptureSkippedFrameNotifier.kt new file mode 100644 index 0000000000..c6669b5881 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CaptureSkippedFrameNotifier.kt @@ -0,0 +1,24 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.composition + +import com.datadog.android.api.feature.Feature +import com.datadog.android.api.feature.FeatureSdkCore + +internal class CaptureSkippedFrameNotifier( + private val sdkCore: FeatureSdkCore +) { + fun notifySkippedFrame() { + sdkCore.getFeature(Feature.RUM_FEATURE_NAME)?.sendEvent(SKIPPED_FRAME_EVENT) + } + + private companion object { + const val TYPE_KEY = "type" + const val TYPE_VALUE = "sr_skipped_frame" + val SKIPPED_FRAME_EVENT = mapOf(TYPE_KEY to TYPE_VALUE) + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CaptureTimeBudget.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CaptureTimeBudget.kt new file mode 100644 index 0000000000..7a51283ea9 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CaptureTimeBudget.kt @@ -0,0 +1,36 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.composition + +import com.datadog.android.sessionreplay.internal.recorder.TimeBank + +internal fun interface CaptureTimeProvider { + fun elapsedRealtimeNanos(): Long +} + +internal interface CaptureTimeBudget { + fun canStart(timestampNs: Long): Boolean + fun consume(durationNs: Long) + + companion object { + val UNLIMITED = object : CaptureTimeBudget { + override fun canStart(timestampNs: Long): Boolean = true + override fun consume(durationNs: Long) = Unit + } + } +} + +internal class TimeBankCaptureTimeBudget( + private val timeBank: TimeBank, + private val onAdmissionDenied: () -> Unit = {} +) : CaptureTimeBudget { + override fun canStart(timestampNs: Long): Boolean = timeBank.updateAndCheck(timestampNs).also { admitted -> + if (!admitted) onAdmissionDenied() + } + + override fun consume(durationNs: Long) = timeBank.consume(durationNs) +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CaptureWorkToken.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CaptureWorkToken.kt new file mode 100644 index 0000000000..eea3808ca0 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CaptureWorkToken.kt @@ -0,0 +1,63 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.composition + +import java.util.Collections +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean + +/** Callback identity allocated from, and invalidated with, one generation scope. */ +internal class CaptureWorkToken internal constructor( + private val context: CaptureGenerationContext, + private val registry: GenerationWorkRegistry +) { + private val valid = AtomicBoolean(true) + + fun isValid(): Boolean = valid.get() && context.isActive() + + fun complete(): Boolean { + if (!valid.compareAndSet(true, false)) return false + registry.release(this) + return context.isActive() + } + + internal fun invalidate() { + valid.set(false) + } +} + +internal class GenerationWorkRegistry { + // Collections.newSetFromMap throws IllegalArgumentException only for a non-empty map; + // these maps are freshly constructed and always empty. + @Suppress("UnsafeThirdPartyFunctionCall") + private val workTokens = Collections.newSetFromMap(ConcurrentHashMap()) + + @Suppress("UnsafeThirdPartyFunctionCall") + private val trackedWork = Collections.newSetFromMap(ConcurrentHashMap()) + + fun createToken(context: CaptureGenerationContext): CaptureWorkToken = + CaptureWorkToken(context, this).also(workTokens::add) + + fun track(work: CancellableCaptureWork) { + trackedWork += work + } + + // ConcurrentHashMap.remove throws NPE only for a null key; `work` is a non-null Kotlin type. + @Suppress("UnsafeThirdPartyFunctionCall") + fun release(work: CancellableCaptureWork): Boolean = trackedWork.remove(work) + + fun release(token: CaptureWorkToken) { + workTokens -= token + } + + fun invalidateAll() { + workTokens.forEach(CaptureWorkToken::invalidate) + workTokens.clear() + trackedWork.forEach(CancellableCaptureWork::cancel) + trackedWork.clear() + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CapturedTree.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CapturedTree.kt index a0df331812..3858886a3a 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CapturedTree.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CapturedTree.kt @@ -6,8 +6,35 @@ package com.datadog.android.sessionreplay.internal.composition +/** + * Discovers roots and synchronously inspects Android/Compose state on the main thread. Concrete + * walkers must use [CaptureGenerationContext.shouldContinue] between bounded operations and may + * return a contract-provided placeholder or cached resource before the deadline. [changeset] + * identifies what triggered this generation so a walker may skip untouched subtrees; an empty + * changeset means the trigger carried no such information and everything should be considered + * changed. + */ internal fun interface CapturedSnapshotProducer { - fun capture(): CapturedFullSnapshot? + fun capture(context: CaptureGenerationContext, changeset: CaptureChangeset): CapturedFullSnapshot? +} + +/** + * What changed since the previous generation drained this changeset. Implementations merge with + * [mergedWith] so signals arriving while a generation is active, or while a scheduled capture is + * denied admission, accumulate instead of being dropped. + */ +internal interface CaptureChangeset { + fun isEmpty(): Boolean + fun mergedWith(other: CaptureChangeset): CaptureChangeset + + companion object { + val EMPTY: CaptureChangeset = EmptyCaptureChangeset + } +} + +private object EmptyCaptureChangeset : CaptureChangeset { + override fun isEmpty(): Boolean = true + override fun mergedWith(other: CaptureChangeset): CaptureChangeset = other } internal data class CapturedBounds( diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CompositionCapturePipeline.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CompositionCapturePipeline.kt index fb204d48bc..77ff158bc8 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CompositionCapturePipeline.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CompositionCapturePipeline.kt @@ -17,19 +17,37 @@ internal class CapturePipelineSelector( } /** - * Lifecycle entry point for the composition capture pipeline. Traversal and capture are added by - * later workstreams; keeping this recorder isolated prevents legacy state from crossing pipelines. + * Lifecycle entry point for the composition capture pipeline. Platform callback wiring and + * traversal are added by later workstreams; keeping this recorder and its orchestration isolated + * prevents legacy state from crossing pipelines. */ -internal class CompositionCapturePipeline : Recorder { - override fun registerCallbacks() = Unit - - override fun unregisterCallbacks() = Unit - - override fun stopProcessingRecords() = Unit - - override fun resumeRecorders() = Unit +internal class CompositionCapturePipeline( + private val orchestrator: SnapshotCaptureOrchestrator, + private val lifecycle: CompositionCaptureLifecycle, + private val completionQueue: SnapshotCompletionQueue +) : Recorder { + override fun registerCallbacks() { + lifecycle.registerCallbacks() + } + + override fun unregisterCallbacks() { + lifecycle.unregisterCallbacks() + } + + override fun stopProcessingRecords() { + orchestrator.shutdown() + completionQueue.stop() + } + + override fun resumeRecorders() { + orchestrator.start() + lifecycle.start() + } override fun requestCapture(slotIds: Set) = Unit - override fun stopRecorders() = Unit + override fun stopRecorders() { + lifecycle.stop() + orchestrator.stop() + } } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CompositionDrawSignals.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CompositionDrawSignals.kt new file mode 100644 index 0000000000..f7ed7e07db --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CompositionDrawSignals.kt @@ -0,0 +1,129 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.composition + +import android.view.View +import android.view.ViewTreeObserver +import androidx.annotation.MainThread +import com.datadog.android.api.InternalLogger +import java.lang.ref.WeakReference +import java.util.WeakHashMap + +/** Thread-confined source read by the traversal implementation when a generation starts. */ +internal class ActiveWindowSource { + private var windows: List> = emptyList() + + @MainThread + fun update(views: List) { + windows = views.map(::WeakReference) + } + + @MainThread + fun currentWindows(): List = windows.mapNotNull(WeakReference::get) + + @MainThread + fun clear() { + windows = emptyList() + } +} + +/** Reports which decor views drew or were (re)discovered; owns no traversal, enrichment, queue, or generation state. */ +internal fun interface CompositionChangeListener { + fun onWindowsChanged(windows: List) +} + +/** A [CaptureChangeset] of decor views observed to have drawn since it was last drained. */ +internal class CompositionChangeset private constructor( + private val windows: Set +) : CaptureChangeset { + fun changedWindows(): List = windows.toList() + + override fun isEmpty(): Boolean = windows.isEmpty() + + // Empty means "no information, treat everything as changed" (see CapturedSnapshotProducer), so + // it must dominate the merge rather than act as an identity value: merging a known window set + // with an unknown/full invalidation must stay a full invalidation in either direction. + override fun mergedWith(other: CaptureChangeset): CaptureChangeset = when { + isEmpty() || other.isEmpty() -> EMPTY + other is CompositionChangeset -> CompositionChangeset(windows + other.windows) + else -> other + } + + companion object { + val EMPTY: CompositionChangeset = CompositionChangeset(emptySet()) + + fun of(windows: List): CompositionChangeset = + if (windows.isEmpty()) EMPTY else CompositionChangeset(windows.toSet()) + } +} + +/** Identifies the decor view it was registered on, since [ViewTreeObserver.OnDrawListener.onDraw] carries no source. */ +internal class CompositionOnDrawListener( + private val view: View, + private val onWindowsChanged: CompositionChangeListener +) : ViewTreeObserver.OnDrawListener { + // listOf throws only for a null element; `view` is a non-null Kotlin type. + @Suppress("UnsafeThirdPartyFunctionCall") + @MainThread + override fun onDraw() = onWindowsChanged.onWindowsChanged(listOf(view)) +} + +internal class CompositionViewOnDrawInterceptor( + private val windowSource: ActiveWindowSource, + private val onWindowsChanged: CompositionChangeListener, + private val internalLogger: InternalLogger +) { + private val interceptedViews = WeakHashMap() + + @MainThread + fun intercept(decorViews: List) { + val removed = interceptedViews.keys.filterNot(decorViews::contains) + removed.forEach(::removeListener) + decorViews.filterNot(interceptedViews::containsKey).forEach(::addListener) + windowSource.update(decorViews) + onWindowsChanged.onWindowsChanged(decorViews) + } + + @MainThread + fun stop() { + interceptedViews.keys.toList().forEach(::removeListener) + interceptedViews.clear() + windowSource.clear() + } + + private fun addListener(view: View) { + val observer = view.viewTreeObserver + if (!observer.isAlive) return + try { + val listener = CompositionOnDrawListener(view, onWindowsChanged) + observer.addOnDrawListener(listener) + interceptedViews[view] = listener + } catch (e: IllegalStateException) { + logListenerFailure("add", e) + } + } + + private fun removeListener(view: View) { + val listener = interceptedViews.remove(view) ?: return + val observer = view.viewTreeObserver + if (!observer.isAlive) return + try { + observer.removeOnDrawListener(listener) + } catch (e: IllegalStateException) { + logListenerFailure("remove", e) + } + } + + private fun logListenerFailure(operation: String, error: IllegalStateException) { + internalLogger.log( + InternalLogger.Level.WARN, + InternalLogger.Target.TELEMETRY, + { "Unable to $operation composition onDrawListener on viewTreeObserver" }, + error + ) + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/SnapshotCaptureOrchestrator.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/SnapshotCaptureOrchestrator.kt new file mode 100644 index 0000000000..942662959b --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/SnapshotCaptureOrchestrator.kt @@ -0,0 +1,247 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.composition + +import com.datadog.android.api.InternalLogger +import java.util.concurrent.TimeUnit + +/** + * Serializes snapshot generations and owns their complete lifetime: traversal, asynchronous + * processing, expiry, and handoff. A draw signal only calls [requestCapture]; it owns no capture + * state. At most one generation is active, while additional signals coalesce into one follow-up. + */ +internal class SnapshotCaptureOrchestrator( + private val producer: CapturedSnapshotProducer, + private val processor: CapturedSnapshotProcessor, + private val consumer: CompletedSnapshotConsumer, + private val timeProvider: CaptureTimeProvider, + private val captureScheduler: CaptureTaskScheduler, + private val mainThreadExecutor: CaptureMainThreadExecutor, + private val expiryScheduler: CaptureTaskScheduler, + private val timeBudget: CaptureTimeBudget = CaptureTimeBudget.UNLIMITED, + private val captureDelayNs: Long = DEFAULT_CAPTURE_DELAY_NS, + private val generationBudgetNs: Long = DEFAULT_GENERATION_BUDGET_NS, + private val internalLogger: InternalLogger = InternalLogger.UNBOUND +) { + private val lock = Any() + private var isRunning = false + private var captureRequested = false + private var captureScheduled = false + private var captureScheduleId = 0L + private var nextGenerationId = 1L + private var activeGeneration: ActiveGeneration? = null + private var pendingChangeset: CaptureChangeset = CaptureChangeset.EMPTY + + fun start() { + synchronized(lock) { isRunning = true } + } + + fun stop() { + val workToCancel = synchronized(lock) { + isRunning = false + captureRequested = false + captureScheduled = false + captureScheduleId++ + pendingChangeset = CaptureChangeset.EMPTY + activeGeneration?.also { activeGeneration = null } + } + workToCancel?.cancel() + } + + fun shutdown() { + stop() + captureScheduler.shutdown() + if (expiryScheduler !== captureScheduler) expiryScheduler.shutdown() + } + + fun requestCapture(changeset: CaptureChangeset = CaptureChangeset.EMPTY) { + val shouldSchedule = synchronized(lock) { + if (!isRunning) return + captureRequested = true + pendingChangeset = pendingChangeset.mergedWith(changeset) + activeGeneration == null && !captureScheduled + } + if (shouldSchedule) scheduleCapture() + } + + private fun scheduleCapture() { + val scheduleId = synchronized(lock) { + if (!canScheduleCapture) return + captureScheduled = true + ++captureScheduleId + } + captureScheduler.schedule(captureDelayNs) { + mainThreadExecutor.execute { beginCapture(scheduleId) } + } + } + + private fun beginCapture(scheduleId: Long) { + val active = createActiveGeneration(scheduleId) ?: return + + val expiration = expiryScheduler.schedule(active.generation.remainingBudgetNs()) { + expire(active.generation) + } + val shouldCancelExpiration = synchronized(lock) { + val current = activeGeneration + if (current?.generation?.id == active.generation.id) { + current.expiration = expiration + active.generation.track(expiration) + false + } else { + true + } + } + if (shouldCancelExpiration) { + expiration.cancel() + return + } + + val captureResult = active.generation.runMainThreadCaptureUnit(admissionAlreadyGranted = true) { + safeCapture(producer, internalLogger, active.generation, active.changeset) + } + val snapshot = when (captureResult) { + is MainThreadCaptureResult.Completed -> captureResult.value + MainThreadCaptureResult.Interrupted -> null + } + if (snapshot != null && active.generation.isActive()) { + val processing = processor.process( + SnapshotProcessingRequest(active.generation, snapshot), + SnapshotProcessingCallback(::onProcessed) + ) + val shouldCancel = synchronized(lock) { + val current = activeGeneration + if (current?.generation?.id == active.generation.id) { + current.processing = processing + active.generation.track(processing) + false + } else { + true + } + } + if (shouldCancel) processing.cancel() + } else { + expire(active.generation) + } + } + + private fun createActiveGeneration(scheduleId: Long): ActiveGeneration? = synchronized(lock) { + if (captureScheduleId != scheduleId) return@synchronized null + captureScheduled = false + if (!canScheduleCapture) return@synchronized null + + captureRequested = false + val eligibilityTimestampNs = timeProvider.elapsedRealtimeNanos() + if (!timeBudget.canStart(eligibilityTimestampNs)) return@synchronized null + // Only drain the accumulated changeset once a generation is actually admitted; an + // earlier denial here must leave it intact so the next successful generation still sees + // everything that changed since the last one it actually processed. + val changeset = pendingChangeset + pendingChangeset = CaptureChangeset.EMPTY + // Nothing that belongs to capture runs before this timestamp. The producer's first action + // is window/root discovery, so every capture phase shares the deadline created here. + val startedAtNs = timeProvider.elapsedRealtimeNanos() + ActiveGeneration( + CaptureGenerationContext( + id = nextGenerationId++, + startedAtNs = startedAtNs, + deadlineNs = saturatedAdd(startedAtNs, generationBudgetNs), + timeProvider = timeProvider, + mainThreadTimeBudget = timeBudget + ), + changeset = changeset + ).also { activeGeneration = it } + } + + private fun onProcessed(result: SnapshotProcessingResult) { + var expired: ActiveGeneration? = null + val completed = synchronized(lock) { + val active = activeGeneration + if (!isRunning || active?.generation?.id != result.generationId) return + if (!active.generation.isActive()) { + activeGeneration = null + expired = active + return@synchronized null + } + + activeGeneration = null + active.generation.release(active.processing) + if (result is SnapshotProcessingResult.Completed) { + CompletedSnapshotCapture(active.generation, result.snapshot) + } else { + active.generation.expire() + null + } + } + + expired?.cancel() + completed?.takeIf { it.generation.isActive() }?.let(consumer::consume) + scheduleCaptureIfRequested() + } + + private fun expire(generation: CaptureGenerationContext) { + generation.expire() + val expired = synchronized(lock) { + val active = activeGeneration + if (active?.generation !== generation) return + activeGeneration = null + active + } + expired.cancel() + scheduleCaptureIfRequested() + } + + private fun scheduleCaptureIfRequested() { + val shouldSchedule = synchronized(lock) { canScheduleCapture } + if (shouldSchedule) scheduleCapture() + } + + private val canScheduleCapture: Boolean + get() = when { + !isRunning -> false + activeGeneration != null -> false + captureScheduled -> false + else -> captureRequested + } + + private class ActiveGeneration( + val generation: CaptureGenerationContext, + val changeset: CaptureChangeset, + var expiration: CancellableCaptureWork = CancellableCaptureWork.NONE, + var processing: CancellableCaptureWork = CancellableCaptureWork.NONE + ) { + fun cancel() { + generation.expire() + } + } + + private companion object { + val DEFAULT_CAPTURE_DELAY_NS: Long = TimeUnit.MILLISECONDS.toNanos(64) + val DEFAULT_GENERATION_BUDGET_NS: Long = TimeUnit.MILLISECONDS.toNanos(90) + + fun saturatedAdd(value: Long, increment: Long): Long = + if (increment > 0 && value > Long.MAX_VALUE - increment) Long.MAX_VALUE else value + increment + } +} + +/** The producer walks live application/Compose state and can throw for reasons outside our control. */ +@Suppress("TooGenericExceptionCaught") +private fun safeCapture( + producer: CapturedSnapshotProducer, + internalLogger: InternalLogger, + generation: CaptureGenerationContext, + changeset: CaptureChangeset +): CapturedFullSnapshot? = try { + producer.capture(generation, changeset) +} catch (e: Exception) { + internalLogger.log( + InternalLogger.Level.ERROR, + InternalLogger.Target.TELEMETRY, + { "Composition snapshot producer threw an exception" }, + e + ) + null +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/SnapshotCompletionQueue.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/SnapshotCompletionQueue.kt new file mode 100644 index 0000000000..7685e4c36d --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/SnapshotCompletionQueue.kt @@ -0,0 +1,158 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.composition + +import com.datadog.android.api.InternalLogger +import com.datadog.android.core.internal.utils.executeSafe +import com.datadog.android.sessionreplay.internal.processor.EnrichedRecord +import com.datadog.android.sessionreplay.internal.storage.RecordWriter +import com.datadog.android.sessionreplay.internal.utils.RumContextProvider +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.ExecutorService +import java.util.concurrent.atomic.AtomicBoolean + +internal fun interface SnapshotCompletionProcessor { + fun process(capture: CompletedSnapshotCapture) +} + +internal class DefaultSnapshotCompletionProcessor( + private val rumContextProvider: RumContextProvider, + private val recordWriter: RecordWriter, + private val internalLogger: InternalLogger, + private val wireMapper: CapturedTreeWireMapper = DefaultCapturedTreeWireMapper() +) : SnapshotCompletionProcessor { + override fun process(capture: CompletedSnapshotCapture) { + val rumContext = rumContextProvider.getRumContext() + if (!rumContext.isValid() || rumContext.viewId != capture.snapshot.scope.value) { + capture.generation.expire() + return + } + + when (val mapping = wireMapper.mapFullSnapshot(capture.snapshot)) { + is CaptureWireMappingResult.Success -> { + if (capture.generation.tryAccept()) { + recordWriter.write( + EnrichedRecord( + applicationId = rumContext.applicationId, + sessionId = rumContext.sessionId, + viewId = rumContext.viewId, + records = listOf(mapping.value) + ) + ) + } + } + + is CaptureWireMappingResult.Invalid -> { + capture.generation.expire() + internalLogger.log( + InternalLogger.Level.WARN, + InternalLogger.Target.TELEMETRY, + { "Dropping invalid completed composition snapshot: ${mapping.failures}" } + ) + } + } + } +} + +/** + * The sole downstream handoff for composition snapshots. It accepts only completed generations, + * preserves their serial order, and uses the generation deadline instead of creating a second + * queue-specific expiry lifecycle. + */ +internal class SnapshotCompletionQueue( + private val executorService: ExecutorService, + private val processor: SnapshotCompletionProcessor, + private val internalLogger: InternalLogger +) : CompletedSnapshotConsumer { + private val lifecycleLock = Any() + private val stopped = AtomicBoolean(false) + private val drainScheduled = AtomicBoolean(false) + private val captures = ConcurrentLinkedQueue() + private var processingCapture: CompletedSnapshotCapture? = null + + // ConcurrentLinkedQueue.offer throws NPE only for a null element; `capture` is a non-null Kotlin type. + @Suppress("UnsafeThirdPartyFunctionCall") + override fun consume(capture: CompletedSnapshotCapture) { + val accepted = synchronized(lifecycleLock) { + if (stopped.get() || !capture.generation.isActive()) return@synchronized false + captures.offer(capture) + } + if (!accepted) return + scheduleDrain() + } + + fun stop() { + val didStop = synchronized(lifecycleLock) { + if (!stopped.compareAndSet(false, true)) return@synchronized false + processingCapture?.generation?.expire() + while (true) { + val capture = captures.poll() ?: break + capture.generation.expire() + } + true + } + if (didStop) { + executorService.shutdownNow() + } + } + + private fun scheduleDrain() { + if (!stopped.get() && drainScheduled.compareAndSet(false, true)) { + if (stopped.get()) { + drainScheduled.set(false) + } else { + executorService.executeSafe(EXECUTION_CONTEXT, internalLogger, ::drain) + } + } + } + + private fun drain() { + try { + while (true) { + val capture = nextCapture() ?: break + processCapture(capture) + } + } finally { + drainScheduled.set(false) + if (!stopped.get() && captures.isNotEmpty()) scheduleDrain() + } + } + + private fun nextCapture(): CompletedSnapshotCapture? = synchronized(lifecycleLock) { + if (stopped.get()) return@synchronized null + captures.poll()?.also { processingCapture = it } + } + + // The processor is a pluggable seam (wire mapping, RUM context lookup, disk write via + // RecordWriter) and can throw for reasons outside our control; the queue must keep draining + // subsequent captures regardless. + @Suppress("TooGenericExceptionCaught") + private fun processCapture(capture: CompletedSnapshotCapture) { + try { + if (capture.generation.isActive()) processor.process(capture) + } catch (e: Exception) { + internalLogger.log( + InternalLogger.Level.ERROR, + InternalLogger.Target.TELEMETRY, + { "Composition snapshot completion processing threw an exception" }, + e + ) + } finally { + clearProcessingCapture(capture) + } + } + + private fun clearProcessingCapture(capture: CompletedSnapshotCapture) { + synchronized(lifecycleLock) { + if (processingCapture === capture) processingCapture = null + } + } + + private companion object { + const val EXECUTION_CONTEXT = "Session Replay composition snapshot completion" + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/SnapshotProcessing.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/SnapshotProcessing.kt new file mode 100644 index 0000000000..d08a9867cf --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/SnapshotProcessing.kt @@ -0,0 +1,63 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.composition + +internal data class SnapshotProcessingRequest( + val generation: CaptureGenerationContext, + val snapshot: CapturedFullSnapshot +) + +internal sealed interface SnapshotProcessingResult { + val generationId: Long + + data class Completed( + override val generationId: Long, + val snapshot: CapturedFullSnapshot + ) : SnapshotProcessingResult + + data class Failed( + override val generationId: Long + ) : SnapshotProcessingResult +} + +internal data class CompletedSnapshotCapture( + val generation: CaptureGenerationContext, + val snapshot: CapturedFullSnapshot +) + +internal fun interface SnapshotProcessingCallback { + fun onProcessed(result: SnapshotProcessingResult) +} + +/** + * Owns every asynchronous enrichment operation for one capture generation. Implementations must + * report one terminal result. Cancelling the returned work cancels the whole generation scope. + */ +internal fun interface CapturedSnapshotProcessor { + fun process( + request: SnapshotProcessingRequest, + callback: SnapshotProcessingCallback + ): CancellableCaptureWork +} + +/** Default processing scope until asynchronous enrichers are supplied by capture implementations. */ +internal class ImmediateCapturedSnapshotProcessor : CapturedSnapshotProcessor { + override fun process( + request: SnapshotProcessingRequest, + callback: SnapshotProcessingCallback + ): CancellableCaptureWork { + callback.onProcessed( + SnapshotProcessingResult.Completed(request.generation.id, request.snapshot) + ) + return CancellableCaptureWork.NONE + } +} + +/** Receives only complete snapshots; traversal and enrichment never enter the downstream queue. */ +internal fun interface CompletedSnapshotConsumer { + fun consume(capture: CompletedSnapshotCapture) +} diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/DefaultRecorderProviderTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/DefaultRecorderProviderTest.kt index 8c86f73923..f666c32500 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/DefaultRecorderProviderTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/DefaultRecorderProviderTest.kt @@ -7,10 +7,13 @@ package com.datadog.android.sessionreplay.internal import android.app.Application +import com.datadog.android.api.InternalLogger import com.datadog.android.api.feature.FeatureSdkCore import com.datadog.android.sessionreplay.ImagePrivacy import com.datadog.android.sessionreplay.SessionReplayInternalCallback import com.datadog.android.sessionreplay.TextAndInputPrivacy +import com.datadog.android.sessionreplay.forge.ForgeConfigurator +import com.datadog.android.sessionreplay.internal.composition.CompositionCapturePipeline import com.datadog.android.sessionreplay.internal.embedded.EmbeddedContentSlotRegistry import com.datadog.android.sessionreplay.internal.recorder.Recorder import com.datadog.android.sessionreplay.internal.recorder.SessionReplayRecorder @@ -18,12 +21,23 @@ import com.datadog.android.sessionreplay.internal.resources.ResourceDataStoreMan import com.datadog.android.sessionreplay.internal.storage.RecordWriter import com.datadog.android.sessionreplay.internal.storage.ResourcesWriter import com.datadog.android.sessionreplay.internal.utils.RumContextProvider +import com.datadog.android.utils.verifyLog +import fr.xgouchet.elmyr.annotation.StringForgery +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.Extensions import org.mockito.kotlin.any import org.mockito.kotlin.mock +import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever +@Extensions( + ExtendWith(ForgeExtension::class) +) +@ForgeConfiguration(ForgeConfigurator::class) internal class DefaultRecorderProviderTest { @Test @@ -33,6 +47,7 @@ internal class DefaultRecorderProviderTest { whenever(sdkCore.timeProvider).thenReturn(mock()) whenever(sdkCore.internalLogger).thenReturn(mock()) whenever(sdkCore.createSingleThreadExecutorService(any())).thenReturn(mock()) + whenever(sdkCore.createScheduledExecutorService(any())).thenReturn(mock()) var compositionConstructions = 0 val provider = DefaultRecorderProvider( sdkCore = sdkCore, @@ -104,4 +119,134 @@ internal class DefaultRecorderProviderTest { assertThat(result).isSameAs(compositionRecorder) assertThat(compositionConstructions).isEqualTo(1) } + + @Test + fun `M construct wired composition pipeline W provideSessionReplayRecorder { default factory }`() { + // Given + val sdkCore = mock() + whenever(sdkCore.timeProvider).thenReturn(mock()) + whenever(sdkCore.internalLogger).thenReturn(mock()) + whenever(sdkCore.createSingleThreadExecutorService(any())).thenReturn(mock()) + whenever(sdkCore.createScheduledExecutorService(any())).thenReturn(mock()) + var producerConstructions = 0 + var timeBankConstructions = 0 + val provider = DefaultRecorderProvider( + sdkCore = sdkCore, + textAndInputPrivacy = TextAndInputPrivacy.MASK_ALL, + imagePrivacy = ImagePrivacy.MASK_ALL, + touchPrivacyManager = mock(), + customMappers = emptyList(), + customOptionSelectorDetectors = emptyList(), + customDrawableMappers = emptyList(), + dynamicOptimizationEnabled = false, + internalCallback = mock(), + heatmapsEnabled = false, + compositionTreeRecordingEnabled = true, + compositionSnapshotProducerFactory = { + producerConstructions++ + mock() + }, + recordingTimeBankFactory = { + timeBankConstructions++ + mock() + } + ) + + // When + val result = provider.provideSessionReplayRecorder( + resourceDataStoreManager = mock(), + resourceWriter = mock(), + recordWriter = mock(), + rumContextProvider = mock(), + application = mock(), + embeddedContentSlotRegistry = mock() + ) + + // Then + assertThat(result).isInstanceOf(CompositionCapturePipeline::class.java) + assertThat(producerConstructions).isEqualTo(1) + assertThat(timeBankConstructions).isZero() + } + + @Test + fun `M log warning W provideSessionReplayRecorder { heatmaps and composition recording both enabled }`() { + // Given + val sdkCore = mock() + val internalLogger = mock() + whenever(sdkCore.internalLogger).thenReturn(internalLogger) + whenever(sdkCore.timeProvider).thenReturn(mock()) + whenever(sdkCore.createSingleThreadExecutorService(any())).thenReturn(mock()) + whenever(sdkCore.createScheduledExecutorService(any())).thenReturn(mock()) + val provider = DefaultRecorderProvider( + sdkCore = sdkCore, + textAndInputPrivacy = TextAndInputPrivacy.MASK_ALL, + imagePrivacy = ImagePrivacy.MASK_ALL, + touchPrivacyManager = mock(), + customMappers = emptyList(), + customOptionSelectorDetectors = emptyList(), + customDrawableMappers = emptyList(), + dynamicOptimizationEnabled = false, + internalCallback = mock(), + heatmapsEnabled = true, + compositionTreeRecordingEnabled = true, + compositionPipelineFactory = { mock() } + ) + + // When + provider.provideSessionReplayRecorder( + resourceDataStoreManager = mock(), + resourceWriter = mock(), + recordWriter = mock(), + rumContextProvider = mock(), + application = mock(), + embeddedContentSlotRegistry = mock() + ) + + // Then + internalLogger.verifyLog( + InternalLogger.Level.WARN, + InternalLogger.Target.USER, + DefaultRecorderProvider.HEATMAPS_UNSUPPORTED_WITH_COMPOSITION_RECORDING_MESSAGE + ) + } + + @Test + fun `M not log warning W provideSessionReplayRecorder { heatmaps enabled, composition recording disabled }`( + @StringForgery fakePackageName: String + ) { + // Given + val sdkCore = mock() + val internalLogger = mock() + whenever(sdkCore.internalLogger).thenReturn(internalLogger) + whenever(sdkCore.timeProvider).thenReturn(mock()) + whenever(sdkCore.createSingleThreadExecutorService(any())).thenReturn(mock()) + whenever(sdkCore.createScheduledExecutorService(any())).thenReturn(mock()) + val provider = DefaultRecorderProvider( + sdkCore = sdkCore, + textAndInputPrivacy = TextAndInputPrivacy.MASK_ALL, + imagePrivacy = ImagePrivacy.MASK_ALL, + touchPrivacyManager = mock(), + customMappers = emptyList(), + customOptionSelectorDetectors = emptyList(), + customDrawableMappers = emptyList(), + dynamicOptimizationEnabled = false, + internalCallback = mock(), + heatmapsEnabled = true, + compositionTreeRecordingEnabled = false, + compositionPipelineFactory = { mock() } + ) + + // When + provider.provideSessionReplayRecorder( + resourceDataStoreManager = mock(), + resourceWriter = mock(), + recordWriter = mock(), + rumContextProvider = mock(), + application = mock().apply { whenever(packageName).thenReturn(fakePackageName) }, + embeddedContentSlotRegistry = mock() + ) + + // Then + verifyNoInteractions(internalLogger) + } } diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeatureTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeatureTest.kt index 0db70c10d1..7ec8934d53 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeatureTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeatureTest.kt @@ -71,6 +71,7 @@ import java.util.Locale import java.util.UUID import java.util.concurrent.CountDownLatch import java.util.concurrent.ExecutorService +import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.TimeUnit import java.util.stream.Stream @@ -103,6 +104,9 @@ internal class SessionReplayFeatureTest { @Mock lateinit var mockExecutorService: ExecutorService + @Mock + lateinit var mockScheduledExecutorService: ScheduledExecutorService + private lateinit var fakeSessionId: String private var fakeSampleRate: Float = 75f @@ -117,6 +121,7 @@ internal class SessionReplayFeatureTest { whenever(mockExecutorService.execute(any())) doAnswer { it.getArgument(0).run() } + whenever(mockSdkCore.createScheduledExecutorService(any())) doReturn mockScheduledExecutorService testedFeature = SessionReplayFeature( sdkCore = mockSdkCore, diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/AndroidCaptureExecutionTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/AndroidCaptureExecutionTest.kt new file mode 100644 index 0000000000..3e5a826856 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/AndroidCaptureExecutionTest.kt @@ -0,0 +1,79 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.composition + +import android.os.Handler +import com.datadog.android.api.InternalLogger +import com.datadog.android.sessionreplay.forge.ForgeConfigurator +import fr.xgouchet.elmyr.annotation.LongForgery +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.Extensions +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit + +@Extensions( + ExtendWith(ForgeExtension::class) +) +@ForgeConfiguration(ForgeConfigurator::class) +internal class AndroidCaptureExecutionTest { + + @Test + fun `M execute and cancel main-thread task W handler accepts task`() { + // Given + val handler = mock() + whenever(handler.post(any())).thenReturn(true) + val executor = HandlerCaptureMainThreadExecutor(handler) + var executed = false + + // When + val work = executor.execute { executed = true } + val runnable = argumentCaptor() + verify(handler).post(runnable.capture()) + runnable.firstValue.run() + work.cancel() + + // Then + assertThat(executed).isTrue() + verify(handler).removeCallbacks(runnable.firstValue) + } + + @Test + fun `M schedule cancel and shutdown expiry W use scheduled executor`( + @LongForgery(min = 0L) fakeDelayNs: Long + ) { + // Given + val executorService = mock() + val future = mock>() + whenever( + executorService.schedule(any(), eq(fakeDelayNs), eq(TimeUnit.NANOSECONDS)) + ).thenReturn(future) + val scheduler = ScheduledExecutorCaptureTaskScheduler( + executorService, + mock() + ) + + // When + val work = scheduler.schedule(fakeDelayNs) {} + work.cancel() + scheduler.shutdown() + + // Then + verify(future).cancel(false) + verify(executorService).shutdownNow() + } +} diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/AndroidSnapshotCaptureLifecycleTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/AndroidSnapshotCaptureLifecycleTest.kt new file mode 100644 index 0000000000..62d208d156 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/AndroidSnapshotCaptureLifecycleTest.kt @@ -0,0 +1,143 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.composition + +import android.app.Application +import android.os.Handler +import android.view.View +import android.view.ViewTreeObserver +import android.view.Window +import com.datadog.android.internal.time.TimeProvider +import com.datadog.android.sessionreplay.forge.ForgeConfigurator +import fr.xgouchet.elmyr.annotation.LongForgery +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.Extensions +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@Extensions( + ExtendWith(ForgeExtension::class) +) +@ForgeConfiguration(ForgeConfigurator::class) +internal class AndroidSnapshotCaptureLifecycleTest { + + @Test + fun `M signal source window W onDraw`() { + // Given + val view = mock() + val changedWindows = mutableListOf>() + val listener = CompositionOnDrawListener(view) { changedWindows += it } + + // When + listener.onDraw() + + // Then + assertThat(changedWindows).containsExactly(listOf(view)) + } + + @Test + fun `M update active windows and listener registrations W intercept`() { + // Given + val observer = mock() + whenever(observer.isAlive).thenReturn(true) + val view = mock() + whenever(view.viewTreeObserver).thenReturn(observer) + val changedWindows = mutableListOf>() + val source = ActiveWindowSource() + val interceptor = CompositionViewOnDrawInterceptor(source, { changedWindows += it }, mock()) + + // When + interceptor.intercept(listOf(view)) + val addedListener = argumentCaptor() + verify(observer).addOnDrawListener(addedListener.capture()) + interceptor.intercept(emptyList()) + + // Then + verify(observer).removeOnDrawListener(addedListener.firstValue) + assertThat(source.currentWindows()).isEmpty() + assertThat(changedWindows).containsExactly(listOf(view), emptyList()) + } + + @Test + fun `M refresh interception W lifecycle starts and windows change`() { + // Given + val application = mock() + val interceptor = mock() + val handler = immediateHandler() + val views = listOf(mock()) + val lifecycle = AndroidSnapshotCaptureLifecycle( + application = application, + interceptor = interceptor, + internalLogger = mock(), + uiHandler = handler, + windowProvider = { views } + ) + + // When + lifecycle.registerCallbacks() + lifecycle.start() + lifecycle.onWindowsAdded(listOf(mock())) + lifecycle.stop() + lifecycle.onWindowsRemoved(listOf(mock())) + lifecycle.unregisterCallbacks() + + // Then + verify(application).registerActivityLifecycleCallbacks(any()) + verify(interceptor, org.mockito.kotlin.times(2)).intercept(views) + verify(interceptor).stop() + verify(application).unregisterActivityLifecycleCallbacks(any()) + } + + @Test + fun `M schedule and cancel delayed task W use handler scheduler`() { + // Given + val handler = mock() + whenever(handler.postDelayed(any(), any())).thenReturn(true) + val scheduler = HandlerCaptureTaskScheduler(handler) + + // When + val work = scheduler.schedule(1) {} + work.cancel() + + // Then + val runnable = argumentCaptor() + verify(handler).postDelayed(runnable.capture(), org.mockito.kotlin.eq(1L)) + verify(handler).removeCallbacks(runnable.firstValue) + verify(handler, never()).post(any()) + } + + @Test + fun `M use device elapsed time W read capture clock`( + @LongForgery fakeElapsedTimeNs: Long + ) { + // Given + val timeProvider = mock() + whenever(timeProvider.getDeviceElapsedTimeNanos()).thenReturn(fakeElapsedTimeNs) + + // When + val result = TimeProviderCaptureTimeProvider(timeProvider).elapsedRealtimeNanos() + + // Then + assertThat(result).isEqualTo(fakeElapsedTimeNs) + } + + private fun immediateHandler(): Handler = mock().also { handler -> + doAnswer { + it.getArgument(0).run() + true + }.whenever(handler).post(any()) + } +} diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/CaptureGenerationContextTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/CaptureGenerationContextTest.kt new file mode 100644 index 0000000000..378e4a0c8d --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/CaptureGenerationContextTest.kt @@ -0,0 +1,80 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.composition + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +internal class CaptureGenerationContextTest { + + @Test + fun `M cancel work W track { generation already expired }`() { + // Given + val clock = SequencedClock(0L) + val context = CaptureGenerationContext(1L, 0L, deadlineNs = 100L, timeProvider = clock) + context.expire() + val work = RecordingCancellableCaptureWork() + + // When + context.track(work) + + // Then + assertThat(work.cancelCount).isEqualTo(1) + } + + @Test + fun `M cancel work W track { generation expires between registration and recheck }`() { + // Given + // First isActive() check (inside track()) reads a timestamp before the deadline; the second + // reads one at/after it, so the generation transitions from active to expired strictly + // between registering the work and re-checking liveness - the same place a concurrent + // expire()/tryAccept() could otherwise race with registration in production. + val clock = SequencedClock(0L, 100L) + val context = CaptureGenerationContext(1L, 0L, deadlineNs = 100L, timeProvider = clock) + val work = RecordingCancellableCaptureWork() + + // When + context.track(work) + + // Then + assertThat(work.cancelCount).isGreaterThanOrEqualTo(1) + assertThat(context.isActive()).isFalse() + } + + @Test + fun `M not cancel work W track { generation remains active }`() { + // Given + val clock = SequencedClock(0L) + val context = CaptureGenerationContext(1L, 0L, deadlineNs = 100L, timeProvider = clock) + val work = RecordingCancellableCaptureWork() + + // When + context.track(work) + + // Then + assertThat(work.cancelCount).isZero() + } + + private class SequencedClock(private vararg val readings: Long) : CaptureTimeProvider { + private var index = 0 + + override fun elapsedRealtimeNanos(): Long { + val reading = readings[index.coerceAtMost(readings.size - 1)] + if (index < readings.size - 1) index++ + return reading + } + } + + private class RecordingCancellableCaptureWork : CancellableCaptureWork { + var cancelCount = 0 + private set + + override fun cancel() { + cancelCount++ + } + } +} diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/CaptureSkippedFrameNotifierTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/CaptureSkippedFrameNotifierTest.kt new file mode 100644 index 0000000000..8900c0db86 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/CaptureSkippedFrameNotifierTest.kt @@ -0,0 +1,48 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.composition + +import com.datadog.android.api.feature.Feature +import com.datadog.android.api.feature.FeatureScope +import com.datadog.android.api.feature.FeatureSdkCore +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +internal class CaptureSkippedFrameNotifierTest { + + @Test + fun `M send skipped-frame event W RUM feature exists`() { + // Given + val sdkCore = mock() + val rumFeature = mock() + whenever(sdkCore.getFeature(Feature.RUM_FEATURE_NAME)).thenReturn(rumFeature) + + // When + CaptureSkippedFrameNotifier(sdkCore).notifySkippedFrame() + + // Then + val event = argumentCaptor() + verify(rumFeature).sendEvent(event.capture()) + assertThat(event.firstValue).isEqualTo(mapOf("type" to "sr_skipped_frame")) + } + + @Test + fun `M skip event W RUM feature is absent`() { + // Given + val sdkCore = mock() + + // When + CaptureSkippedFrameNotifier(sdkCore).notifySkippedFrame() + + // Then + verify(sdkCore).getFeature(Feature.RUM_FEATURE_NAME) + } +} diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/CompositionCapturePipelineTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/CompositionCapturePipelineTest.kt index 82427e3630..4834497095 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/CompositionCapturePipelineTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/CompositionCapturePipelineTest.kt @@ -117,6 +117,32 @@ internal class CompositionCapturePipelineTest { assertThat(second.callbacksRegistered).isFalse() } + @Test + fun `M delegate recording lifecycle W composition pipeline is orchestrated`() { + // Given + val orchestrator = mock() + val lifecycle = mock() + val completionQueue = mock() + val pipeline = CompositionCapturePipeline(orchestrator, lifecycle, completionQueue) + + // When + pipeline.registerCallbacks() + pipeline.resumeRecorders() + pipeline.stopRecorders() + pipeline.stopProcessingRecords() + pipeline.unregisterCallbacks() + + // Then + verify(lifecycle).registerCallbacks() + verify(orchestrator).start() + verify(lifecycle).start() + verify(lifecycle).stop() + verify(orchestrator).stop() + verify(orchestrator).shutdown() + verify(completionQueue).stop() + verify(lifecycle).unregisterCallbacks() + } + private class StatefulRecorder : Recorder { var callbacksRegistered = false diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/CompositionChangesetTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/CompositionChangesetTest.kt new file mode 100644 index 0000000000..9a63a69be2 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/CompositionChangesetTest.kt @@ -0,0 +1,68 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.composition + +import android.view.View +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.mockito.kotlin.mock + +internal class CompositionChangesetTest { + + @Test + fun `M union windows W mergedWith { both sides non-empty }`() { + // Given + val first = CompositionChangeset.of(listOf(mock())) + val secondView = mock() + val second = CompositionChangeset.of(listOf(secondView)) + + // When + val merged = first.mergedWith(second) as CompositionChangeset + + // Then + assertThat(merged.changedWindows()).containsAll(first.changedWindows() + secondView) + } + + @Test + fun `M stay empty W mergedWith { receiver carries unknown invalidation }`() { + // Given + val unknown = CompositionChangeset.EMPTY + val specific = CompositionChangeset.of(listOf(mock())) + + // When + val merged = unknown.mergedWith(specific) + + // Then + assertThat(merged.isEmpty()).isTrue() + } + + @Test + fun `M become empty W mergedWith { incoming signal carries unknown invalidation }`() { + // Given + val specific = CompositionChangeset.of(listOf(mock())) + val unknown: CaptureChangeset = CaptureChangeset.EMPTY + + // When + val merged = specific.mergedWith(unknown) + + // Then + assertThat(merged.isEmpty()).isTrue() + } + + @Test + fun `M stay empty W mergedWith { both sides carry unknown invalidation }`() { + // Given + val first = CompositionChangeset.EMPTY + val second = CompositionChangeset.EMPTY + + // When + val merged = first.mergedWith(second) + + // Then + assertThat(merged.isEmpty()).isTrue() + } +} diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/SnapshotCaptureOrchestratorTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/SnapshotCaptureOrchestratorTest.kt new file mode 100644 index 0000000000..2d8fcb3fc3 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/SnapshotCaptureOrchestratorTest.kt @@ -0,0 +1,761 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.composition + +import android.view.View +import com.datadog.android.sessionreplay.internal.recorder.RecordingTimeBank +import com.datadog.android.sessionreplay.internal.recorder.TimeBank +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.mockito.kotlin.mock + +internal class SnapshotCaptureOrchestratorTest { + + @Test + fun `M use 90 ms deadline W generation budget is not overridden`() { + // Given + val clock = FakeClock().apply { nowNs = 42L } + val scheduler = FakeScheduler() + val expiryScheduler = FakeScheduler() + val generations = mutableListOf() + val orchestrator = SnapshotCaptureOrchestrator( + producer = CapturedSnapshotProducer { context, _ -> + generations += context + null + }, + processor = FakeProcessor(), + consumer = CompletedSnapshotConsumer { _ -> }, + timeProvider = clock, + captureScheduler = scheduler, + mainThreadExecutor = FakeMainThreadExecutor(), + expiryScheduler = expiryScheduler, + captureDelayNs = IMMEDIATE + ) + + // When + orchestrator.start() + orchestrator.requestCapture() + scheduler.runNext(IMMEDIATE) + + // Then + assertThat(generations.single().deadlineNs - generations.single().startedAtNs) + .isEqualTo(90_000_000L) + } + + @Test + fun `M keep recording allowance independent W generation deadline is 90 ms`() { + // Given + val recordingTimeBank = RecordingTimeBank() + val generation = CaptureGenerationContext(1L, 0L, 90_000_000L, FakeClock()) + + // When + val initiallyAdmitted = recordingTimeBank.updateAndCheck(0L) + recordingTimeBank.consume(100_000_001L) + + // Then + assertThat(initiallyAdmitted).isTrue() + assertThat(recordingTimeBank.updateAndCheck(0L)).isFalse() + assertThat(generation.remainingBudgetNs()).isEqualTo(90_000_000L) + } + + @Test + fun `M discover roots on main thread W generation starts`() { + // Given + val mainThread = FakeMainThreadExecutor(autoRun = false) + val fixture = Fixture(mainThreadExecutor = mainThread) + fixture.orchestrator.start() + + // When + fixture.orchestrator.requestCapture() + fixture.scheduler.runNext(IMMEDIATE) + + // Then + assertThat(fixture.producerCaptures).isZero() + + // When + mainThread.runNext() + + // Then + assertThat(fixture.producerCaptures).isEqualTo(1) + } + + @Test + fun `M execute synchronous capture adapter on main thread W capture starts`() { + // Given + val mainThread = FakeMainThreadExecutor() + var producerRanOnMainThread = false + val fixture = Fixture( + mainThreadExecutor = mainThread, + onProducerCapture = { producerRanOnMainThread = mainThread.isExecuting } + ) + + // When + fixture.orchestrator.start() + fixture.orchestrator.requestCapture() + fixture.scheduler.runNext(IMMEDIATE) + + // Then + assertThat(producerRanOnMainThread).isTrue() + } + + @Test + fun `M release main thread W generation waits for asynchronous processing`() { + // Given + val mainThread = FakeMainThreadExecutor() + val fixture = Fixture(mainThreadExecutor = mainThread) + fixture.orchestrator.start() + + // When + fixture.orchestrator.requestCapture() + fixture.scheduler.runNext(IMMEDIATE) + + // Then + assertThat(fixture.processor.pending).hasSize(1) + assertThat(mainThread.isExecuting).isFalse() + assertThat(fixture.consumed).isEmpty() + } + + @Test + fun `M hand off completed snapshot W processing completes in generation`() { + // Given + val fixture = Fixture() + + // When + fixture.orchestrator.start() + fixture.orchestrator.requestCapture() + fixture.scheduler.runNext(IMMEDIATE) + val processing = fixture.processor.pending.single() + processing.complete() + + // Then + assertThat(fixture.producerCaptures).isEqualTo(1) + assertThat(fixture.consumed).hasSize(1) + assertThat(fixture.consumed.single().generation).isEqualTo(processing.request.generation) + assertThat(fixture.consumed.single().snapshot).isSameAs(fixture.snapshot) + assertThat(fixture.expiryScheduler.tasks.single { it.delayNs == TIMEOUT_NS }.cancelled).isFalse() + } + + @Test + fun `M coalesce a follow-up capture W draw signals arrive during processing`() { + // Given + val fixture = Fixture() + fixture.orchestrator.start() + fixture.orchestrator.requestCapture() + fixture.scheduler.runNext(IMMEDIATE) + + // When + fixture.orchestrator.requestCapture() + fixture.orchestrator.requestCapture() + fixture.processor.pending.single().complete() + fixture.scheduler.runNext(IMMEDIATE) + + // Then + assertThat(fixture.producerCaptures).isEqualTo(2) + assertThat(fixture.processor.pending).hasSize(2) + assertThat(fixture.processor.pending.map { it.request.generation.id }).containsExactly(1L, 2L) + } + + @Test + fun `M pass changeset to producer W requestCapture supplies one`() { + // Given + val fixture = Fixture() + val view = mock() + fixture.orchestrator.start() + + // When + fixture.orchestrator.requestCapture(CompositionChangeset.of(listOf(view))) + fixture.scheduler.runNext(IMMEDIATE) + + // Then + val changeset = fixture.producerChangesets.single() as CompositionChangeset + assertThat(changeset.changedWindows()).containsExactly(view) + } + + @Test + fun `M merge changesets W multiple draw signals coalesce before generation starts`() { + // Given + val fixture = Fixture() + val first = mock() + val second = mock() + fixture.orchestrator.start() + + // When + fixture.orchestrator.requestCapture(CompositionChangeset.of(listOf(first))) + fixture.orchestrator.requestCapture(CompositionChangeset.of(listOf(second))) + fixture.scheduler.runNext(IMMEDIATE) + + // Then + val changeset = fixture.producerChangesets.single() as CompositionChangeset + assertThat(changeset.changedWindows()).containsExactlyInAnyOrder(first, second) + } + + @Test + fun `M keep accumulated changeset W time budget denies admission`() { + // Given + val budget = FakeTimeBudget(canStart = false) + val fixture = Fixture(timeBudget = budget) + val deniedView = mock() + val admittedView = mock() + fixture.orchestrator.start() + + // When + fixture.orchestrator.requestCapture(CompositionChangeset.of(listOf(deniedView))) + fixture.scheduler.runNext(IMMEDIATE) + + // Then + assertThat(fixture.producerCaptures).isZero() + + // When + budget.canStart = true + fixture.orchestrator.requestCapture(CompositionChangeset.of(listOf(admittedView))) + fixture.scheduler.runNext(IMMEDIATE) + + // Then + val changeset = fixture.producerChangesets.single() as CompositionChangeset + assertThat(changeset.changedWindows()).containsExactlyInAnyOrder(deniedView, admittedView) + } + + @Test + fun `M drop pending changeset W orchestration stops`() { + // Given + val fixture = Fixture() + val view = mock() + fixture.orchestrator.start() + fixture.orchestrator.requestCapture(CompositionChangeset.of(listOf(view))) + + // When + fixture.orchestrator.stop() + fixture.orchestrator.start() + fixture.orchestrator.requestCapture() + fixture.scheduler.runNext(IMMEDIATE) + fixture.scheduler.runNext(IMMEDIATE) + + // Then + val changeset = fixture.producerChangesets.single() + assertThat(changeset.isEmpty()).isTrue() + } + + @Test + fun `M cancel generation and ignore callback W processing expires`() { + // Given + val fixture = Fixture() + fixture.orchestrator.start() + fixture.orchestrator.requestCapture() + fixture.scheduler.runNext(IMMEDIATE) + val expiredProcessing = fixture.processor.pending.single() + fixture.orchestrator.requestCapture() + + // When + fixture.expiryScheduler.runNext(TIMEOUT_NS) + fixture.scheduler.runNext(IMMEDIATE) + expiredProcessing.complete() + + // Then + assertThat(expiredProcessing.cancelled).isTrue() + assertThat(fixture.consumed).isEmpty() + assertThat(fixture.processor.pending.map { it.request.generation.id }).containsExactly(1L, 2L) + } + + @Test + fun `M expire generation W completion arrives at deadline before timeout task`() { + // Given + val fixture = Fixture() + fixture.clock.nowNs = 20L + fixture.orchestrator.start() + fixture.orchestrator.requestCapture() + fixture.scheduler.runNext(IMMEDIATE) + val processing = fixture.processor.pending.single() + + // When + fixture.clock.nowNs = 20L + TIMEOUT_NS + processing.complete() + + // Then + assertThat(processing.cancelled).isTrue() + assertThat(fixture.consumed).isEmpty() + } + + @Test + fun `M hand off generation W completion arrives immediately before deadline`() { + // Given + val fixture = Fixture() + fixture.clock.nowNs = 20L + fixture.orchestrator.start() + fixture.orchestrator.requestCapture() + fixture.scheduler.runNext(IMMEDIATE) + val processing = fixture.processor.pending.single() + + // When + fixture.clock.nowNs = 20L + TIMEOUT_NS - 1L + processing.complete() + + // Then + assertThat(fixture.consumed).hasSize(1) + } + + @Test + fun `M expire generation W completion arrives after deadline`() { + // Given + val fixture = Fixture() + fixture.clock.nowNs = 20L + fixture.orchestrator.start() + fixture.orchestrator.requestCapture() + fixture.scheduler.runNext(IMMEDIATE) + val processing = fixture.processor.pending.single() + + // When + fixture.clock.nowNs = 20L + TIMEOUT_NS + 1L + processing.complete() + + // Then + assertThat(processing.cancelled).isTrue() + assertThat(fixture.consumed).isEmpty() + } + + @Test + fun `M expose one absolute budget W producer and processor query generation`() { + // Given + val fixture = Fixture() + fixture.clock.nowNs = 20L + fixture.orchestrator.start() + + // When + fixture.orchestrator.requestCapture() + fixture.scheduler.runNext(IMMEDIATE) + val generation = fixture.producerGenerations.single() + fixture.clock.nowNs = 50L + + // Then + assertThat(generation.startedAtNs).isEqualTo(20L) + assertThat(generation.deadlineNs).isEqualTo(20L + TIMEOUT_NS) + assertThat(generation.remainingBudgetNs()).isEqualTo(70L) + assertThat(fixture.processor.pending.single().request.generation).isSameAs(generation) + } + + @Test + fun `M invalidate outstanding work token W generation reaches deadline`() { + // Given + val fixture = Fixture() + fixture.orchestrator.start() + fixture.orchestrator.requestCapture() + fixture.scheduler.runNext(IMMEDIATE) + val generation = fixture.producerGenerations.single() + val token = generation.createWorkToken() + + // When + fixture.clock.nowNs = TIMEOUT_NS + + // Then + assertThat(token).isNotNull + assertThat(token?.isValid()).isFalse() + assertThat(token?.complete()).isFalse() + assertThat(generation.remainingBudgetNs()).isZero() + } + + @Test + fun `M ignore callback W result carries a different generation identity`() { + // Given + val fixture = Fixture() + fixture.orchestrator.start() + fixture.orchestrator.requestCapture() + fixture.scheduler.runNext(IMMEDIATE) + val processing = fixture.processor.pending.single() + + // When + processing.complete(generationId = processing.request.generation.id + 1) + + // Then + assertThat(fixture.consumed).isEmpty() + + // When + processing.complete() + + // Then + assertThat(fixture.consumed).hasSize(1) + } + + @Test + fun `M ignore stale scheduled capture W pipeline restarts`() { + // Given + val fixture = Fixture() + fixture.orchestrator.start() + fixture.orchestrator.requestCapture() + + // When + fixture.orchestrator.stop() + fixture.orchestrator.start() + fixture.orchestrator.requestCapture() + fixture.scheduler.runNext(IMMEDIATE) + fixture.scheduler.runNext(IMMEDIATE) + + // Then + assertThat(fixture.producerCaptures).isEqualTo(1) + assertThat(fixture.processor.pending.single().request.generation.id).isEqualTo(1L) + } + + @Test + fun `M cancel generation scoped work W orchestration stops`() { + // Given + val fixture = Fixture() + fixture.orchestrator.start() + fixture.orchestrator.requestCapture() + fixture.scheduler.runNext(IMMEDIATE) + val processing = fixture.processor.pending.single() + + // When + fixture.orchestrator.stop() + processing.complete() + + // Then + assertThat(processing.cancelled).isTrue() + assertThat(fixture.expiryScheduler.tasks.single { it.delayNs == TIMEOUT_NS }.cancelled).isTrue() + assertThat(fixture.consumed).isEmpty() + } + + @Test + fun `M shutdown capture and expiry schedulers W orchestration shuts down`() { + // Given + val fixture = Fixture() + + // When + fixture.orchestrator.shutdown() + + // Then + assertThat(fixture.scheduler.shutdowns).isEqualTo(1) + assertThat(fixture.expiryScheduler.shutdowns).isEqualTo(1) + } + + @Test + fun `M skip processing and continue W traversal produces no snapshot`() { + // Given + val fixture = Fixture(snapshotToProduce = null) + fixture.orchestrator.start() + + // When + fixture.orchestrator.requestCapture() + fixture.scheduler.runNext(IMMEDIATE) + + // Then + assertThat(fixture.producerCaptures).isEqualTo(1) + assertThat(fixture.processor.pending).isEmpty() + assertThat(fixture.consumed).isEmpty() + assertThat(fixture.expiryScheduler.tasks.single { it.delayNs == TIMEOUT_NS }.cancelled).isTrue() + } + + @Test + fun `M skip traversal W capture time budget is exhausted`() { + // Given + val budget = FakeTimeBudget(canStart = false) + val fixture = Fixture(timeBudget = budget) + fixture.orchestrator.start() + + // When + fixture.orchestrator.requestCapture() + fixture.scheduler.runNext(IMMEDIATE) + + // Then + assertThat(fixture.producerCaptures).isZero() + assertThat(fixture.producerGenerations).isEmpty() + assertThat(fixture.consumed).isEmpty() + assertThat(budget.startChecks).containsExactly(0L) + + // When + budget.canStart = true + fixture.orchestrator.requestCapture() + fixture.scheduler.runNext(IMMEDIATE) + + // Then + assertThat(fixture.producerCaptures).isEqualTo(1) + assertThat(fixture.producerGenerations.single().id).isEqualTo(1L) + assertThat(budget.consumedDurations).containsExactly(0L) + } + + @Test + fun `M notify skipped frame W recording time bank denies admission`() { + // Given + var skippedFrames = 0 + val deniedTimeBank = object : TimeBank { + override fun consume(executionTime: Long) = Unit + override fun updateAndCheck(timestamp: Long): Boolean = false + } + val budget = TimeBankCaptureTimeBudget(deniedTimeBank) { skippedFrames++ } + + // When + val admitted = budget.canStart(0L) + + // Then + assertThat(admitted).isFalse() + assertThat(skippedFrames).isEqualTo(1) + } + + @Test + fun `M charge active main thread work W capture adapter completes`() { + // Given + val budget = FakeTimeBudget(canStart = true) + val fixture = Fixture(timeBudget = budget, producerExecutionNs = 25L) + + // When + fixture.orchestrator.start() + fixture.orchestrator.requestCapture() + fixture.scheduler.runNext(IMMEDIATE) + + // Then + assertThat(budget.consumedDurations).containsExactly(25L) + } + + @Test + fun `M exclude asynchronous waiting W charge recording time bank`() { + // Given + val budget = FakeTimeBudget(canStart = true) + val fixture = Fixture(timeBudget = budget, producerExecutionNs = 25L) + fixture.orchestrator.start() + fixture.orchestrator.requestCapture() + fixture.scheduler.runNext(IMMEDIATE) + + // When + fixture.clock.nowNs += 1_000L + fixture.processor.pending.single().complete() + + // Then + assertThat(budget.consumedDurations).containsExactly(25L) + } + + @Test + fun `M observe cancellation between synchronous capture units W generation expires`() { + // Given + val clock = FakeClock() + val context = CaptureGenerationContext(1L, 0L, 100L, clock) + var executedUnits = 0 + + // When + val first = context.runMainThreadCaptureUnit { + executedUnits++ + } + context.expire() + val second = context.runMainThreadCaptureUnit { + executedUnits++ + } + + // Then + assertThat(first).isInstanceOf(MainThreadCaptureResult.Completed::class.java) + assertThat(second).isEqualTo(MainThreadCaptureResult.Interrupted) + assertThat(executedUnits).isEqualTo(1) + } + + @Test + fun `M stop further synchronous work W time bank denies continuation`() { + // Given + val clock = FakeClock() + val budget = FakeTimeBudget(canStart = true) + val context = CaptureGenerationContext(1L, 0L, 100L, clock, budget) + var executedUnits = 0 + context.runMainThreadCaptureUnit(admissionAlreadyGranted = true) { executedUnits++ } + budget.canStart = false + + // When + val result = context.runMainThreadCaptureUnit { executedUnits++ } + + // Then + assertThat(result).isEqualTo(MainThreadCaptureResult.Interrupted) + assertThat(executedUnits).isEqualTo(1) + } + + @Test + fun `M check deadline before synchronous adapter W deadline already reached`() { + // Given + val clock = FakeClock().apply { nowNs = 100L } + val context = CaptureGenerationContext(1L, 0L, 100L, clock) + var adapterCalled = false + + // When + val result = context.runMainThreadCaptureUnit { adapterCalled = true } + + // Then + assertThat(result).isEqualTo(MainThreadCaptureResult.Interrupted) + assertThat(adapterCalled).isFalse() + } + + @Test + fun `M check deadline after synchronous adapter W deadline reached during call`() { + // Given + val clock = FakeClock() + val context = CaptureGenerationContext(1L, 0L, 100L, clock) + + // When + val result = context.runMainThreadCaptureUnit { + clock.nowNs = 100L + } + + // Then + assertThat(result).isEqualTo(MainThreadCaptureResult.Interrupted) + assertThat(context.isActive()).isFalse() + } + + @Test + fun `M cooperatively drop capture W blocking adapter outlives deadline`() { + // Given + val fixture = Fixture(producerExecutionNs = TIMEOUT_NS) + + // When + fixture.orchestrator.start() + fixture.orchestrator.requestCapture() + fixture.scheduler.runNext(IMMEDIATE) + + // Then + assertThat(fixture.expiryScheduler.tasks.single().executed).isFalse() + assertThat(fixture.processor.pending).isEmpty() + assertThat(fixture.consumed).isEmpty() + } + + private class Fixture( + snapshotToProduce: CapturedFullSnapshot? = compositionTestTree().snapshot, + timeBudget: CaptureTimeBudget = CaptureTimeBudget.UNLIMITED, + mainThreadExecutor: FakeMainThreadExecutor = FakeMainThreadExecutor(), + producerExecutionNs: Long = 0L, + onProducerCapture: () -> Unit = {} + ) { + val snapshot = snapshotToProduce + val clock = FakeClock() + val scheduler = FakeScheduler() + val expiryScheduler = FakeScheduler() + val processor = FakeProcessor() + val consumed = mutableListOf() + val producerGenerations = mutableListOf() + val producerChangesets = mutableListOf() + var producerCaptures = 0 + val orchestrator = SnapshotCaptureOrchestrator( + producer = CapturedSnapshotProducer { generation, changeset -> + producerCaptures++ + producerGenerations += generation + producerChangesets += changeset + onProducerCapture() + clock.nowNs += producerExecutionNs + snapshotToProduce + }, + processor = processor, + consumer = CompletedSnapshotConsumer(consumed::add), + timeProvider = clock, + captureScheduler = scheduler, + mainThreadExecutor = mainThreadExecutor, + expiryScheduler = expiryScheduler, + timeBudget = timeBudget, + captureDelayNs = IMMEDIATE, + generationBudgetNs = TIMEOUT_NS + ) + } + + private class FakeClock : CaptureTimeProvider { + var nowNs = 0L + + override fun elapsedRealtimeNanos(): Long = nowNs + } + + private class FakeProcessor : CapturedSnapshotProcessor { + val pending = mutableListOf() + + override fun process( + request: SnapshotProcessingRequest, + callback: SnapshotProcessingCallback + ): CancellableCaptureWork = PendingProcessing(request, callback).also(pending::add) + } + + private class FakeMainThreadExecutor( + private val autoRun: Boolean = true + ) : CaptureMainThreadExecutor { + private val tasks = mutableListOf() + var isExecuting = false + private set + + override fun execute(task: () -> Unit): CancellableCaptureWork { + val scheduled = ScheduledTask(IMMEDIATE) { + isExecuting = true + try { + task() + } finally { + isExecuting = false + } + }.also(tasks::add) + if (autoRun) scheduled.run() + return scheduled + } + + fun runNext() { + tasks.first { !it.executed && !it.cancelled }.run() + } + } + + private class PendingProcessing( + val request: SnapshotProcessingRequest, + private val callback: SnapshotProcessingCallback + ) : CancellableCaptureWork { + var cancelled = false + + override fun cancel() { + cancelled = true + } + + fun complete(generationId: Long = request.generation.id) { + callback.onProcessed( + SnapshotProcessingResult.Completed(generationId, request.snapshot) + ) + } + } + + private class FakeScheduler : CaptureTaskScheduler { + val tasks = mutableListOf() + var shutdowns = 0 + + override fun schedule(delayNs: Long, task: () -> Unit): CancellableCaptureWork = + ScheduledTask(delayNs, task).also(tasks::add) + + fun runNext(delayNs: Long) { + tasks.first { !it.executed && !it.cancelled && it.delayNs == delayNs }.run() + } + + override fun shutdown() { + shutdowns++ + } + } + + private class FakeTimeBudget( + var canStart: Boolean + ) : CaptureTimeBudget { + val startChecks = mutableListOf() + val consumedDurations = mutableListOf() + + override fun canStart(timestampNs: Long): Boolean { + startChecks += timestampNs + return canStart + } + + override fun consume(durationNs: Long) { + consumedDurations += durationNs + } + } + + private class ScheduledTask( + val delayNs: Long, + private val task: () -> Unit + ) : CancellableCaptureWork { + var cancelled = false + var executed = false + + override fun cancel() { + cancelled = true + } + + fun run() { + executed = true + task() + } + } + + private companion object { + const val IMMEDIATE = 0L + const val TIMEOUT_NS = 100L + } +} diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/SnapshotCompletionQueueTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/SnapshotCompletionQueueTest.kt new file mode 100644 index 0000000000..5e3d127a20 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/SnapshotCompletionQueueTest.kt @@ -0,0 +1,263 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.composition + +import com.datadog.android.api.InternalLogger +import com.datadog.android.sessionreplay.forge.ForgeConfigurator +import com.datadog.android.sessionreplay.internal.processor.EnrichedRecord +import com.datadog.android.sessionreplay.internal.storage.RecordWriter +import com.datadog.android.sessionreplay.internal.utils.RumContextProvider +import com.datadog.android.sessionreplay.internal.utils.SessionReplayRumContext +import com.datadog.android.sessionreplay.model.MobileSegment +import fr.xgouchet.elmyr.annotation.StringForgery +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.Extensions +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.inOrder +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.util.concurrent.ExecutorService + +@Extensions( + ExtendWith(ForgeExtension::class) +) +@ForgeConfiguration(ForgeConfigurator::class) +internal class SnapshotCompletionQueueTest { + + @Test + fun `M process completed capture W generation remains valid`() { + // Given + val fixture = QueueFixture() + val capture = fixture.capture(expiresAtNs = 10) + + // When + fixture.queue.consume(capture) + fixture.runQueuedTask() + + // Then + verify(fixture.processor).process(capture) + } + + @Test + fun `M drop completed capture W generation expires before handoff`() { + // Given + val fixture = QueueFixture() + fixture.clock.nowNs = 10 + + // When + fixture.queue.consume(fixture.capture(expiresAtNs = 10)) + + // Then + verify(fixture.executorService, never()).execute(any()) + verify(fixture.processor, never()).process(any()) + } + + @Test + fun `M drop queued capture W generation expires before processing`() { + // Given + val fixture = QueueFixture() + fixture.queue.consume(fixture.capture(expiresAtNs = 10)) + + // When + fixture.clock.nowNs = 10 + fixture.runQueuedTask() + + // Then + verify(fixture.processor, never()).process(any()) + } + + @Test + fun `M preserve capture order W multiple generations are handed off`() { + // Given + val fixture = QueueFixture() + val first = fixture.capture(generationId = 1, expiresAtNs = 10) + val second = fixture.capture(generationId = 2, expiresAtNs = 10) + + // When + fixture.queue.consume(first) + fixture.queue.consume(second) + fixture.runQueuedTask() + + // Then + inOrder(fixture.processor) { + verify(fixture.processor).process(first) + verify(fixture.processor).process(second) + } + verify(fixture.executorService).execute(any()) + } + + @Test + fun `M reject handoff and cancel executor W stop`() { + // Given + val fixture = QueueFixture() + + // When + fixture.queue.stop() + fixture.queue.consume(fixture.capture(expiresAtNs = 10)) + + // Then + verify(fixture.executorService).shutdownNow() + verify(fixture.executorService, never()).execute(any()) + } + + @Test + fun `M write mapped snapshot W acceptance is immediately before deadline`( + @StringForgery fakeApplicationId: String, + @StringForgery fakeSessionId: String + ) { + // Given + val tree = compositionTestTree() + val record = mock() + val wireMapper = mock() + whenever(wireMapper.mapFullSnapshot(tree.snapshot)) + .thenReturn(CaptureWireMappingResult.Success(record)) + val writer = mock() + val rumContext = SessionReplayRumContext( + applicationId = fakeApplicationId, + sessionId = fakeSessionId, + viewId = tree.scope.value + ) + val rumContextProvider = mock() + whenever(rumContextProvider.getRumContext()).thenReturn(rumContext) + val processor = DefaultSnapshotCompletionProcessor( + rumContextProvider = rumContextProvider, + recordWriter = writer, + internalLogger = mock(), + wireMapper = wireMapper + ) + val clock = FakeClock().apply { nowNs = 9L } + + // When + processor.process(CompletedSnapshotCapture(generation(clock, deadlineNs = 10L), tree.snapshot)) + + // Then + val recordCaptor = argumentCaptor() + verify(writer).write(recordCaptor.capture(), any()) + assertThat(recordCaptor.firstValue).isEqualTo( + EnrichedRecord(fakeApplicationId, fakeSessionId, tree.scope.value, listOf(record)) + ) + } + + @Test + fun `M drop mapped snapshot W acceptance is exactly at deadline`( + @StringForgery fakeApplicationId: String, + @StringForgery fakeSessionId: String + ) { + assertSnapshotDroppedAt(nowNs = 10L, applicationId = fakeApplicationId, sessionId = fakeSessionId) + } + + @Test + fun `M drop mapped snapshot W acceptance is after deadline`( + @StringForgery fakeApplicationId: String, + @StringForgery fakeSessionId: String + ) { + assertSnapshotDroppedAt(nowNs = 11L, applicationId = fakeApplicationId, sessionId = fakeSessionId) + } + + @Test + fun `M drop snapshot W process completed capture { RUM view changed }`( + @StringForgery fakeApplicationId: String, + @StringForgery fakeSessionId: String, + @StringForgery fakeOtherViewId: String + ) { + // Given + val tree = compositionTestTree() + val writer = mock() + val wireMapper = mock() + val rumContextProvider = mock() + whenever(rumContextProvider.getRumContext()).thenReturn( + SessionReplayRumContext(fakeApplicationId, fakeSessionId, fakeOtherViewId) + ) + val processor = DefaultSnapshotCompletionProcessor( + rumContextProvider = rumContextProvider, + recordWriter = writer, + internalLogger = mock(), + wireMapper = wireMapper + ) + + // When + processor.process( + CompletedSnapshotCapture(generation(FakeClock(), deadlineNs = 10L), tree.snapshot) + ) + + // Then + verify(wireMapper, never()).mapFullSnapshot(any()) + verify(writer, never()).write(any(), any()) + } + + private class QueueFixture { + val executorService = mock() + val processor = mock() + val clock = FakeClock() + val queue = SnapshotCompletionQueue( + executorService = executorService, + processor = processor, + internalLogger = mock() + ) + + fun capture( + generationId: Long = 1, + expiresAtNs: Long + ): CompletedSnapshotCapture = CompletedSnapshotCapture( + generation(clock, generationId, expiresAtNs), + compositionTestTree().snapshot + ) + + fun runQueuedTask() { + val captor = argumentCaptor() + verify(executorService).execute(captor.capture()) + captor.firstValue.run() + } + } + + private fun assertSnapshotDroppedAt(nowNs: Long, applicationId: String, sessionId: String) { + val tree = compositionTestTree() + val record = mock() + val wireMapper = mock() + whenever(wireMapper.mapFullSnapshot(tree.snapshot)) + .thenReturn(CaptureWireMappingResult.Success(record)) + val writer = mock() + val rumContextProvider = mock() + whenever(rumContextProvider.getRumContext()).thenReturn( + SessionReplayRumContext(applicationId, sessionId, tree.scope.value) + ) + val processor = DefaultSnapshotCompletionProcessor( + rumContextProvider = rumContextProvider, + recordWriter = writer, + internalLogger = mock(), + wireMapper = wireMapper + ) + val clock = FakeClock().apply { this.nowNs = nowNs } + + processor.process( + CompletedSnapshotCapture(generation(clock, deadlineNs = 10L), tree.snapshot) + ) + + verify(writer, never()).write(any(), any()) + } + + private companion object { + fun generation( + clock: CaptureTimeProvider, + id: Long = 1L, + deadlineNs: Long + ) = CaptureGenerationContext(id, 0L, deadlineNs, clock) + } + + private class FakeClock : CaptureTimeProvider { + var nowNs = 0L + + override fun elapsedRealtimeNanos(): Long = nowNs + } +} diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/SnapshotOrchestrationIntegrationTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/SnapshotOrchestrationIntegrationTest.kt new file mode 100644 index 0000000000..8a4c1d9861 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/SnapshotOrchestrationIntegrationTest.kt @@ -0,0 +1,118 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.composition + +import android.view.View +import com.datadog.android.api.InternalLogger +import com.datadog.android.sessionreplay.forge.ForgeConfigurator +import com.datadog.android.sessionreplay.internal.processor.EnrichedRecord +import com.datadog.android.sessionreplay.internal.storage.RecordWriter +import com.datadog.android.sessionreplay.internal.utils.RumContextProvider +import com.datadog.android.sessionreplay.internal.utils.SessionReplayRumContext +import fr.xgouchet.elmyr.annotation.StringForgery +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.Extensions +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.util.concurrent.ExecutorService + +@Extensions( + ExtendWith(ForgeExtension::class) +) +@ForgeConfiguration(ForgeConfigurator::class) +internal class SnapshotOrchestrationIntegrationTest { + + @Test + fun `M write completed snapshot W draw signal traverses orchestration boundary`( + @StringForgery fakeApplicationId: String, + @StringForgery fakeSessionId: String + ) { + // Given + val tree = compositionTestTree() + val scheduler = TestScheduler() + val expiryScheduler = TestScheduler() + val clock = CaptureTimeProvider { 0L } + val executor = mock() + val writer = mock() + val rumContextProvider = mock() + whenever(rumContextProvider.getRumContext()).thenReturn( + SessionReplayRumContext(fakeApplicationId, fakeSessionId, tree.scope.value) + ) + val completionQueue = SnapshotCompletionQueue( + executorService = executor, + processor = DefaultSnapshotCompletionProcessor( + rumContextProvider = rumContextProvider, + recordWriter = writer, + internalLogger = mock() + ), + internalLogger = mock() + ) + val orchestrator = SnapshotCaptureOrchestrator( + producer = CapturedSnapshotProducer { _, _ -> tree.snapshot }, + processor = ImmediateCapturedSnapshotProcessor(), + consumer = completionQueue, + timeProvider = clock, + captureScheduler = scheduler, + mainThreadExecutor = CaptureMainThreadExecutor { task -> + task() + CancellableCaptureWork.NONE + }, + expiryScheduler = expiryScheduler, + captureDelayNs = 0 + ) + val listener = CompositionOnDrawListener(mock()) { orchestrator.requestCapture() } + + // When + orchestrator.start() + listener.onDraw() + scheduler.runNext() + val completionTask = argumentCaptor() + verify(executor).execute(completionTask.capture()) + completionTask.firstValue.run() + + // Then + val record = argumentCaptor() + verify(writer).write(record.capture(), any()) + assertThat(record.firstValue.applicationId).isEqualTo(fakeApplicationId) + assertThat(record.firstValue.sessionId).isEqualTo(fakeSessionId) + assertThat(record.firstValue.viewId).isEqualTo(tree.scope.value) + assertThat(record.firstValue.records).hasSize(1) + } + + private class TestScheduler : CaptureTaskScheduler { + private val tasks = mutableListOf() + + override fun schedule(delayNs: Long, task: () -> Unit): CancellableCaptureWork = + TestTask(task).also(tasks::add) + + fun runNext() { + tasks.first { !it.cancelled }.run() + } + } + + private class TestTask( + private val task: () -> Unit + ) : CancellableCaptureWork { + var cancelled = false + + override fun cancel() { + cancelled = true + } + + fun run() { + task() + cancelled = true + } + } +} From e67102ff7cf56997bc4b5499cbc3686d8a222117 Mon Sep 17 00:00:00 2001 From: Jonathan Moskovich <48201295+jonathanmos@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:32:58 +0300 Subject: [PATCH 2/2] PANA-8613: View traversal and decomposition --- detekt_custom_safe_calls_third_party.yml | 7 + .../internal/DefaultRecorderProvider.kt | 104 +++++-- .../AndroidCapturedSnapshotProducer.kt | 89 ++++++ .../composition/AndroidWindowTraversal.kt | 241 +++++++++++++++ .../CapturedInteropViewCallback.kt | 23 ++ .../internal/composition/CapturedTree.kt | 4 + .../composition/RumViewScopeProvider.kt | 37 +++ .../CapturedBackgroundShapeStyleResolver.kt | 34 ++ .../mapper/CapturedHiddenViewMapper.kt | 40 +++ .../mapper/CapturedMapperTypeWrapper.kt | 21 ++ .../mapper/CapturedTextViewMapper.kt | 199 ++++++++++++ .../mapper/CapturedViewGroupFallbackMapper.kt | 44 +++ .../composition/mapper/CapturedViewMapper.kt | 31 ++ .../mapper/CapturedViewMapperRegistry.kt | 40 +++ .../mapper/CapturedWebViewMapper.kt | 42 +++ .../internal/DefaultRecorderProviderTest.kt | 38 ++- .../AndroidCapturedSnapshotProducerTest.kt | 247 +++++++++++++++ .../composition/AndroidWindowTraversalTest.kt | 291 ++++++++++++++++++ .../mapper/CapturedHiddenViewMapperTest.kt | 63 ++++ .../mapper/CapturedTextViewMapperTest.kt | 111 +++++++ .../CapturedViewGroupFallbackMapperTest.kt | 106 +++++++ .../mapper/CapturedViewMapperRegistryTest.kt | 84 +++++ .../mapper/CapturedWebViewMapperTest.kt | 71 +++++ 23 files changed, 1936 insertions(+), 31 deletions(-) create mode 100644 features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/AndroidCapturedSnapshotProducer.kt create mode 100644 features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/AndroidWindowTraversal.kt create mode 100644 features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CapturedInteropViewCallback.kt create mode 100644 features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/RumViewScopeProvider.kt create mode 100644 features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/mapper/CapturedBackgroundShapeStyleResolver.kt create mode 100644 features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/mapper/CapturedHiddenViewMapper.kt create mode 100644 features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/mapper/CapturedMapperTypeWrapper.kt create mode 100644 features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/mapper/CapturedTextViewMapper.kt create mode 100644 features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/mapper/CapturedViewGroupFallbackMapper.kt create mode 100644 features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/mapper/CapturedViewMapper.kt create mode 100644 features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/mapper/CapturedViewMapperRegistry.kt create mode 100644 features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/mapper/CapturedWebViewMapper.kt create mode 100644 features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/AndroidCapturedSnapshotProducerTest.kt create mode 100644 features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/AndroidWindowTraversalTest.kt create mode 100644 features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/mapper/CapturedHiddenViewMapperTest.kt create mode 100644 features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/mapper/CapturedTextViewMapperTest.kt create mode 100644 features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/mapper/CapturedViewGroupFallbackMapperTest.kt create mode 100644 features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/mapper/CapturedViewMapperRegistryTest.kt create mode 100644 features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/mapper/CapturedWebViewMapperTest.kt diff --git a/detekt_custom_safe_calls_third_party.yml b/detekt_custom_safe_calls_third_party.yml index dd20847d99..b80fd7dc72 100644 --- a/detekt_custom_safe_calls_third_party.yml +++ b/detekt_custom_safe_calls_third_party.yml @@ -608,6 +608,10 @@ datadog: - "kotlin.collections.MutableList.add(com.datadog.android.rum.model.ActionEvent.Type)" - "kotlin.collections.MutableList.add(com.datadog.android.sessionreplay.compose.internal.data.Parameter)" - "kotlin.collections.MutableList.add(com.datadog.android.sessionreplay.compose.internal.utils.BackgroundInfo)" + - "kotlin.collections.MutableList.add(com.datadog.android.sessionreplay.internal.composition.CapturedBounds)" + - "kotlin.collections.MutableList.add(com.datadog.android.sessionreplay.internal.composition.CapturedChild)" + - "kotlin.collections.MutableList.add(com.datadog.android.sessionreplay.internal.composition.CapturedLayer)" + - "kotlin.collections.MutableList.add(com.datadog.android.sessionreplay.internal.composition.CapturedWireframe)" - "kotlin.collections.MutableList.add(com.datadog.android.sessionreplay.internal.embedded.PendingBatch)" - "kotlin.collections.MutableList.add(com.datadog.android.sessionreplay.internal.prerequisite.SystemRequirementChecker)" - "kotlin.collections.MutableList.add(com.datadog.android.sessionreplay.internal.processor.MutationResolver.Entry)" @@ -786,6 +790,9 @@ datadog: - "kotlin.collections.listOf(com.datadog.android.rum.model.VitalAppLaunchEvent.Interface)" - "kotlin.collections.listOf(com.datadog.android.rum.model.VitalOperationStepEvent.Interface)" - "kotlin.collections.listOf(com.datadog.android.sessionreplay.MapperTypeWrapper)" + - "kotlin.collections.listOf(com.datadog.android.sessionreplay.internal.composition.CapturedWireframe.PrivacyPlaceholder)" + - "kotlin.collections.listOf(com.datadog.android.sessionreplay.internal.composition.CapturedWireframe.Shape)" + - "kotlin.collections.listOf(com.datadog.android.sessionreplay.internal.composition.CapturedWireframe.WebView)" - "kotlin.collections.listOf(com.datadog.android.sessionreplay.internal.recorder.DefaultOptionSelectorDetector)" - "kotlin.collections.listOf(com.datadog.android.sessionreplay.material.internal.MaterialDrawableToColorMapper)" - "kotlin.collections.listOf(com.datadog.android.sessionreplay.material.internal.MaterialOptionSelectorDetector)" diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/DefaultRecorderProvider.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/DefaultRecorderProvider.kt index 3098a2bf0f..af41ee8237 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/DefaultRecorderProvider.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/DefaultRecorderProvider.kt @@ -29,7 +29,9 @@ import com.datadog.android.sessionreplay.MapperTypeWrapper import com.datadog.android.sessionreplay.SessionReplayInternalCallback import com.datadog.android.sessionreplay.TextAndInputPrivacy import com.datadog.android.sessionreplay.internal.composition.ActiveWindowSource +import com.datadog.android.sessionreplay.internal.composition.AndroidCapturedSnapshotProducer import com.datadog.android.sessionreplay.internal.composition.AndroidSnapshotCaptureLifecycle +import com.datadog.android.sessionreplay.internal.composition.AndroidWindowTraversal import com.datadog.android.sessionreplay.internal.composition.CapturePipelineSelector import com.datadog.android.sessionreplay.internal.composition.CaptureSkippedFrameNotifier import com.datadog.android.sessionreplay.internal.composition.CaptureTimeBudget @@ -38,6 +40,7 @@ import com.datadog.android.sessionreplay.internal.composition.CompositionCapture import com.datadog.android.sessionreplay.internal.composition.CompositionChangeListener import com.datadog.android.sessionreplay.internal.composition.CompositionChangeset import com.datadog.android.sessionreplay.internal.composition.CompositionViewOnDrawInterceptor +import com.datadog.android.sessionreplay.internal.composition.DefaultRumViewScopeProvider import com.datadog.android.sessionreplay.internal.composition.DefaultSnapshotCompletionProcessor import com.datadog.android.sessionreplay.internal.composition.HandlerCaptureMainThreadExecutor import com.datadog.android.sessionreplay.internal.composition.HandlerCaptureTaskScheduler @@ -47,6 +50,11 @@ import com.datadog.android.sessionreplay.internal.composition.SnapshotCaptureOrc import com.datadog.android.sessionreplay.internal.composition.SnapshotCompletionQueue import com.datadog.android.sessionreplay.internal.composition.TimeBankCaptureTimeBudget import com.datadog.android.sessionreplay.internal.composition.TimeProviderCaptureTimeProvider +import com.datadog.android.sessionreplay.internal.composition.mapper.CapturedMapperTypeWrapper +import com.datadog.android.sessionreplay.internal.composition.mapper.CapturedTextViewMapper +import com.datadog.android.sessionreplay.internal.composition.mapper.CapturedViewGroupFallbackMapper +import com.datadog.android.sessionreplay.internal.composition.mapper.CapturedViewMapperRegistry +import com.datadog.android.sessionreplay.internal.composition.mapper.CapturedWebViewMapper import com.datadog.android.sessionreplay.internal.embedded.EmbeddedContentSlotRegistry import com.datadog.android.sessionreplay.internal.recorder.Recorder import com.datadog.android.sessionreplay.internal.recorder.RecordingTimeBank @@ -93,9 +101,9 @@ internal class DefaultRecorderProvider( private val heatmapsEnabled: Boolean, private val compositionTreeRecordingEnabled: Boolean, private val compositionPipelineFactory: (() -> Recorder)? = null, - private val compositionSnapshotProducerFactory: (ActiveWindowSource) -> CapturedSnapshotProducer = { - CapturedSnapshotProducer { _, _ -> null } - }, + private val compositionSnapshotProducerFactory: ( + (ActiveWindowSource, RumContextProvider) -> CapturedSnapshotProducer + )? = null, private val recordingTimeBankFactory: () -> TimeBank = { RecordingTimeBank() } ) : RecorderProvider { @@ -154,23 +162,61 @@ internal class DefaultRecorderProvider( application: Application ): Recorder { val internalLogger = sdkCore.internalLogger - val timeProvider = TimeProviderCaptureTimeProvider(sdkCore.timeProvider) - val skippedFrameNotifier = CaptureSkippedFrameNotifier(sdkCore) val windowSource = ActiveWindowSource() - val completionQueue = SnapshotCompletionQueue( - executorService = sdkCore.createSingleThreadExecutorService("sr-composition-processing"), - processor = DefaultSnapshotCompletionProcessor( - rumContextProvider = rumContextProvider, - recordWriter = recordWriter, - internalLogger = internalLogger - ), + val completionQueue = createCompletionQueue(recordWriter, rumContextProvider, internalLogger) + val orchestrator = createCaptureOrchestrator(windowSource, rumContextProvider, completionQueue, internalLogger) + val interceptor = CompositionViewOnDrawInterceptor( + windowSource = windowSource, + onWindowsChanged = CompositionChangeListener { windows -> + orchestrator.requestCapture(CompositionChangeset.of(windows)) + }, internalLogger = internalLogger ) - val orchestrator = SnapshotCaptureOrchestrator( - producer = compositionSnapshotProducerFactory(windowSource), + return CompositionCapturePipeline( + orchestrator = orchestrator, + lifecycle = AndroidSnapshotCaptureLifecycle( + application = application, + interceptor = interceptor, + internalLogger = internalLogger, + currentActivity = internalCallback.getCurrentActivity() + ), + completionQueue = completionQueue + ) + } + + private fun createCompletionQueue( + recordWriter: RecordWriter, + rumContextProvider: RumContextProvider, + internalLogger: InternalLogger + ): SnapshotCompletionQueue = SnapshotCompletionQueue( + executorService = sdkCore.createSingleThreadExecutorService("sr-composition-processing"), + processor = DefaultSnapshotCompletionProcessor( + rumContextProvider = rumContextProvider, + recordWriter = recordWriter, + internalLogger = internalLogger + ), + internalLogger = internalLogger + ) + + private fun createCaptureOrchestrator( + windowSource: ActiveWindowSource, + rumContextProvider: RumContextProvider, + completionQueue: SnapshotCompletionQueue, + internalLogger: InternalLogger + ): SnapshotCaptureOrchestrator { + val skippedFrameNotifier = CaptureSkippedFrameNotifier(sdkCore) + val producer = compositionSnapshotProducerFactory?.invoke(windowSource, rumContextProvider) + ?: AndroidCapturedSnapshotProducer( + windowSource = windowSource, + scopeProvider = DefaultRumViewScopeProvider(rumContextProvider), + timeProvider = sdkCore.timeProvider, + traversal = AndroidWindowTraversal(mapperRegistry = builtInCapturedMappers()) + ) + return SnapshotCaptureOrchestrator( + producer = producer, processor = ImmediateCapturedSnapshotProcessor(), consumer = completionQueue, - timeProvider = timeProvider, + timeProvider = TimeProviderCaptureTimeProvider(sdkCore.timeProvider), captureScheduler = HandlerCaptureTaskScheduler(), mainThreadExecutor = HandlerCaptureMainThreadExecutor(), expiryScheduler = ScheduledExecutorCaptureTaskScheduler( @@ -187,22 +233,20 @@ internal class DefaultRecorderProvider( }, internalLogger = internalLogger ) - val interceptor = CompositionViewOnDrawInterceptor( - windowSource = windowSource, - onWindowsChanged = CompositionChangeListener { windows -> - orchestrator.requestCapture(CompositionChangeset.of(windows)) - }, - internalLogger = internalLogger - ) - return CompositionCapturePipeline( - orchestrator = orchestrator, - lifecycle = AndroidSnapshotCaptureLifecycle( - application = application, - interceptor = interceptor, - internalLogger = internalLogger, - currentActivity = internalCallback.getCurrentActivity() + } + + private fun builtInCapturedMappers(): CapturedViewMapperRegistry { + val internalLogger = sdkCore.internalLogger + return CapturedViewMapperRegistry( + mappers = listOf( + CapturedMapperTypeWrapper(WebView::class.java, CapturedWebViewMapper()), + CapturedMapperTypeWrapper( + TextView::class.java, + CapturedTextViewMapper(internalLogger = internalLogger) + ) ), - completionQueue = completionQueue + fallbackMapper = CapturedViewGroupFallbackMapper(internalLogger = internalLogger), + internalLogger = internalLogger ) } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/AndroidCapturedSnapshotProducer.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/AndroidCapturedSnapshotProducer.kt new file mode 100644 index 0000000000..ef69270a48 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/AndroidCapturedSnapshotProducer.kt @@ -0,0 +1,89 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.composition + +import android.view.View +import androidx.annotation.MainThread +import com.datadog.android.internal.time.TimeProvider +import com.datadog.android.sessionreplay.utils.DefaultViewIdentifierResolver +import com.datadog.android.sessionreplay.utils.ViewIdentifierResolver + +/** + * The real [CapturedSnapshotProducer] for the plain Android View hierarchy - the workstream-3 + * implementation of the extension point [SnapshotCaptureOrchestrator] drives every generation. + * Builds a fresh [CapturedIdentityFactory] per call (this workstream only produces full snapshots; + * an identity factory persisting across generations for incremental diffing is a later workstream's + * concern), walks every currently active window via [AndroidWindowTraversal], and assembles them + * under one synthetic screen root in [ActiveWindowSource.currentWindows] order (already z-ordered). + */ +internal class AndroidCapturedSnapshotProducer( + private val windowSource: ActiveWindowSource, + private val scopeProvider: RumViewScopeProvider, + private val timeProvider: TimeProvider, + private val traversal: AndroidWindowTraversal, + private val viewIdentifierResolver: ViewIdentifierResolver = DefaultViewIdentifierResolver +) : CapturedSnapshotProducer { + + @MainThread + override fun capture(context: CaptureGenerationContext, changeset: CaptureChangeset): CapturedFullSnapshot? { + val rumViewScope = scopeProvider.currentScope() ?: return null + val identityFactory = DefaultCapturedIdentityFactory(rumViewScope.scope) + val walk = walkWindows(windowSource.currentWindows(), identityFactory, context) + + return walk?.let { + val root = CapturedLayer( + identity = identityFactory.screenRoot(), + kind = CapturedLayerKind.SYNTHETIC_SCREEN_ROOT, + bounds = it.windowLayers.firstOrNull()?.bounds ?: CapturedBounds(0, 0, 0, 0), + children = it.windowLayers.map { layer -> CapturedChild.Layer(layer.identity) } + ) + CapturedFullSnapshot( + timestamp = timeProvider.getDeviceTimestampMillis() + rumViewScope.viewTimeOffsetMs, + scope = rumViewScope.scope, + root = root, + layers = it.layers, + wireframes = it.wireframes + ) + } + } + + private fun walkWindows( + windows: List, + identityFactory: CapturedIdentityFactory, + context: CaptureGenerationContext + ): WindowsWalkAccumulation? { + if (windows.isEmpty()) return null + val layers = mutableListOf() + val wireframes = mutableListOf() + val windowLayers = mutableListOf() + var aborted = false + + for (window in windows) { + val windowIdentity = identityFactory.window(viewIdentifierResolver.resolveViewId(window).toString()) + when (val result = traversal.traverseWindow(window, windowIdentity, identityFactory, context)) { + is WindowWalkResult.Present -> { + windowLayers += result.rootLayer + layers += result.layers + wireframes += result.wireframes + } + WindowWalkResult.Filtered -> Unit + WindowWalkResult.Aborted -> { + aborted = true + } + } + if (aborted) break + } + + return if (aborted) null else WindowsWalkAccumulation(windowLayers, layers, wireframes) + } + + private class WindowsWalkAccumulation( + val windowLayers: List, + val layers: List, + val wireframes: List + ) +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/AndroidWindowTraversal.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/AndroidWindowTraversal.kt new file mode 100644 index 0000000000..540da3055c --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/AndroidWindowTraversal.kt @@ -0,0 +1,241 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.composition + +import android.view.View +import android.view.ViewGroup +import com.datadog.android.sessionreplay.R +import com.datadog.android.sessionreplay.internal.composition.mapper.CapturedHiddenViewMapper +import com.datadog.android.sessionreplay.internal.composition.mapper.CapturedMappingContext +import com.datadog.android.sessionreplay.internal.composition.mapper.CapturedViewMapper +import com.datadog.android.sessionreplay.internal.composition.mapper.CapturedViewMapperRegistry +import com.datadog.android.sessionreplay.internal.composition.mapper.CapturedViewMapperResult +import com.datadog.android.sessionreplay.internal.recorder.ViewUtilsInternal +import com.datadog.android.sessionreplay.utils.DefaultViewBoundsResolver +import com.datadog.android.sessionreplay.utils.DefaultViewIdentifierResolver +import com.datadog.android.sessionreplay.utils.ViewBoundsResolver +import com.datadog.android.sessionreplay.utils.ViewIdentifierResolver +import kotlin.math.max + +internal sealed interface WindowWalkResult { + data class Present( + val rootLayer: CapturedLayer, + val layers: List, + val wireframes: List + ) : WindowWalkResult + + /** The window (e.g. not shown, or on a secondary display) contributes nothing - skip it. */ + object Filtered : WindowWalkResult + + /** The deadline expired mid-walk - the whole capture must be discarded, not just this window. */ + object Aborted : WindowWalkResult +} + +/** + * Walks one window's native View hierarchy into a [CapturedLayer] tree, combining legacy + * `TreeViewTraversal` (per-view decisions) and `SnapshotProducer` (recursion) into a single pass, + * with clip computed inline via a threaded ancestor-bounds stack instead of a separate flatten step. + * + * Every visited [View] becomes exactly one [CapturedLayer] (kind [CapturedLayerKind.NATIVE_VIEW], + * or [CapturedLayerKind.WINDOW_ROOT] for the window's own root). Its identity is always created via + * [CapturedIdentityFactory.view] against the *window's* identity - never the view's real structural + * parent - per the flat-namespacing rule [CapturedIdentityFactory] enforces; real nesting is carried + * purely by [CapturedLayer.children]. + */ +internal class AndroidWindowTraversal( + private val mapperRegistry: CapturedViewMapperRegistry, + private val hiddenViewMapper: CapturedViewMapper = CapturedHiddenViewMapper(), + private val viewIdentifierResolver: ViewIdentifierResolver = DefaultViewIdentifierResolver, + private val viewBoundsResolver: ViewBoundsResolver = DefaultViewBoundsResolver, + private val viewUtilsInternal: ViewUtilsInternal = ViewUtilsInternal(), + private val composeHostCallback: CapturedInteropViewCallback? = null, + private val viewsPerCheckpoint: Int = VIEWS_PER_CHECKPOINT +) { + + fun traverseWindow( + windowRoot: View, + windowIdentity: CapturedIdentity, + identityFactory: CapturedIdentityFactory, + context: CaptureGenerationContext + ): WindowWalkResult { + if (!context.shouldContinue()) return WindowWalkResult.Aborted + val state = TraversalState(screenDensity = windowRoot.resources.displayMetrics.density) + return when ( + val result = visitView( + view = windowRoot, + ownIdentity = windowIdentity, + ownKind = CapturedLayerKind.WINDOW_ROOT, + windowIdentity = windowIdentity, + identityFactory = identityFactory, + context = context, + state = state + ) + ) { + is LayerWalkResult.Present -> + WindowWalkResult.Present(result.layer, state.layers, state.wireframes) + LayerWalkResult.Filtered -> WindowWalkResult.Filtered + LayerWalkResult.Aborted -> WindowWalkResult.Aborted + } + } + + @Suppress("ReturnCount") + private fun visitView( + view: View, + ownIdentity: CapturedIdentity, + ownKind: CapturedLayerKind, + windowIdentity: CapturedIdentity, + identityFactory: CapturedIdentityFactory, + context: CaptureGenerationContext, + state: TraversalState + ): LayerWalkResult { + state.viewsVisited++ + if (state.viewsVisited % viewsPerCheckpoint == 0 && !context.shouldContinue()) { + return LayerWalkResult.Aborted + } + if (viewUtilsInternal.isNotVisible(view) || + viewUtilsInternal.isSystemNoise(view) || + viewUtilsInternal.isOnSecondaryDisplay(view) + ) { + return LayerWalkResult.Filtered + } + + val bounds = viewBoundsResolver.resolveViewGlobalBounds(view, state.screenDensity).toCaptured() + val mappingContext = CapturedMappingContext(identityFactory, ownIdentity, state.screenDensity) + val isHidden = view.getTag(R.id.datadog_hidden) == true + + val children = mutableListOf() + val interopResult = composeHostCallback?.takeIf { isComposeHost(view) }?.map(view, mappingContext) + val mapped = interopResult + ?: (if (isHidden) hiddenViewMapper else mapperRegistry.resolve(view)).map(view, mappingContext) + addWireframes(mapped, state.ancestorBounds, children, state) + + if (!isHidden && interopResult == null && view is ViewGroup) { + val aborted = visitChildren(view, windowIdentity, identityFactory, context, state, bounds, children) + if (aborted) return LayerWalkResult.Aborted + } + + val layer = CapturedLayer(identity = ownIdentity, kind = ownKind, bounds = bounds, children = children) + state.layers.add(layer) + return LayerWalkResult.Present(layer) + } + + /** + * A Compose host's interior is Compose's own node tree, not further Android child Views - its + * content is fully described by whatever `composeHostCallback` returned in [visitView], which + * is why that case never reaches here. + */ + @Suppress("LongParameterList") + private fun visitChildren( + viewGroup: ViewGroup, + windowIdentity: CapturedIdentity, + identityFactory: CapturedIdentityFactory, + context: CaptureGenerationContext, + state: TraversalState, + ownBounds: CapturedBounds, + children: MutableList + ): Boolean { + state.ancestorBounds.add(ownBounds) + try { + for (i in 0 until viewGroup.childCount) { + val child = viewGroup.getChildAt(i) ?: continue + val childIdentity = identityFactory.view( + windowIdentity, + viewIdentifierResolver.resolveViewId(child).toString() + ) + when ( + val childResult = visitView( + view = child, + ownIdentity = childIdentity, + ownKind = CapturedLayerKind.NATIVE_VIEW, + windowIdentity = windowIdentity, + identityFactory = identityFactory, + context = context, + state = state + ) + ) { + is LayerWalkResult.Present -> children.add(CapturedChild.Layer(childResult.layer.identity)) + LayerWalkResult.Filtered -> Unit + LayerWalkResult.Aborted -> return true + } + } + } finally { + state.ancestorBounds.removeAt(state.ancestorBounds.lastIndex) + } + return false + } + + private fun addWireframes( + result: CapturedViewMapperResult, + ancestorBounds: List, + children: MutableList, + state: TraversalState + ) { + if (result !is CapturedViewMapperResult.Wireframes) return + for (wireframe in result.wireframes) { + val clipped = wireframe.withClip(computeClip(wireframe.bounds, ancestorBounds)) + state.wireframes.add(clipped) + children.add(CapturedChild.Wireframe(clipped.identity)) + } + } + + private fun computeClip(bounds: CapturedBounds, ancestorBounds: List): CapturedClip? { + var clipTop = 0L + var clipBottom = 0L + var clipLeft = 0L + var clipRight = 0L + val bottom = bounds.y + bounds.height + val right = bounds.x + bounds.width + for (ancestor in ancestorBounds) { + clipTop = max(ancestor.y - bounds.y, clipTop) + clipBottom = max(bottom - (ancestor.y + ancestor.height), clipBottom) + clipLeft = max(ancestor.x - bounds.x, clipLeft) + clipRight = max(right - (ancestor.x + ancestor.width), clipRight) + } + val hasNoClip = listOf(clipTop, clipBottom, clipLeft, clipRight).all { it <= 0 } + if (hasNoClip) return null + return CapturedClip( + top = clipTop.takeIf { it > 0 }, + bottom = clipBottom.takeIf { it > 0 }, + left = clipLeft.takeIf { it > 0 }, + right = clipRight.takeIf { it > 0 } + ) + } + + /** + * Detected by class name only, deliberately with no compile-time `androidx.compose` dependency + * from this module. `ComposeView` is the public entry point apps add to a native layout; the + * internal `AndroidComposeView` it creates as its single child is Compose-owned and never itself + * an addressable child in the surrounding native hierarchy. + */ + private fun isComposeHost(view: View): Boolean = view.javaClass.name == COMPOSE_VIEW_CLASS_NAME + + private fun CapturedWireframe.withClip(clip: CapturedClip?): CapturedWireframe = when (this) { + is CapturedWireframe.Shape -> copy(clip = clip) + is CapturedWireframe.Text -> copy(clip = clip) + is CapturedWireframe.WebView -> copy(clip = clip) + is CapturedWireframe.Pixel -> copy(clip = clip) + is CapturedWireframe.PrivacyPlaceholder -> copy(clip = clip) + } + + private sealed interface LayerWalkResult { + data class Present(val layer: CapturedLayer) : LayerWalkResult + object Filtered : LayerWalkResult + object Aborted : LayerWalkResult + } + + private class TraversalState(val screenDensity: Float) { + var viewsVisited = 0 + val layers = mutableListOf() + val wireframes = mutableListOf() + val ancestorBounds = mutableListOf() + } + + private companion object { + const val VIEWS_PER_CHECKPOINT = 200 + const val COMPOSE_VIEW_CLASS_NAME = "androidx.compose.ui.platform.ComposeView" + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CapturedInteropViewCallback.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CapturedInteropViewCallback.kt new file mode 100644 index 0000000000..3f74afb33b --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CapturedInteropViewCallback.kt @@ -0,0 +1,23 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.composition + +import android.view.View +import com.datadog.android.sessionreplay.internal.composition.mapper.CapturedMappingContext +import com.datadog.android.sessionreplay.internal.composition.mapper.CapturedViewMapperResult + +/** + * The seam a future Compose semantics-tree walker (not yet built) can call into when it hits an + * embedded native `AndroidView`, to reuse this module's native mapper registry - mirroring legacy's + * one-directional `InteropViewCallback`. No reverse (native-calls-into-Compose) callback exists yet + * since nothing needs it: a bare `ComposeView` encountered during native traversal falls through to + * the generic [com.datadog.android.sessionreplay.internal.composition.mapper.CapturedViewGroupFallbackMapper], + * matching current production behavior. + */ +internal fun interface CapturedInteropViewCallback { + fun map(view: View, mappingContext: CapturedMappingContext): CapturedViewMapperResult +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CapturedTree.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CapturedTree.kt index 3858886a3a..9697b32d5e 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CapturedTree.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CapturedTree.kt @@ -6,6 +6,8 @@ package com.datadog.android.sessionreplay.internal.composition +import com.datadog.android.sessionreplay.utils.GlobalBounds + /** * Discovers roots and synchronously inspects Android/Compose state on the main thread. Concrete * walkers must use [CaptureGenerationContext.shouldContinue] between bounded operations and may @@ -44,6 +46,8 @@ internal data class CapturedBounds( val height: Long ) +internal fun GlobalBounds.toCaptured() = CapturedBounds(x, y, width, height) + internal enum class CapturedLayerKind { SYNTHETIC_SCREEN_ROOT, WINDOW_ROOT, diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/RumViewScopeProvider.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/RumViewScopeProvider.kt new file mode 100644 index 0000000000..205de4da74 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/RumViewScopeProvider.kt @@ -0,0 +1,37 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.composition + +import com.datadog.android.sessionreplay.internal.utils.RumContextProvider + +/** The current RUM view's identity scope and time offset, or null when there is no active view. */ +internal data class CapturedRumViewScope( + val scope: RumViewIdentityScope, + val viewTimeOffsetMs: Long +) + +/** + * Isolates the traversal layer from [com.datadog.android.sessionreplay.internal.utils.SessionReplayRumContext] + * details it doesn't need beyond the identity scope a capture must be tagged with and the offset + * used to timestamp it. + */ +internal fun interface RumViewScopeProvider { + fun currentScope(): CapturedRumViewScope? +} + +internal class DefaultRumViewScopeProvider( + private val rumContextProvider: RumContextProvider +) : RumViewScopeProvider { + override fun currentScope(): CapturedRumViewScope? { + val rumContext = rumContextProvider.getRumContext() + if (rumContext.isNotValid()) return null + return CapturedRumViewScope( + scope = RumViewIdentityScope(rumContext.viewId), + viewTimeOffsetMs = rumContext.viewTimeOffsetMs + ) + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/mapper/CapturedBackgroundShapeStyleResolver.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/mapper/CapturedBackgroundShapeStyleResolver.kt new file mode 100644 index 0000000000..94131f287e --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/mapper/CapturedBackgroundShapeStyleResolver.kt @@ -0,0 +1,34 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.composition.mapper + +import android.view.View +import com.datadog.android.api.InternalLogger +import com.datadog.android.sessionreplay.internal.composition.CapturedShapeStyle +import com.datadog.android.sessionreplay.utils.ColorStringFormatter +import com.datadog.android.sessionreplay.utils.DefaultColorStringFormatter +import com.datadog.android.sessionreplay.utils.DrawableToColorMapper + +/** + * Resolves a view's background drawable to a solid-color [CapturedShapeStyle], or null if the + * drawable can't be reduced to a single color. Mirrors legacy `BaseWireframeMapper.resolveShapeStyle`, + * re-inlined here rather than reused directly since that method is `protected` on a base class + * belonging to a different wireframe model hierarchy. + */ +internal class CapturedBackgroundShapeStyleResolver( + private val colorStringFormatter: ColorStringFormatter = DefaultColorStringFormatter, + private val drawableToColorMapper: DrawableToColorMapper = DrawableToColorMapper.getDefault() +) { + fun resolve(view: View, internalLogger: InternalLogger): CapturedShapeStyle? { + val color = view.background?.let { drawableToColorMapper.mapDrawableToColor(it, internalLogger) } + ?: return null + return CapturedShapeStyle( + backgroundColor = colorStringFormatter.formatColorAsHexString(color), + opacity = view.alpha + ) + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/mapper/CapturedHiddenViewMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/mapper/CapturedHiddenViewMapper.kt new file mode 100644 index 0000000000..8dd167865e --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/mapper/CapturedHiddenViewMapper.kt @@ -0,0 +1,40 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.composition.mapper + +import android.view.View +import com.datadog.android.sessionreplay.internal.composition.CapturedWireframe +import com.datadog.android.sessionreplay.internal.composition.toCaptured +import com.datadog.android.sessionreplay.utils.DefaultViewBoundsResolver +import com.datadog.android.sessionreplay.utils.ViewBoundsResolver + +/** + * Emitted for views tagged `R.id.datadog_hidden`, in place of the normal mapper lookup, matching + * legacy `HiddenViewMapper`. The traversal must not recurse into this view's children. + */ +internal class CapturedHiddenViewMapper( + private val viewBoundsResolver: ViewBoundsResolver = DefaultViewBoundsResolver +) : CapturedViewMapper { + + override fun map(view: View, mappingContext: CapturedMappingContext): CapturedViewMapperResult { + val bounds = viewBoundsResolver.resolveViewGlobalBounds(view, mappingContext.screenDensity) + val identity = mappingContext.identityFactory.placeholderWireframe(mappingContext.ownerIdentity) + return CapturedViewMapperResult.Wireframes( + listOf( + CapturedWireframe.PrivacyPlaceholder( + identity = identity, + bounds = bounds.toCaptured(), + label = HIDDEN_VIEW_PLACEHOLDER_TEXT + ) + ) + ) + } + + companion object { + internal const val HIDDEN_VIEW_PLACEHOLDER_TEXT = "Hidden" + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/mapper/CapturedMapperTypeWrapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/mapper/CapturedMapperTypeWrapper.kt new file mode 100644 index 0000000000..38997a8da8 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/mapper/CapturedMapperTypeWrapper.kt @@ -0,0 +1,21 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.composition.mapper + +import android.view.View + +internal class CapturedMapperTypeWrapper( + private val type: Class, + private val mapper: CapturedViewMapper +) { + // view::class.java is a non-null Kotlin type, so it can't be the null that would make this throw + @Suppress("UnsafeThirdPartyFunctionCall") + fun supportsView(view: View): Boolean = type.isAssignableFrom(view::class.java) + + @Suppress("UNCHECKED_CAST") + fun getUnsafeMapper(): CapturedViewMapper = mapper as CapturedViewMapper +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/mapper/CapturedTextViewMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/mapper/CapturedTextViewMapper.kt new file mode 100644 index 0000000000..ba22c02ed3 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/mapper/CapturedTextViewMapper.kt @@ -0,0 +1,199 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.composition.mapper + +import android.graphics.Typeface +import android.text.TextUtils +import android.view.Gravity +import android.widget.TextView +import com.datadog.android.api.InternalLogger +import com.datadog.android.internal.utils.densityNormalized +import com.datadog.android.sessionreplay.internal.composition.CapturedAlignment +import com.datadog.android.sessionreplay.internal.composition.CapturedHorizontalAlignment +import com.datadog.android.sessionreplay.internal.composition.CapturedPadding +import com.datadog.android.sessionreplay.internal.composition.CapturedTextPosition +import com.datadog.android.sessionreplay.internal.composition.CapturedTextStyle +import com.datadog.android.sessionreplay.internal.composition.CapturedTruncationMode +import com.datadog.android.sessionreplay.internal.composition.CapturedVerticalAlignment +import com.datadog.android.sessionreplay.internal.composition.CapturedWireframe +import com.datadog.android.sessionreplay.internal.composition.toCaptured +import com.datadog.android.sessionreplay.utils.ColorStringFormatter +import com.datadog.android.sessionreplay.utils.DefaultColorStringFormatter +import com.datadog.android.sessionreplay.utils.DefaultViewBoundsResolver +import com.datadog.android.sessionreplay.utils.OPAQUE_ALPHA_VALUE +import com.datadog.android.sessionreplay.utils.ViewBoundsResolver + +/** + * The flagship semantic wireframe for this workstream: a real, unmasked text capture. Text/input + * privacy masking is explicitly out of scope here - it is owned by the pixel-fallback/privacy + * workstream, which applies masking policy uniformly across text and images before anything is + * uploadable. Font-family bucketing, truncation-mode mapping, and padding/alignment resolution are + * pure functions over a [TextView] ported verbatim from legacy `TextViewMapper`. + */ +internal class CapturedTextViewMapper( + private val viewBoundsResolver: ViewBoundsResolver = DefaultViewBoundsResolver, + private val colorStringFormatter: ColorStringFormatter = DefaultColorStringFormatter, + private val backgroundShapeStyleResolver: CapturedBackgroundShapeStyleResolver = + CapturedBackgroundShapeStyleResolver(), + private val internalLogger: InternalLogger +) : CapturedViewMapper { + + override fun map(view: TextView, mappingContext: CapturedMappingContext): CapturedViewMapperResult { + val wireframes = mutableListOf() + val bounds = viewBoundsResolver.resolveViewGlobalBounds(view, mappingContext.screenDensity) + val capturedBounds = bounds.toCaptured() + + backgroundShapeStyleResolver.resolve(view, internalLogger)?.let { style -> + wireframes += CapturedWireframe.Shape( + identity = mappingContext.identityFactory.shapeWireframe(mappingContext.ownerIdentity), + bounds = capturedBounds, + style = style + ) + } + + wireframes += CapturedWireframe.Text( + identity = mappingContext.identityFactory.textWireframe(mappingContext.ownerIdentity), + bounds = capturedBounds, + text = resolveLayoutText(view), + textStyle = resolveTextStyle(view, mappingContext.screenDensity), + textPosition = resolveTextPosition(view, mappingContext.screenDensity) + ) + + return CapturedViewMapperResult.Wireframes(wireframes) + } + + private fun resolveLayoutText(textView: TextView): String = + (textView.layout?.text ?: textView.text)?.toString().orEmpty() + + private fun resolveTextStyle(textView: TextView, pixelsDensity: Float): CapturedTextStyle { + return CapturedTextStyle( + family = resolveFontFamily(textView.typeface), + size = textView.textSize.toLong().densityNormalized(pixelsDensity), + color = resolveTextColor(textView), + truncationMode = resolveTruncationMode(textView) + ) + } + + private fun resolveTextColor(textView: TextView): String { + return if (textView.text.isNullOrEmpty()) { + resolveHintTextColor(textView) + } else { + colorStringFormatter.formatColorAndAlphaAsHexString(textView.currentTextColor, OPAQUE_ALPHA_VALUE) + } + } + + private fun resolveHintTextColor(textView: TextView): String { + val hintTextColors = textView.hintTextColors + return if (hintTextColors != null) { + colorStringFormatter.formatColorAndAlphaAsHexString(hintTextColors.defaultColor, OPAQUE_ALPHA_VALUE) + } else { + colorStringFormatter.formatColorAndAlphaAsHexString(textView.currentTextColor, OPAQUE_ALPHA_VALUE) + } + } + + private fun resolveFontFamily(typeface: Typeface?): String { + return when (typeface) { + Typeface.SANS_SERIF -> SANS_SERIF_FAMILY_NAME + Typeface.MONOSPACE -> MONOSPACE_FAMILY_NAME + Typeface.SERIF -> SERIF_FAMILY_NAME + else -> SANS_SERIF_FAMILY_NAME + } + } + + private fun resolveTruncationMode(textView: TextView): CapturedTruncationMode? { + return textView.ellipsize?.let { truncationMode -> + when (truncationMode) { + TextUtils.TruncateAt.START -> CapturedTruncationMode.HEAD + TextUtils.TruncateAt.END -> CapturedTruncationMode.TAIL + TextUtils.TruncateAt.MIDDLE -> CapturedTruncationMode.MIDDLE + TextUtils.TruncateAt.MARQUEE -> CapturedTruncationMode.CLIP + } + } + } + + private fun resolveTextPosition(textView: TextView, pixelsDensity: Float): CapturedTextPosition { + return CapturedTextPosition( + padding = resolvePadding(textView, pixelsDensity), + alignment = resolveAlignment(textView) + ) + } + + private fun resolvePadding(textView: TextView, pixelsDensity: Float): CapturedPadding { + return if (textView.layout != null) { + CapturedPadding( + top = textView.totalPaddingTop.densityNormalized(pixelsDensity).toLong(), + bottom = textView.totalPaddingBottom.densityNormalized(pixelsDensity).toLong(), + left = textView.totalPaddingStart.densityNormalized(pixelsDensity).toLong(), + right = textView.totalPaddingEnd.densityNormalized(pixelsDensity).toLong() + ) + } else { + CapturedPadding( + top = textView.paddingTop.densityNormalized(pixelsDensity).toLong(), + bottom = textView.paddingBottom.densityNormalized(pixelsDensity).toLong(), + left = textView.paddingStart.densityNormalized(pixelsDensity).toLong(), + right = textView.paddingEnd.densityNormalized(pixelsDensity).toLong() + ) + } + } + + private fun resolveAlignment(textView: TextView): CapturedAlignment { + return when (textView.textAlignment) { + TextView.TEXT_ALIGNMENT_CENTER -> CapturedAlignment( + horizontal = CapturedHorizontalAlignment.CENTER, + vertical = CapturedVerticalAlignment.CENTER + ) + + TextView.TEXT_ALIGNMENT_TEXT_END, + TextView.TEXT_ALIGNMENT_VIEW_END -> CapturedAlignment( + horizontal = CapturedHorizontalAlignment.RIGHT, + vertical = CapturedVerticalAlignment.CENTER + ) + + TextView.TEXT_ALIGNMENT_TEXT_START, + TextView.TEXT_ALIGNMENT_VIEW_START -> CapturedAlignment( + horizontal = CapturedHorizontalAlignment.LEFT, + vertical = CapturedVerticalAlignment.CENTER + ) + + TextView.TEXT_ALIGNMENT_GRAVITY -> resolveAlignmentFromGravity(textView) + else -> CapturedAlignment( + horizontal = CapturedHorizontalAlignment.LEFT, + vertical = CapturedVerticalAlignment.CENTER + ) + } + } + + private fun resolveAlignmentFromGravity(textView: TextView): CapturedAlignment { + val horizontalAlignment = when (textView.gravity.and(Gravity.HORIZONTAL_GRAVITY_MASK)) { + Gravity.START, + Gravity.LEFT -> CapturedHorizontalAlignment.LEFT + + Gravity.END, + Gravity.RIGHT -> CapturedHorizontalAlignment.RIGHT + + Gravity.CENTER, + Gravity.CENTER_HORIZONTAL -> CapturedHorizontalAlignment.CENTER + + else -> CapturedHorizontalAlignment.LEFT + } + val verticalAlignment = when (textView.gravity.and(Gravity.VERTICAL_GRAVITY_MASK)) { + Gravity.TOP -> CapturedVerticalAlignment.TOP + Gravity.BOTTOM -> CapturedVerticalAlignment.BOTTOM + Gravity.CENTER_VERTICAL, + Gravity.CENTER -> CapturedVerticalAlignment.CENTER + + else -> CapturedVerticalAlignment.CENTER + } + return CapturedAlignment(horizontalAlignment, verticalAlignment) + } + + private companion object { + const val SANS_SERIF_FAMILY_NAME = "roboto, sans-serif" + const val SERIF_FAMILY_NAME = "serif" + const val MONOSPACE_FAMILY_NAME = "monospace" + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/mapper/CapturedViewGroupFallbackMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/mapper/CapturedViewGroupFallbackMapper.kt new file mode 100644 index 0000000000..d6d24d1f38 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/mapper/CapturedViewGroupFallbackMapper.kt @@ -0,0 +1,44 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.composition.mapper + +import android.view.View +import com.datadog.android.api.InternalLogger +import com.datadog.android.sessionreplay.internal.composition.CapturedWireframe +import com.datadog.android.sessionreplay.internal.composition.toCaptured +import com.datadog.android.sessionreplay.utils.DefaultViewBoundsResolver +import com.datadog.android.sessionreplay.utils.ViewBoundsResolver + +/** + * Generic fallback for any [View]/[ViewGroup] with no dedicated mapper: a single background-color + * [CapturedWireframe.Shape] if the background resolves to a solid color, else nothing. Also what an + * unmapped `ComposeView`/`AndroidComposeView` falls through to, since no Compose-aware decomposition + * exists in this module yet. Direct port of legacy `ViewWireframeMapper`. + */ +internal class CapturedViewGroupFallbackMapper( + private val viewBoundsResolver: ViewBoundsResolver = DefaultViewBoundsResolver, + private val backgroundShapeStyleResolver: CapturedBackgroundShapeStyleResolver = + CapturedBackgroundShapeStyleResolver(), + private val internalLogger: InternalLogger +) : CapturedViewMapper { + + override fun map(view: View, mappingContext: CapturedMappingContext): CapturedViewMapperResult { + val style = backgroundShapeStyleResolver.resolve(view, internalLogger) + ?: return CapturedViewMapperResult.None + val bounds = viewBoundsResolver.resolveViewGlobalBounds(view, mappingContext.screenDensity) + val identity = mappingContext.identityFactory.shapeWireframe(mappingContext.ownerIdentity) + return CapturedViewMapperResult.Wireframes( + listOf( + CapturedWireframe.Shape( + identity = identity, + bounds = bounds.toCaptured(), + style = style + ) + ) + ) + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/mapper/CapturedViewMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/mapper/CapturedViewMapper.kt new file mode 100644 index 0000000000..bc11ab9fb5 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/mapper/CapturedViewMapper.kt @@ -0,0 +1,31 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.composition.mapper + +import android.view.View +import com.datadog.android.sessionreplay.internal.composition.CapturedIdentity +import com.datadog.android.sessionreplay.internal.composition.CapturedIdentityFactory +import com.datadog.android.sessionreplay.internal.composition.CapturedWireframe + +/** + * Deliberately narrower than the legacy `MappingContext`: no privacy/image-helper fields, since + * masking and image-resource capture are owned by a later workstream, not by View decomposition. + */ +internal data class CapturedMappingContext( + val identityFactory: CapturedIdentityFactory, + val ownerIdentity: CapturedIdentity, + val screenDensity: Float +) + +internal sealed interface CapturedViewMapperResult { + data class Wireframes(val wireframes: List) : CapturedViewMapperResult + object None : CapturedViewMapperResult +} + +internal fun interface CapturedViewMapper { + fun map(view: T, mappingContext: CapturedMappingContext): CapturedViewMapperResult +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/mapper/CapturedViewMapperRegistry.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/mapper/CapturedViewMapperRegistry.kt new file mode 100644 index 0000000000..96f1976ad9 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/mapper/CapturedViewMapperRegistry.kt @@ -0,0 +1,40 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.composition.mapper + +import android.view.View +import android.view.ViewGroup +import com.datadog.android.api.InternalLogger + +/** + * First-match-wins lookup, mirroring the legacy `TreeViewTraversal.findMapperForView` registry. + * Falls back to [fallbackMapper] for any view with no dedicated mapper, logging telemetry once per + * unmapped view type so unsupported views are visible without spamming logs every frame. + */ +internal class CapturedViewMapperRegistry( + private val mappers: List>, + private val fallbackMapper: CapturedViewMapper, + private val internalLogger: InternalLogger +) { + fun resolve(view: View): CapturedViewMapper { + val mapper = mappers.firstOrNull { it.supportsView(view) }?.getUnsafeMapper() + if (mapper != null) return mapper + + if (view !is ViewGroup) { + val viewType = view.javaClass.canonicalName ?: view.javaClass.name + internalLogger.log( + level = InternalLogger.Level.INFO, + target = InternalLogger.Target.TELEMETRY, + messageBuilder = { "No mapper found for view $viewType" }, + throwable = null, + onlyOnce = true, + additionalProperties = mapOf("replay.widget.type" to viewType) + ) + } + return fallbackMapper + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/mapper/CapturedWebViewMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/mapper/CapturedWebViewMapper.kt new file mode 100644 index 0000000000..74ec0db4c1 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/mapper/CapturedWebViewMapper.kt @@ -0,0 +1,42 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.composition.mapper + +import android.webkit.WebView +import com.datadog.android.sessionreplay.internal.composition.CapturedWireframe +import com.datadog.android.sessionreplay.internal.composition.toCaptured +import com.datadog.android.sessionreplay.utils.DefaultViewBoundsResolver +import com.datadog.android.sessionreplay.utils.DefaultViewIdentifierResolver +import com.datadog.android.sessionreplay.utils.ViewBoundsResolver +import com.datadog.android.sessionreplay.utils.ViewIdentifierResolver + +/** + * WebView content is never decomposed - a single opaque placeholder wireframe is emitted whose + * `id == slotId` (guaranteed by [com.datadog.android.sessionreplay.internal.composition.CapturedIdentityFactory.webViewWireframe]), + * which an out-of-band JS bridge uses to correlate this rect with independently-recorded web + * content. Direct port of legacy `WebViewWireframeMapper`. + */ +internal class CapturedWebViewMapper( + private val viewIdentifierResolver: ViewIdentifierResolver = DefaultViewIdentifierResolver, + private val viewBoundsResolver: ViewBoundsResolver = DefaultViewBoundsResolver +) : CapturedViewMapper { + + override fun map(view: WebView, mappingContext: CapturedMappingContext): CapturedViewMapperResult { + val bounds = viewBoundsResolver.resolveViewGlobalBounds(view, mappingContext.screenDensity) + val slotId = viewIdentifierResolver.resolveViewId(view) + val identity = mappingContext.identityFactory.webViewWireframe(mappingContext.ownerIdentity, slotId) + return CapturedViewMapperResult.Wireframes( + listOf( + CapturedWireframe.WebView( + identity = identity, + bounds = bounds.toCaptured(), + isVisible = true + ) + ) + ) + } +} diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/DefaultRecorderProviderTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/DefaultRecorderProviderTest.kt index f666c32500..4dcb34cca8 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/DefaultRecorderProviderTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/DefaultRecorderProviderTest.kt @@ -120,6 +120,42 @@ internal class DefaultRecorderProviderTest { assertThat(compositionConstructions).isEqualTo(1) } + @Test + fun `M construct a real composition pipeline W provideSessionReplayRecorder { no factory override }`() { + // Given + val sdkCore = mock() + whenever(sdkCore.timeProvider).thenReturn(mock()) + whenever(sdkCore.internalLogger).thenReturn(mock()) + whenever(sdkCore.createSingleThreadExecutorService(any())).thenReturn(mock()) + whenever(sdkCore.createScheduledExecutorService(any())).thenReturn(mock()) + val provider = DefaultRecorderProvider( + sdkCore = sdkCore, + textAndInputPrivacy = TextAndInputPrivacy.MASK_ALL, + imagePrivacy = ImagePrivacy.MASK_ALL, + touchPrivacyManager = mock(), + customMappers = emptyList(), + customOptionSelectorDetectors = emptyList(), + customDrawableMappers = emptyList(), + dynamicOptimizationEnabled = false, + internalCallback = mock(), + heatmapsEnabled = false, + compositionTreeRecordingEnabled = true + ) + + // When + val result = provider.provideSessionReplayRecorder( + resourceDataStoreManager = mock(), + resourceWriter = mock(), + recordWriter = mock(), + rumContextProvider = mock(), + application = mock(), + embeddedContentSlotRegistry = mock() + ) + + // Then + assertThat(result).isInstanceOf(CompositionCapturePipeline::class.java) + } + @Test fun `M construct wired composition pipeline W provideSessionReplayRecorder { default factory }`() { // Given @@ -142,7 +178,7 @@ internal class DefaultRecorderProviderTest { internalCallback = mock(), heatmapsEnabled = false, compositionTreeRecordingEnabled = true, - compositionSnapshotProducerFactory = { + compositionSnapshotProducerFactory = { _, _ -> producerConstructions++ mock() }, diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/AndroidCapturedSnapshotProducerTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/AndroidCapturedSnapshotProducerTest.kt new file mode 100644 index 0000000000..23ed48ab9f --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/AndroidCapturedSnapshotProducerTest.kt @@ -0,0 +1,247 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.composition + +import android.content.res.Resources +import android.util.DisplayMetrics +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import com.datadog.android.internal.time.TimeProvider +import com.datadog.android.sessionreplay.forge.ForgeConfigurator +import com.datadog.android.sessionreplay.internal.composition.mapper.CapturedMapperTypeWrapper +import com.datadog.android.sessionreplay.internal.composition.mapper.CapturedTextViewMapper +import com.datadog.android.sessionreplay.internal.composition.mapper.CapturedViewGroupFallbackMapper +import com.datadog.android.sessionreplay.internal.composition.mapper.CapturedViewMapperRegistry +import com.datadog.android.sessionreplay.internal.recorder.ViewUtilsInternal +import com.datadog.android.sessionreplay.utils.GlobalBounds +import com.datadog.android.sessionreplay.utils.ViewBoundsResolver +import com.datadog.android.sessionreplay.utils.ViewIdentifierResolver +import fr.xgouchet.elmyr.annotation.FloatForgery +import fr.xgouchet.elmyr.annotation.Forgery +import fr.xgouchet.elmyr.annotation.IntForgery +import fr.xgouchet.elmyr.annotation.LongForgery +import fr.xgouchet.elmyr.annotation.StringForgery +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.Extensions +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness +import java.util.concurrent.atomic.AtomicLong + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(ForgeConfigurator::class) +internal class AndroidCapturedSnapshotProducerTest { + + private val mockViewBoundsResolver: ViewBoundsResolver = mock() + private val mockViewIdentifierResolver: ViewIdentifierResolver = mock() + private lateinit var nextViewId: AtomicLong + private lateinit var fakeContext: CaptureGenerationContext + private var fakeDensity: Float = 1f + + @BeforeEach + fun `set up`( + @LongForgery(min = 1L, max = 1_000_000L) fakeViewIdSeed: Long, + @FloatForgery(min = 0.75f, max = 4f) fakeDensityForgery: Float + ) { + nextViewId = AtomicLong(fakeViewIdSeed) + fakeDensity = fakeDensityForgery + fakeContext = CaptureGenerationContext( + id = 1L, + startedAtNs = 0L, + deadlineNs = Long.MAX_VALUE / 2, + timeProvider = CaptureTimeProvider { 0L } + ) + } + + private fun mockWindow(bounds: GlobalBounds): ViewGroup { + val view: ViewGroup = mock() + whenever(view.isShown).thenReturn(true) + whenever(view.width).thenReturn(bounds.width.toInt()) + whenever(view.height).thenReturn(bounds.height.toInt()) + whenever(view.getTag(any())).thenReturn(null) + whenever(view.childCount).thenReturn(0) + whenever(mockViewIdentifierResolver.resolveViewId(view)).thenReturn(nextViewId.getAndIncrement()) + whenever(mockViewBoundsResolver.resolveViewGlobalBounds(view, fakeDensity)).thenReturn(bounds) + val mockResources: Resources = mock() + val metrics = DisplayMetrics().apply { density = fakeDensity } + whenever(mockResources.displayMetrics).thenReturn(metrics) + whenever(view.resources).thenReturn(mockResources) + return view + } + + private fun traversal() = AndroidWindowTraversal( + mapperRegistry = CapturedViewMapperRegistry( + mappers = listOf( + CapturedMapperTypeWrapper(TextView::class.java, CapturedTextViewMapper(internalLogger = mock())) + ), + fallbackMapper = CapturedViewGroupFallbackMapper(internalLogger = mock()), + internalLogger = mock() + ), + viewIdentifierResolver = mockViewIdentifierResolver, + viewBoundsResolver = mockViewBoundsResolver, + viewUtilsInternal = ViewUtilsInternal() + ) + + private fun producer( + windows: List, + scope: CapturedRumViewScope?, + fakeDeviceTimestampMs: Long + ): AndroidCapturedSnapshotProducer { + val windowSource = ActiveWindowSource().apply { update(windows) } + val timeProvider: TimeProvider = mock() + whenever(timeProvider.getDeviceTimestampMillis()).thenReturn(fakeDeviceTimestampMs) + return AndroidCapturedSnapshotProducer( + windowSource = windowSource, + scopeProvider = RumViewScopeProvider { scope }, + timeProvider = timeProvider, + traversal = traversal(), + viewIdentifierResolver = mockViewIdentifierResolver + ) + } + + @Test + fun `M return null W capture { no active RUM view }`( + @Forgery fakeBounds: GlobalBounds, + @LongForgery fakeTimestamp: Long + ) { + // Given + val testedProducer = producer( + windows = listOf(mockWindow(fakeBounds)), + scope = null, + fakeDeviceTimestampMs = fakeTimestamp + ) + + // When + val snapshot = testedProducer.capture(fakeContext, CaptureChangeset.EMPTY) + + // Then + assertThat(snapshot).isNull() + } + + @Test + fun `M return null W capture { no windows }`( + @StringForgery fakeViewId: String, + @LongForgery(min = 0L) fakeOffset: Long, + @LongForgery fakeTimestamp: Long + ) { + // Given + val testedProducer = producer( + windows = emptyList(), + scope = CapturedRumViewScope(RumViewIdentityScope(fakeViewId), fakeOffset), + fakeDeviceTimestampMs = fakeTimestamp + ) + + // When + val snapshot = testedProducer.capture(fakeContext, CaptureChangeset.EMPTY) + + // Then + assertThat(snapshot).isNull() + } + + @Test + fun `M stamp the timestamp from device time plus view offset W capture()`( + @Forgery fakeBounds: GlobalBounds, + @StringForgery fakeViewId: String, + @LongForgery(min = 0L, max = 100_000L) fakeOffset: Long, + @LongForgery(min = 0L, max = Long.MAX_VALUE / 2) fakeTimestamp: Long + ) { + // Given + val testedProducer = producer( + windows = listOf(mockWindow(fakeBounds)), + scope = CapturedRumViewScope(RumViewIdentityScope(fakeViewId), fakeOffset), + fakeDeviceTimestampMs = fakeTimestamp + ) + + // When + val snapshot = testedProducer.capture(fakeContext, CaptureChangeset.EMPTY) + + // Then + assertThat(snapshot?.timestamp).isEqualTo(fakeTimestamp + fakeOffset) + assertThat(snapshot?.scope).isEqualTo(RumViewIdentityScope(fakeViewId)) + } + + @Test + fun `M assemble windows under one synthetic root in order W capture { multiple windows }`( + @Forgery fakeFirstBounds: GlobalBounds, + @Forgery fakeSecondBounds: GlobalBounds, + @StringForgery fakeViewId: String, + @LongForgery(min = 0L) fakeOffset: Long, + @LongForgery fakeTimestamp: Long + ) { + // Given + val firstWindow = mockWindow(fakeFirstBounds) + val secondWindow = mockWindow(fakeSecondBounds) + val testedProducer = producer( + windows = listOf(firstWindow, secondWindow), + scope = CapturedRumViewScope(RumViewIdentityScope(fakeViewId), fakeOffset), + fakeDeviceTimestampMs = fakeTimestamp + ) + + // When + val snapshot = testedProducer.capture(fakeContext, CaptureChangeset.EMPTY) + + // Then + val root = snapshot!!.root!! + assertThat(root.kind).isEqualTo(CapturedLayerKind.SYNTHETIC_SCREEN_ROOT) + assertThat(root.children).hasSize(2) + val windowLayerIds = snapshot.layers + .filter { it.kind == CapturedLayerKind.WINDOW_ROOT } + .map { it.identity } + assertThat(root.children.map { it.identity }).isEqualTo(windowLayerIds) + } + + @Test + fun `M produce a snapshot that passes full validation W capture { mixed tree }`( + @Forgery fakeWindowBounds: GlobalBounds, + @Forgery fakeTextBounds: GlobalBounds, + @StringForgery fakeViewId: String, + @LongForgery(min = 0L) fakeOffset: Long, + @LongForgery fakeTimestamp: Long, + @StringForgery fakeText: String, + @IntForgery(min = 0, max = 0xFFFFFF) fakeTextColor: Int + ) { + // Given + val window = mockWindow(fakeWindowBounds) + val textView: TextView = mock() + whenever(textView.isShown).thenReturn(true) + whenever(textView.width).thenReturn(fakeTextBounds.width.toInt()) + whenever(textView.height).thenReturn(fakeTextBounds.height.toInt()) + whenever(textView.getTag(any())).thenReturn(null) + whenever(textView.text).thenReturn(fakeText) + whenever(textView.background).thenReturn(null) + whenever(textView.currentTextColor).thenReturn(fakeTextColor) + whenever(mockViewIdentifierResolver.resolveViewId(textView)).thenReturn(nextViewId.getAndIncrement()) + whenever(mockViewBoundsResolver.resolveViewGlobalBounds(textView, fakeDensity)).thenReturn(fakeTextBounds) + whenever(window.childCount).thenReturn(1) + whenever(window.getChildAt(0)).thenReturn(textView) + val testedProducer = producer( + windows = listOf(window), + scope = CapturedRumViewScope(RumViewIdentityScope(fakeViewId), fakeOffset), + fakeDeviceTimestampMs = fakeTimestamp + ) + + // When + val snapshot = testedProducer.capture(fakeContext, CaptureChangeset.EMPTY) + + // Then + val validation = DefaultCapturedTreeValidator().validate(snapshot!!) + assertThat(validation).isEqualTo(CaptureValidationResult.Valid) + } +} diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/AndroidWindowTraversalTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/AndroidWindowTraversalTest.kt new file mode 100644 index 0000000000..f513b31575 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/AndroidWindowTraversalTest.kt @@ -0,0 +1,291 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.composition + +import android.content.res.Resources +import android.util.DisplayMetrics +import android.view.View +import android.view.ViewGroup +import android.view.ViewStub +import com.datadog.android.sessionreplay.forge.ForgeConfigurator +import com.datadog.android.sessionreplay.internal.composition.mapper.CapturedMapperTypeWrapper +import com.datadog.android.sessionreplay.internal.composition.mapper.CapturedViewMapper +import com.datadog.android.sessionreplay.internal.composition.mapper.CapturedViewMapperRegistry +import com.datadog.android.sessionreplay.internal.composition.mapper.CapturedViewMapperResult +import com.datadog.android.sessionreplay.internal.recorder.ViewUtilsInternal +import com.datadog.android.sessionreplay.utils.GlobalBounds +import com.datadog.android.sessionreplay.utils.ViewBoundsResolver +import com.datadog.android.sessionreplay.utils.ViewIdentifierResolver +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.annotation.FloatForgery +import fr.xgouchet.elmyr.annotation.Forgery +import fr.xgouchet.elmyr.annotation.LongForgery +import fr.xgouchet.elmyr.annotation.StringForgery +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.Extensions +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness +import java.util.concurrent.atomic.AtomicLong + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(ForgeConfigurator::class) +internal class AndroidWindowTraversalTest { + + private val mockViewBoundsResolver: ViewBoundsResolver = mock() + private val mockViewIdentifierResolver: ViewIdentifierResolver = mock() + private lateinit var nextViewId: AtomicLong + private lateinit var identityFactory: DefaultCapturedIdentityFactory + private lateinit var fakeContext: CaptureGenerationContext + private var fakeDensity: Float = 1f + + private val noOpFallback = CapturedViewMapper { _, _ -> CapturedViewMapperResult.None } + private val markerMapper = CapturedViewMapper { view, mappingContext -> + val bounds = mockViewBoundsResolver.resolveViewGlobalBounds(view, mappingContext.screenDensity) + CapturedViewMapperResult.Wireframes( + listOf( + CapturedWireframe.Shape( + identity = mappingContext.identityFactory.shapeWireframe(mappingContext.ownerIdentity), + bounds = CapturedBounds(bounds.x, bounds.y, bounds.width, bounds.height) + ) + ) + ) + } + + @BeforeEach + fun `set up`( + forge: Forge, + @StringForgery fakeScope: String, + @LongForgery(min = 1L, max = 1_000_000L) fakeViewIdSeed: Long, + @FloatForgery(min = 0.75f, max = 4f) fakeDensityForgery: Float + ) { + nextViewId = AtomicLong(fakeViewIdSeed) + identityFactory = DefaultCapturedIdentityFactory(RumViewIdentityScope(fakeScope)) + fakeDensity = fakeDensityForgery + fakeContext = CaptureGenerationContext( + id = forge.aLong(min = 1L), + startedAtNs = 0L, + deadlineNs = Long.MAX_VALUE / 2, + timeProvider = CaptureTimeProvider { 0L } + ) + } + + private fun mockView(bounds: GlobalBounds): View { + val view: View = mock() + stubDefaults(view, bounds) + return view + } + + private fun mockViewGroup(bounds: GlobalBounds): ViewGroup { + val view: ViewGroup = mock() + stubDefaults(view, bounds) + return view + } + + private fun stubDefaults(view: View, bounds: GlobalBounds) { + whenever(view.isShown).thenReturn(true) + whenever(view.width).thenReturn(bounds.width.toInt().coerceAtLeast(1)) + whenever(view.height).thenReturn(bounds.height.toInt().coerceAtLeast(1)) + whenever(view.getTag(any())).thenReturn(null) + whenever(mockViewIdentifierResolver.resolveViewId(view)).thenReturn(nextViewId.getAndIncrement()) + whenever(mockViewBoundsResolver.resolveViewGlobalBounds(view, fakeDensity)).thenReturn(bounds) + val mockResources: Resources = mock() + val metrics = DisplayMetrics().apply { density = fakeDensity } + whenever(mockResources.displayMetrics).thenReturn(metrics) + whenever(view.resources).thenReturn(mockResources) + } + + private fun traversal( + fallback: CapturedViewMapper = noOpFallback, + typedMappers: List> = emptyList() + ) = AndroidWindowTraversal( + mapperRegistry = CapturedViewMapperRegistry(typedMappers, fallback, mock()), + viewIdentifierResolver = mockViewIdentifierResolver, + viewBoundsResolver = mockViewBoundsResolver, + viewUtilsInternal = ViewUtilsInternal() + ) + + @Test + fun `M drop the child W visit { not visible }`( + @Forgery fakeRootBounds: GlobalBounds, + @Forgery fakeChildBounds: GlobalBounds + ) { + // Given + val root = mockViewGroup(fakeRootBounds) + val hiddenChild = mockView(fakeChildBounds).apply { whenever(isShown).thenReturn(false) } + whenever(root.childCount).thenReturn(1) + whenever(root.getChildAt(0)).thenReturn(hiddenChild) + val windowIdentity = identityFactory.window("window") + + // When + val result = traversal().traverseWindow(root, windowIdentity, identityFactory, fakeContext) + + // Then + val present = result as WindowWalkResult.Present + assertThat(present.rootLayer.children).isEmpty() + assertThat(present.layers).hasSize(1) // only the window root itself + } + + @Test + fun `M drop the child W visit { system noise }`( + @Forgery fakeRootBounds: GlobalBounds, + @Forgery fakeChildBounds: GlobalBounds + ) { + // Given + val root = mockViewGroup(fakeRootBounds) + val viewStubChild: ViewStub = mock() + stubDefaults(viewStubChild, fakeChildBounds) + whenever(root.childCount).thenReturn(1) + whenever(root.getChildAt(0)).thenReturn(viewStubChild) + val windowIdentity = identityFactory.window("window") + + // When + val result = traversal().traverseWindow(root, windowIdentity, identityFactory, fakeContext) + + // Then + val present = result as WindowWalkResult.Present + assertThat(present.rootLayer.children).isEmpty() + } + + @Test + fun `M emit a placeholder and not recurse W visit { hidden tag }`( + @Forgery fakeRootBounds: GlobalBounds, + @Forgery fakeHiddenBounds: GlobalBounds, + @Forgery fakeGrandChildBounds: GlobalBounds + ) { + // Given + val root = mockViewGroup(fakeRootBounds) + val hiddenGroup = mockViewGroup(fakeHiddenBounds) + whenever(hiddenGroup.getTag(any())).thenReturn(true) + val grandChild = mockView(fakeGrandChildBounds) + whenever(hiddenGroup.childCount).thenReturn(1) + whenever(hiddenGroup.getChildAt(0)).thenReturn(grandChild) + whenever(root.childCount).thenReturn(1) + whenever(root.getChildAt(0)).thenReturn(hiddenGroup) + val windowIdentity = identityFactory.window("window") + + // When + val result = traversal().traverseWindow(root, windowIdentity, identityFactory, fakeContext) + + // Then + val present = result as WindowWalkResult.Present + val hiddenLayer = present.layers.first { it.identity != present.rootLayer.identity } + assertThat(hiddenLayer.children).hasSize(1) + val placeholder = present.wireframes.single() as CapturedWireframe.PrivacyPlaceholder + assertThat(placeholder.label).isEqualTo("Hidden") + // No layer was created for grandChild since the hidden view's children are never visited. + assertThat(present.layers).hasSize(2) // root + hiddenGroup only + } + + @Test + fun `M preserve child order W visit { multiple children }`(forge: Forge) { + // Given + val root = mockViewGroup(forge.getForgery()) + val children = List(3) { mockView(forge.getForgery()) } + whenever(root.childCount).thenReturn(children.size) + children.forEachIndexed { index, child -> whenever(root.getChildAt(index)).thenReturn(child) } + val windowIdentity = identityFactory.window("window") + + // When + val result = traversal().traverseWindow(root, windowIdentity, identityFactory, fakeContext) + + // Then + val present = result as WindowWalkResult.Present + val childIdentities = present.rootLayer.children.map { it.identity } + val expectedOrder = children.map { child -> + present.layers.first { layer -> + layer.identity.localId == mockViewIdentifierResolver.resolveViewId(child).toString() + }.identity + } + assertThat(childIdentities).isEqualTo(expectedOrder) + } + + @Test + fun `M compute clip against ancestor bounds W child overflows parent`( + @Forgery fakeRootBounds: GlobalBounds, + @LongForgery(min = 1L, max = 500L) fakeRightOverflow: Long, + @LongForgery(min = 1L, max = 500L) fakeBottomOverflow: Long + ) { + // Given + val root = mockViewGroup(fakeRootBounds) + val childBounds = GlobalBounds( + x = fakeRootBounds.x, + y = fakeRootBounds.y, + width = fakeRootBounds.width + fakeRightOverflow, + height = fakeRootBounds.height + fakeBottomOverflow + ) + val child = mockView(childBounds) + whenever(root.childCount).thenReturn(1) + whenever(root.getChildAt(0)).thenReturn(child) + val windowIdentity = identityFactory.window("window") + + // When + val result = traversal( + fallback = markerMapper + ).traverseWindow(root, windowIdentity, identityFactory, fakeContext) + + // Then + val present = result as WindowWalkResult.Present + val childWireframe = present.wireframes.first { + it.bounds.x == childBounds.x && + it.bounds.width == childBounds.width + } + assertThat(childWireframe.clip).isEqualTo( + CapturedClip(top = null, bottom = fakeBottomOverflow, left = null, right = fakeRightOverflow) + ) + } + + @Test + fun `M abort the whole capture W deadline expires mid walk`( + @Forgery fakeRootBounds: GlobalBounds, + @LongForgery(min = 1L, max = 1000L) fakeDeadlineNs: Long + ) { + // Given + val root = mockViewGroup(fakeRootBounds) + val children = List(5) { mockView(fakeRootBounds) } + whenever(root.childCount).thenReturn(children.size) + children.forEachIndexed { index, child -> whenever(root.getChildAt(index)).thenReturn(child) } + val windowIdentity = identityFactory.window("window") + var calls = 0 + val expiringContext = CaptureGenerationContext( + id = 1L, + startedAtNs = 0L, + deadlineNs = fakeDeadlineNs, + timeProvider = CaptureTimeProvider { + calls++ + // Not expired for the upfront per-window check and the first checkpoint, + // expired from the second checkpoint onward. + if (calls <= 2) 0L else fakeDeadlineNs * 2 + } + ) + val testedTraversal = AndroidWindowTraversal( + mapperRegistry = CapturedViewMapperRegistry(emptyList(), noOpFallback, mock()), + viewIdentifierResolver = mockViewIdentifierResolver, + viewBoundsResolver = mockViewBoundsResolver, + viewUtilsInternal = ViewUtilsInternal(), + viewsPerCheckpoint = 1 + ) + + // When + val result = testedTraversal.traverseWindow(root, windowIdentity, identityFactory, expiringContext) + + // Then + assertThat(result).isEqualTo(WindowWalkResult.Aborted) + } +} diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/mapper/CapturedHiddenViewMapperTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/mapper/CapturedHiddenViewMapperTest.kt new file mode 100644 index 0000000000..4c299bbd01 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/mapper/CapturedHiddenViewMapperTest.kt @@ -0,0 +1,63 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.composition.mapper + +import android.view.View +import com.datadog.android.sessionreplay.forge.ForgeConfigurator +import com.datadog.android.sessionreplay.internal.composition.CapturedWireframe +import com.datadog.android.sessionreplay.internal.composition.DefaultCapturedIdentityFactory +import com.datadog.android.sessionreplay.internal.composition.RumViewIdentityScope +import com.datadog.android.sessionreplay.utils.GlobalBounds +import com.datadog.android.sessionreplay.utils.ViewBoundsResolver +import fr.xgouchet.elmyr.annotation.FloatForgery +import fr.xgouchet.elmyr.annotation.Forgery +import fr.xgouchet.elmyr.annotation.StringForgery +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.Extensions +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(ForgeConfigurator::class) +internal class CapturedHiddenViewMapperTest { + + private val mockViewBoundsResolver: ViewBoundsResolver = mock() + private val testedMapper = CapturedHiddenViewMapper(mockViewBoundsResolver) + + @Test + fun `M emit a placeholder labeled Hidden W map()`( + @StringForgery fakeScope: String, + @Forgery fakeBounds: GlobalBounds, + @FloatForgery(min = 0f, max = 4f) fakeDensity: Float + ) { + // Given + val mockView: View = mock() + val factory = DefaultCapturedIdentityFactory(RumViewIdentityScope(fakeScope)) + val owner = factory.view(factory.window("window"), "hidden-owner") + val mappingContext = CapturedMappingContext(factory, owner, screenDensity = fakeDensity) + whenever(mockViewBoundsResolver.resolveViewGlobalBounds(mockView, fakeDensity)).thenReturn(fakeBounds) + + // When + val result = testedMapper.map(mockView, mappingContext) as CapturedViewMapperResult.Wireframes + + // Then + val wireframe = result.wireframes.single() as CapturedWireframe.PrivacyPlaceholder + assertThat(wireframe.label).isEqualTo("Hidden") + assertThat(wireframe.bounds.x).isEqualTo(fakeBounds.x) + } +} diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/mapper/CapturedTextViewMapperTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/mapper/CapturedTextViewMapperTest.kt new file mode 100644 index 0000000000..a11fca0ff6 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/mapper/CapturedTextViewMapperTest.kt @@ -0,0 +1,111 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.composition.mapper + +import android.widget.TextView +import com.datadog.android.api.InternalLogger +import com.datadog.android.sessionreplay.forge.ForgeConfigurator +import com.datadog.android.sessionreplay.internal.composition.CapturedWireframe +import com.datadog.android.sessionreplay.internal.composition.DefaultCapturedIdentityFactory +import com.datadog.android.sessionreplay.internal.composition.RumViewIdentityScope +import com.datadog.android.sessionreplay.utils.ColorStringFormatter +import com.datadog.android.sessionreplay.utils.GlobalBounds +import com.datadog.android.sessionreplay.utils.OPAQUE_ALPHA_VALUE +import com.datadog.android.sessionreplay.utils.ViewBoundsResolver +import fr.xgouchet.elmyr.annotation.FloatForgery +import fr.xgouchet.elmyr.annotation.Forgery +import fr.xgouchet.elmyr.annotation.IntForgery +import fr.xgouchet.elmyr.annotation.StringForgery +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.Extensions +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(ForgeConfigurator::class) +internal class CapturedTextViewMapperTest { + + private val mockViewBoundsResolver: ViewBoundsResolver = mock() + private val mockColorStringFormatter: ColorStringFormatter = mock() + private val mockInternalLogger: InternalLogger = mock() + private val testedMapper = CapturedTextViewMapper( + viewBoundsResolver = mockViewBoundsResolver, + colorStringFormatter = mockColorStringFormatter, + backgroundShapeStyleResolver = CapturedBackgroundShapeStyleResolver(), + internalLogger = mockInternalLogger + ) + + @Test + fun `M capture raw unmasked text W map()`( + @StringForgery fakeScope: String, + @StringForgery fakeText: String, + @IntForgery(min = 0, max = 0xFFFFFF) fakeTextColor: Int, + @StringForgery(regex = "#[0-9A-F]{8}") fakeColorHexString: String, + @FloatForgery(min = 0f, max = 4f) fakeDensity: Float, + @Forgery fakeBounds: GlobalBounds + ) { + // Given + val mockTextView: TextView = mock() + whenever(mockTextView.text).thenReturn(fakeText) + whenever(mockTextView.background).thenReturn(null) + whenever(mockTextView.currentTextColor).thenReturn(fakeTextColor) + whenever(mockColorStringFormatter.formatColorAndAlphaAsHexString(fakeTextColor, OPAQUE_ALPHA_VALUE)) + .thenReturn(fakeColorHexString) + whenever(mockViewBoundsResolver.resolveViewGlobalBounds(mockTextView, fakeDensity)).thenReturn(fakeBounds) + val factory = DefaultCapturedIdentityFactory(RumViewIdentityScope(fakeScope)) + val owner = factory.view(factory.window("window"), "text-owner") + val mappingContext = CapturedMappingContext(factory, owner, screenDensity = fakeDensity) + + // When + val result = testedMapper.map(mockTextView, mappingContext) as CapturedViewMapperResult.Wireframes + + // Then + val textWireframe = result.wireframes.filterIsInstance().single() + assertThat(textWireframe.text).isEqualTo(fakeText) + assertThat(textWireframe.textStyle.color).isEqualTo(fakeColorHexString) + assertThat(textWireframe.bounds.x).isEqualTo(fakeBounds.x) + } + + @Test + fun `M not emit a Shape wireframe W map { no background }`( + @StringForgery fakeScope: String, + @StringForgery fakeText: String, + @IntForgery(min = 0, max = 0xFFFFFF) fakeTextColor: Int, + @StringForgery(regex = "#[0-9A-F]{8}") fakeColorHexString: String, + @FloatForgery(min = 0f, max = 4f) fakeDensity: Float, + @Forgery fakeBounds: GlobalBounds + ) { + // Given + val mockTextView: TextView = mock() + whenever(mockTextView.text).thenReturn(fakeText) + whenever(mockTextView.background).thenReturn(null) + whenever(mockTextView.currentTextColor).thenReturn(fakeTextColor) + whenever(mockColorStringFormatter.formatColorAndAlphaAsHexString(fakeTextColor, OPAQUE_ALPHA_VALUE)) + .thenReturn(fakeColorHexString) + whenever(mockViewBoundsResolver.resolveViewGlobalBounds(mockTextView, fakeDensity)).thenReturn(fakeBounds) + val factory = DefaultCapturedIdentityFactory(RumViewIdentityScope(fakeScope)) + val owner = factory.view(factory.window("window"), "text-owner") + val mappingContext = CapturedMappingContext(factory, owner, screenDensity = fakeDensity) + + // When + val result = testedMapper.map(mockTextView, mappingContext) as CapturedViewMapperResult.Wireframes + + // Then + assertThat(result.wireframes.filterIsInstance()).isEmpty() + } +} diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/mapper/CapturedViewGroupFallbackMapperTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/mapper/CapturedViewGroupFallbackMapperTest.kt new file mode 100644 index 0000000000..c753c55dfe --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/mapper/CapturedViewGroupFallbackMapperTest.kt @@ -0,0 +1,106 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.composition.mapper + +import android.graphics.drawable.Drawable +import android.view.View +import com.datadog.android.api.InternalLogger +import com.datadog.android.sessionreplay.forge.ForgeConfigurator +import com.datadog.android.sessionreplay.internal.composition.CapturedWireframe +import com.datadog.android.sessionreplay.internal.composition.DefaultCapturedIdentityFactory +import com.datadog.android.sessionreplay.internal.composition.RumViewIdentityScope +import com.datadog.android.sessionreplay.utils.ColorStringFormatter +import com.datadog.android.sessionreplay.utils.DrawableToColorMapper +import com.datadog.android.sessionreplay.utils.GlobalBounds +import com.datadog.android.sessionreplay.utils.ViewBoundsResolver +import fr.xgouchet.elmyr.annotation.FloatForgery +import fr.xgouchet.elmyr.annotation.Forgery +import fr.xgouchet.elmyr.annotation.IntForgery +import fr.xgouchet.elmyr.annotation.StringForgery +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.Extensions +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(ForgeConfigurator::class) +internal class CapturedViewGroupFallbackMapperTest { + + private val mockViewBoundsResolver: ViewBoundsResolver = mock() + private val mockColorStringFormatter: ColorStringFormatter = mock() + private val mockDrawableToColorMapper: DrawableToColorMapper = mock() + private val mockInternalLogger: InternalLogger = mock() + private val testedMapper = CapturedViewGroupFallbackMapper( + viewBoundsResolver = mockViewBoundsResolver, + backgroundShapeStyleResolver = CapturedBackgroundShapeStyleResolver( + mockColorStringFormatter, + mockDrawableToColorMapper + ), + internalLogger = mockInternalLogger + ) + + @Test + fun `M emit nothing W map { no background }`( + @StringForgery fakeScope: String, + @FloatForgery(min = 0f, max = 4f) fakeDensity: Float + ) { + // Given + val mockView: View = mock() + whenever(mockView.background).thenReturn(null) + val factory = DefaultCapturedIdentityFactory(RumViewIdentityScope(fakeScope)) + val owner = factory.view(factory.window("window"), "owner") + val mappingContext = CapturedMappingContext(factory, owner, screenDensity = fakeDensity) + + // When + val result = testedMapper.map(mockView, mappingContext) + + // Then + assertThat(result).isEqualTo(CapturedViewMapperResult.None) + } + + @Test + fun `M emit a Shape wireframe W map { resolvable background color }`( + @StringForgery fakeScope: String, + @IntForgery(min = 0, max = 0xFFFFFF) fakeColor: Int, + @StringForgery(regex = "#[0-9A-F]{8}") fakeColorHexString: String, + @FloatForgery(min = 0f, max = 1f) fakeAlpha: Float, + @FloatForgery(min = 0f, max = 4f) fakeDensity: Float, + @Forgery fakeBounds: GlobalBounds + ) { + // Given + val mockView: View = mock() + val mockDrawable: Drawable = mock() + whenever(mockView.background).thenReturn(mockDrawable) + whenever(mockView.alpha).thenReturn(fakeAlpha) + whenever(mockDrawableToColorMapper.mapDrawableToColor(mockDrawable, mockInternalLogger)).thenReturn(fakeColor) + whenever(mockColorStringFormatter.formatColorAsHexString(fakeColor)).thenReturn(fakeColorHexString) + whenever(mockViewBoundsResolver.resolveViewGlobalBounds(mockView, fakeDensity)).thenReturn(fakeBounds) + val factory = DefaultCapturedIdentityFactory(RumViewIdentityScope(fakeScope)) + val owner = factory.view(factory.window("window"), "owner") + val mappingContext = CapturedMappingContext(factory, owner, screenDensity = fakeDensity) + + // When + val result = testedMapper.map(mockView, mappingContext) as CapturedViewMapperResult.Wireframes + + // Then + val wireframe = result.wireframes.single() as CapturedWireframe.Shape + assertThat(wireframe.style?.backgroundColor).isEqualTo(fakeColorHexString) + assertThat(wireframe.style?.opacity).isEqualTo(fakeAlpha) + assertThat(wireframe.bounds.x).isEqualTo(fakeBounds.x) + } +} diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/mapper/CapturedViewMapperRegistryTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/mapper/CapturedViewMapperRegistryTest.kt new file mode 100644 index 0000000000..a0494262be --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/mapper/CapturedViewMapperRegistryTest.kt @@ -0,0 +1,84 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.composition.mapper + +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import com.datadog.android.api.InternalLogger +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify + +internal class CapturedViewMapperRegistryTest { + + private val mockInternalLogger: InternalLogger = mock() + private val textViewMapper: CapturedViewMapper = mock() + private val fallbackMapper: CapturedViewMapper = mock() + private val testedRegistry = CapturedViewMapperRegistry( + mappers = listOf(CapturedMapperTypeWrapper(TextView::class.java, textViewMapper)), + fallbackMapper = fallbackMapper, + internalLogger = mockInternalLogger + ) + + @Test + fun `M resolve the matching mapper W resolve { TextView }`() { + // Given + val mockTextView: TextView = mock() + + // When + val resolved = testedRegistry.resolve(mockTextView) + + // Then + assertThat(resolved).isEqualTo(textViewMapper) + } + + @Test + fun `M resolve the fallback and log once W resolve { unmapped leaf view }`() { + // Given + val mockView: View = mock() + + // When + val resolved = testedRegistry.resolve(mockView) + + // Then + assertThat(resolved).isEqualTo(fallbackMapper) + verify(mockInternalLogger).log( + level = eq(InternalLogger.Level.INFO), + target = eq(InternalLogger.Target.TELEMETRY), + messageBuilder = any(), + throwable = eq(null), + onlyOnce = eq(true), + additionalProperties = any() + ) + } + + @Test + fun `M resolve the fallback without logging W resolve { unmapped ViewGroup }`() { + // Given + val mockViewGroup: ViewGroup = mock() + + // When + val resolved = testedRegistry.resolve(mockViewGroup) + + // Then + assertThat(resolved).isEqualTo(fallbackMapper) + verify(mockInternalLogger, never()).log( + level = any(), + target = any(), + messageBuilder = any(), + throwable = anyOrNull(), + onlyOnce = any(), + additionalProperties = anyOrNull() + ) + } +} diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/mapper/CapturedWebViewMapperTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/mapper/CapturedWebViewMapperTest.kt new file mode 100644 index 0000000000..40fb9a1410 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/mapper/CapturedWebViewMapperTest.kt @@ -0,0 +1,71 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.composition.mapper + +import android.webkit.WebView +import com.datadog.android.sessionreplay.forge.ForgeConfigurator +import com.datadog.android.sessionreplay.internal.composition.CapturedWireframe +import com.datadog.android.sessionreplay.internal.composition.DefaultCapturedIdentityFactory +import com.datadog.android.sessionreplay.internal.composition.RumViewIdentityScope +import com.datadog.android.sessionreplay.utils.GlobalBounds +import com.datadog.android.sessionreplay.utils.ViewBoundsResolver +import com.datadog.android.sessionreplay.utils.ViewIdentifierResolver +import fr.xgouchet.elmyr.annotation.Forgery +import fr.xgouchet.elmyr.annotation.LongForgery +import fr.xgouchet.elmyr.annotation.StringForgery +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.Extensions +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(ForgeConfigurator::class) +internal class CapturedWebViewMapperTest { + + private val mockViewIdentifierResolver: ViewIdentifierResolver = mock() + private val mockViewBoundsResolver: ViewBoundsResolver = mock() + private val testedMapper = CapturedWebViewMapper(mockViewIdentifierResolver, mockViewBoundsResolver) + + @Test + fun `M emit a WebView wireframe with id equal to slotId W map()`( + @StringForgery fakeScope: String, + @LongForgery(min = 0L) fakeSlotId: Long, + @Forgery fakeBounds: GlobalBounds + ) { + // Given + val mockWebView: WebView = mock() + val factory = DefaultCapturedIdentityFactory(RumViewIdentityScope(fakeScope)) + val window = factory.window("window") + val owner = factory.view(window, "webview-owner") + val mappingContext = CapturedMappingContext(factory, owner, screenDensity = 2f) + whenever(mockViewIdentifierResolver.resolveViewId(mockWebView)).thenReturn(fakeSlotId) + whenever(mockViewBoundsResolver.resolveViewGlobalBounds(mockWebView, 2f)).thenReturn(fakeBounds) + + // When + val result = testedMapper.map(mockWebView, mappingContext) as CapturedViewMapperResult.Wireframes + + // Then + val wireframe = result.wireframes.single() as CapturedWireframe.WebView + assertThat(wireframe.identity.wireId).isEqualTo(fakeSlotId) + assertThat(wireframe.isVisible).isTrue() + assertThat(wireframe.bounds.x).isEqualTo(fakeBounds.x) + assertThat(wireframe.bounds.y).isEqualTo(fakeBounds.y) + assertThat(wireframe.bounds.width).isEqualTo(fakeBounds.width) + assertThat(wireframe.bounds.height).isEqualTo(fakeBounds.height) + } +}