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] 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) + } +}