diff --git a/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/RootSemanticsNodeMapperTest.kt b/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/RootSemanticsNodeMapperTest.kt index 2a95b61393..3d13ebced7 100644 --- a/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/RootSemanticsNodeMapperTest.kt +++ b/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/RootSemanticsNodeMapperTest.kt @@ -400,16 +400,18 @@ class RootSemanticsNodeMapperTest { } @Test - fun `M call interop callback W semantics node has interop view`(forge: Forge) { + fun `M preserve embedded wireframe W semantics node has interop view`(forge: Forge) { // Given val mockSemanticsNode = mockSemanticsNode(null) val mockView = mock() - val fakeInteropWireframe = MobileSegment.Wireframe.ShapeWireframe( + val fakeInteropWireframe = MobileSegment.Wireframe.EmbeddedContentWireframe( id = forge.aLong(), x = forge.aLong(min = 0, max = 500), y = forge.aLong(min = 0, max = 500), width = forge.aLong(min = 1, max = 500), - height = forge.aLong(min = 1, max = 500) + height = forge.aLong(min = 1, max = 500), + slotId = forge.anAlphabeticalString(), + isVisible = true ) whenever(mockSemanticsUtils.getInteropView(mockSemanticsNode)) doReturn mockView whenever(fakeMappingContext.interopViewCallback.map(mockView, fakeMappingContext)) diff --git a/features/dd-sdk-android-session-replay/api/apiSurface b/features/dd-sdk-android-session-replay/api/apiSurface index 6e785f579d..f30b122d01 100644 --- a/features/dd-sdk-android-session-replay/api/apiSurface +++ b/features/dd-sdk-android-session-replay/api/apiSurface @@ -78,6 +78,10 @@ enum com.datadog.android.sessionreplay.TouchPrivacy : PrivacyLevel class com.datadog.android.sessionreplay._SessionReplayInternalProxy constructor(SessionReplayConfiguration.Builder) fun setInternalCallback(SessionReplayInternalCallback): SessionReplayConfiguration.Builder + companion object + fun setEmbeddedContentSlotId(android.view.View, String?) + fun addEmbeddedContentRecords(List>, String, String, com.datadog.android.api.SdkCore = Datadog.getInstance()) + fun addEmbeddedContentResource(String, ByteArray, String, com.datadog.android.api.SdkCore = Datadog.getInstance()) class com.datadog.android.sessionreplay.internal.TouchPrivacyManager constructor(com.datadog.android.sessionreplay.TouchPrivacy) fun addTouchOverrideArea(android.graphics.Rect, com.datadog.android.sessionreplay.TouchPrivacy) diff --git a/features/dd-sdk-android-session-replay/api/dd-sdk-android-session-replay.api b/features/dd-sdk-android-session-replay/api/dd-sdk-android-session-replay.api index 3e2bfc8ecc..15468311df 100644 --- a/features/dd-sdk-android-session-replay/api/dd-sdk-android-session-replay.api +++ b/features/dd-sdk-android-session-replay/api/dd-sdk-android-session-replay.api @@ -147,10 +147,19 @@ public final class com/datadog/android/sessionreplay/TouchPrivacy : java/lang/En } public final class com/datadog/android/sessionreplay/_SessionReplayInternalProxy { + public static final field Companion Lcom/datadog/android/sessionreplay/_SessionReplayInternalProxy$Companion; public fun (Lcom/datadog/android/sessionreplay/SessionReplayConfiguration$Builder;)V public final fun setInternalCallback (Lcom/datadog/android/sessionreplay/SessionReplayInternalCallback;)Lcom/datadog/android/sessionreplay/SessionReplayConfiguration$Builder; } +public final class com/datadog/android/sessionreplay/_SessionReplayInternalProxy$Companion { + public final fun addEmbeddedContentRecords (Ljava/util/List;Ljava/lang/String;Ljava/lang/String;Lcom/datadog/android/api/SdkCore;)V + public static synthetic fun addEmbeddedContentRecords$default (Lcom/datadog/android/sessionreplay/_SessionReplayInternalProxy$Companion;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;Lcom/datadog/android/api/SdkCore;ILjava/lang/Object;)V + public final fun addEmbeddedContentResource (Ljava/lang/String;[BLjava/lang/String;Lcom/datadog/android/api/SdkCore;)V + public static synthetic fun addEmbeddedContentResource$default (Lcom/datadog/android/sessionreplay/_SessionReplayInternalProxy$Companion;Ljava/lang/String;[BLjava/lang/String;Lcom/datadog/android/api/SdkCore;ILjava/lang/Object;)V + public final fun setEmbeddedContentSlotId (Landroid/view/View;Ljava/lang/String;)V +} + public final class com/datadog/android/sessionreplay/internal/TouchPrivacyManager { public fun (Lcom/datadog/android/sessionreplay/TouchPrivacy;)V public final fun addTouchOverrideArea (Landroid/graphics/Rect;Lcom/datadog/android/sessionreplay/TouchPrivacy;)V diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/SessionReplay.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/SessionReplay.kt index ae1e3420f4..6c7e85aaa2 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/SessionReplay.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/SessionReplay.kt @@ -105,6 +105,12 @@ object SessionReplay { sessionReplayFeature?.manuallyStopRecording() } + internal fun registeredFeature(): SessionReplayFeature? { + return (currentRegisteredCore?.get() as? FeatureSdkCore) + ?.getFeature(Feature.SESSION_REPLAY_FEATURE_NAME) + ?.unwrap() + } + private fun isAlreadyRegistered() = currentRegisteredCore?.get()?.isCoreActive() == true diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/_SessionReplayInternalProxy.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/_SessionReplayInternalProxy.kt index 9c8525a138..16ad094ee2 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/_SessionReplayInternalProxy.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/_SessionReplayInternalProxy.kt @@ -6,7 +6,17 @@ package com.datadog.android.sessionreplay +import android.view.View +import androidx.annotation.AnyThread +import androidx.annotation.UiThread +import com.datadog.android.Datadog +import com.datadog.android.api.SdkCore +import com.datadog.android.api.feature.Feature +import com.datadog.android.api.feature.FeatureSdkCore import com.datadog.android.lint.InternalApi +import com.datadog.android.sessionreplay.internal.SessionReplayFeature +import com.datadog.android.sessionreplay.internal.embedded.EmbeddedContentEvent +import com.datadog.android.sessionreplay.internal.embedded.EmbeddedContentSlotRegistration /** * This class exposes internal methods that are used by other Datadog modules and cross platform @@ -35,4 +45,109 @@ class _SessionReplayInternalProxy(private val builder: SessionReplayConfiguratio ): SessionReplayConfiguration.Builder { return builder.setInternalCallback(internalCallback) } + + companion object { + /** + * Identifies [view] as a host slot for embedded Session Replay content. + * + * The slot identifier is supplied by the embedding SDK and is independent from the native + * wireframe identifier. Passing `null` permanently detaches the slot and removes its + * embedded-content wireframe from the next capture. Reassigning the current slot identifier + * has no effect. + */ + @UiThread + fun setEmbeddedContentSlotId(view: View, slotId: String?) { + val previousSlotId = + view.getTag(R.id.datadog_session_replay_slot_id) as? String + if (previousSlotId == slotId) { + return + } + val previousRegistration = + view.getTag(R.id.datadog_session_replay_slot_registration) + as? EmbeddedContentSlotRegistration + val newRegistration = slotId?.let { + EmbeddedContentSlotRegistration(it) + } + previousRegistration?.deactivate() + view.setTag(R.id.datadog_session_replay_slot_id, slotId) + view.setTag(R.id.datadog_session_replay_slot_registration, newRegistration) + SessionReplay.registeredFeature()?.notifyEmbeddedContentSlotChanged( + previousRegistration = previousRegistration, + newRegistration = newRegistration + ) + @Suppress("UnsafeThirdPartyFunctionCall") // Android documents no exception for scheduling invalidation. + view.postInvalidateOnAnimation() + } + + /** + * Queues Session Replay records produced by an embedded renderer. + */ + @AnyThread + fun addEmbeddedContentRecords( + records: List>, + slotId: String, + viewId: String, + sdkCore: SdkCore = Datadog.getInstance() + ) { + sendEmbeddedContentEvent( + sdkCore, + EmbeddedContentEvent.RecordBatch(snapshotRecords(records), slotId, viewId) + ) + } + + /** + * Queues a Session Replay resource produced by an embedded renderer. + */ + @AnyThread + fun addEmbeddedContentResource( + identifier: String, + resourceData: ByteArray, + mimeType: String, + sdkCore: SdkCore = Datadog.getInstance() + ) { + sendEmbeddedContentEvent( + sdkCore, + EmbeddedContentEvent.Resource(identifier, resourceData.copyOf(), mimeType) + ) + } + + private fun sendEmbeddedContentEvent(sdkCore: SdkCore, event: EmbeddedContentEvent) { + (sdkCore as? FeatureSdkCore) + ?.getFeature(Feature.SESSION_REPLAY_FEATURE_NAME) + ?.unwrap() + ?.receiveEmbeddedContentEvent(event) + } + + private fun snapshotRecords(records: List>): List> { + return records.map { record -> + buildMap { + record.forEach { (key, value) -> + put(key, snapshotValue(value)) + } + } + } + } + + @Suppress("UnsafeThirdPartyFunctionCall") // Standard collection mapping and array copies only. + private fun snapshotValue(value: Any?): Any? { + return when (value) { + is Map<*, *> -> buildMap { + value.forEach { (key, nestedValue) -> + put(key, snapshotValue(nestedValue)) + } + } + is Iterable<*> -> value.map { snapshotValue(it) } + is Array<*> -> value.map { snapshotValue(it) } + is ByteArray -> value.copyOf() + is ShortArray -> value.copyOf() + is IntArray -> value.copyOf() + is LongArray -> value.copyOf() + is FloatArray -> value.copyOf() + is DoubleArray -> value.copyOf() + is BooleanArray -> value.copyOf() + is CharArray -> value.copyOf() + else -> value + } + } + } } 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 df12318dc8..65d16aa3c4 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 @@ -27,6 +27,7 @@ import com.datadog.android.sessionreplay.ImagePrivacy import com.datadog.android.sessionreplay.MapperTypeWrapper import com.datadog.android.sessionreplay.SessionReplayInternalCallback import com.datadog.android.sessionreplay.TextAndInputPrivacy +import com.datadog.android.sessionreplay.internal.embedded.EmbeddedContentSlotRegistry import com.datadog.android.sessionreplay.internal.recorder.Recorder import com.datadog.android.sessionreplay.internal.recorder.SessionReplayRecorder import com.datadog.android.sessionreplay.internal.recorder.mapper.ActionBarContainerMapper @@ -75,7 +76,8 @@ internal class DefaultRecorderProvider( resourceWriter: ResourcesWriter, recordWriter: RecordWriter, rumContextProvider: RumContextProvider, - application: Application + application: Application, + embeddedContentSlotRegistry: EmbeddedContentSlotRegistry ): Recorder { return SessionReplayRecorder( application, @@ -93,6 +95,7 @@ internal class DefaultRecorderProvider( sdkCore = sdkCore, dynamicOptimizationEnabled = dynamicOptimizationEnabled, internalCallback = internalCallback, + embeddedContentSlotRegistry = embeddedContentSlotRegistry, heatmapIdentifierRegistry = if (heatmapsEnabled) LazyHeatmapIdentifierRegistry(sdkCore) else null ) } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/RecorderProvider.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/RecorderProvider.kt index a048aa10a6..fabd656722 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/RecorderProvider.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/RecorderProvider.kt @@ -7,6 +7,7 @@ package com.datadog.android.sessionreplay.internal import android.app.Application +import com.datadog.android.sessionreplay.internal.embedded.EmbeddedContentSlotRegistry import com.datadog.android.sessionreplay.internal.recorder.Recorder import com.datadog.android.sessionreplay.internal.resources.ResourceDataStoreManager import com.datadog.android.sessionreplay.internal.storage.RecordWriter @@ -19,6 +20,7 @@ internal fun interface RecorderProvider { resourceWriter: ResourcesWriter, recordWriter: RecordWriter, rumContextProvider: RumContextProvider, - application: Application + application: Application, + embeddedContentSlotRegistry: EmbeddedContentSlotRegistry ): Recorder } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeature.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeature.kt index e970c94b90..3b03ca72a6 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeature.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeature.kt @@ -8,6 +8,7 @@ package com.datadog.android.sessionreplay.internal import android.app.Application import android.content.Context +import androidx.annotation.UiThread import com.datadog.android.api.InternalLogger import com.datadog.android.api.SdkCore import com.datadog.android.api.feature.Feature @@ -25,13 +26,22 @@ import com.datadog.android.sessionreplay.SessionReplayInternalCallback import com.datadog.android.sessionreplay.SessionReplayPrivacy import com.datadog.android.sessionreplay.TextAndInputPrivacy import com.datadog.android.sessionreplay.TouchPrivacy +import com.datadog.android.sessionreplay.internal.embedded.EmbeddedContentEvent +import com.datadog.android.sessionreplay.internal.embedded.EmbeddedContentReceiver +import com.datadog.android.sessionreplay.internal.embedded.EmbeddedContentSlotRegistration +import com.datadog.android.sessionreplay.internal.embedded.EmbeddedContentSlotRegistry import com.datadog.android.sessionreplay.internal.net.BatchesToSegmentsMapper import com.datadog.android.sessionreplay.internal.net.SegmentRequestFactory +import com.datadog.android.sessionreplay.internal.processor.DefaultResourceProcessor +import com.datadog.android.sessionreplay.internal.processor.NoOpResourceProcessor +import com.datadog.android.sessionreplay.internal.processor.ResourceProcessor import com.datadog.android.sessionreplay.internal.recorder.NoOpRecorder import com.datadog.android.sessionreplay.internal.recorder.Recorder import com.datadog.android.sessionreplay.internal.resources.ResourceDataStoreManager import com.datadog.android.sessionreplay.internal.resources.ResourceHashesEntryDeserializer import com.datadog.android.sessionreplay.internal.resources.ResourceHashesEntrySerializer +import com.datadog.android.sessionreplay.internal.storage.EmbeddedContentRecordWriter +import com.datadog.android.sessionreplay.internal.storage.NoOpEmbeddedContentRecordWriter import com.datadog.android.sessionreplay.internal.storage.NoOpRecordWriter import com.datadog.android.sessionreplay.internal.storage.RecordWriter import com.datadog.android.sessionreplay.internal.storage.SessionReplayRecordWriter @@ -58,6 +68,7 @@ internal class SessionReplayFeature( ) : StorageBackedFeature, FeatureEventReceiver { private val currentRumSessionId = AtomicReference() + internal val embeddedContentSlotRegistry = EmbeddedContentSlotRegistry() @Suppress("LongParameterList") internal constructor( @@ -121,7 +132,19 @@ internal class SessionReplayFeature( internal var sessionReplayRecorder: Recorder = NoOpRecorder() internal var dataWriter: RecordWriter = NoOpRecordWriter() internal val initialized = AtomicBoolean(false) - private val rumContextProvider = SessionReplayRumContextProvider() + + private val rumContextProvider = SessionReplayRumContextProvider { + onRumViewChanged() + } + private var resourceProcessor: ResourceProcessor = NoOpResourceProcessor() + private var embeddedContentRecordWriter: EmbeddedContentRecordWriter = NoOpEmbeddedContentRecordWriter() + private val embeddedContentReceiver = EmbeddedContentReceiver( + rumContextProvider = rumContextProvider, + recordWriter = { embeddedContentRecordWriter }, + resourceProcessor = { resourceProcessor }, + isRecording = { isRecording.get() }, + internalLogger = sdkCore.internalLogger + ) // region Feature @@ -144,7 +167,13 @@ internal class SessionReplayFeature( resourceHashesDeserializer = ResourceHashesEntryDeserializer(internalLogger = sdkCore.internalLogger) ) - dataWriter = createDataWriter() + val sessionReplayRecordWriter = createDataWriter() + dataWriter = sessionReplayRecordWriter + embeddedContentRecordWriter = sessionReplayRecordWriter + resourceProcessor = DefaultResourceProcessor( + resourceDataStoreManager = resourceDataStoreManager, + resourcesWriter = resourcesFeature.dataWriter + ) sdkCore.setContextUpdateReceiver(rumContextProvider) sessionReplayRecorder = recorderProvider.provideSessionReplayRecorder( @@ -152,7 +181,8 @@ internal class SessionReplayFeature( resourceWriter = resourcesFeature.dataWriter, recordWriter = dataWriter, rumContextProvider = rumContextProvider, - application = appContext + application = appContext, + embeddedContentSlotRegistry = embeddedContentSlotRegistry ) sessionReplayRecorder.registerCallbacks() initialized.set(true) @@ -182,6 +212,8 @@ internal class SessionReplayFeature( sessionReplayRecorder.unregisterCallbacks() sessionReplayRecorder.stopProcessingRecords() dataWriter = NoOpRecordWriter() + embeddedContentRecordWriter = NoOpEmbeddedContentRecordWriter() + resourceProcessor = NoOpResourceProcessor() sessionReplayRecorder = NoOpRecorder() initialized.set(false) } @@ -190,21 +222,36 @@ internal class SessionReplayFeature( // region EventReceiver - override fun onReceive(event: Any) { - if (event !is Map<*, *>) { - sdkCore.internalLogger.log( - InternalLogger.Level.WARN, - InternalLogger.Target.USER, - { UNSUPPORTED_EVENT_TYPE.format(Locale.US, event::class.java.canonicalName) } - ) - return + internal fun receiveEmbeddedContentEvent(event: EmbeddedContentEvent) { + if (checkIfInitialized()) { + embeddedContentReceiver.receive(event) } + } - if (!checkIfInitialized()) { - return - } + @UiThread + internal fun notifyEmbeddedContentSlotChanged( + previousRegistration: EmbeddedContentSlotRegistration?, + newRegistration: EmbeddedContentSlotRegistration? + ) { + embeddedContentSlotRegistry.notifySlotChanged(previousRegistration, newRegistration) + } - handleRumSession(event) + override fun onReceive(event: Any) { + when (event) { + is EmbeddedContentEvent -> receiveEmbeddedContentEvent(event) + is Map<*, *> -> { + if (checkIfInitialized()) { + handleRumSession(event) + } + } + else -> { + sdkCore.internalLogger.log( + InternalLogger.Level.WARN, + InternalLogger.Target.USER, + { UNSUPPORTED_EVENT_TYPE.format(Locale.US, event::class.java.canonicalName) } + ) + } + } } // endregion @@ -364,9 +411,15 @@ internal class SessionReplayFeature( } } - private fun createDataWriter(): RecordWriter { + private fun createDataWriter(): SessionReplayRecordWriter { val recordCallback = SessionReplayRecordCallback(sdkCore) - return SessionReplayRecordWriter(sdkCore, recordCallback) + return SessionReplayRecordWriter( + sdkCore, + recordCallback, + { viewId, recordsCount -> + recordCallback.onEmbeddedRecordsForViewSent(viewId, recordsCount) + } + ) } /** @@ -381,6 +434,15 @@ internal class SessionReplayFeature( } } + private fun onRumViewChanged() { + if (!isRecording.get()) { + return + } + if (embeddedContentSlotRegistry.hasMarkedSlots()) { + sessionReplayRecorder.requestCapture() + } + } + // endregion // region resourcesFeature diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayRecordCallback.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayRecordCallback.kt index 3b83fde495..c342a8ea04 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayRecordCallback.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayRecordCallback.kt @@ -14,19 +14,26 @@ internal class SessionReplayRecordCallback( private val featureSdkCore: FeatureSdkCore ) : RecordCallback { - @Suppress("UNCHECKED_CAST") override fun onRecordForViewSent(record: EnrichedRecord) { - val recordsSize = record.records.size - if (recordsSize > 0) { - // we are already past the event processing pipeline and this method is called from write stage, we can - // update directly from this thread, given we patially mutate context. - featureSdkCore.updateFeatureContext(Feature.SESSION_REPLAY_FEATURE_NAME, useContextThread = false) { - val viewId = record.viewId - val viewMetadata = (it[viewId] as? MutableMap) ?: mutableMapOf() - viewMetadata[HAS_REPLAY_KEY] = true - updateRecordsCount(viewMetadata, recordsSize) - it[viewId] = viewMetadata - } + updateViewMetadata(record.viewId, record.records.size) + } + + fun onEmbeddedRecordsForViewSent(viewId: String, recordsCount: Int) { + updateViewMetadata(viewId, recordsCount) + } + + @Suppress("UNCHECKED_CAST") + private fun updateViewMetadata(viewId: String, recordsCount: Int) { + if (recordsCount <= 0) { + return + } + // This callback runs inside the synchronized write stage after persistence succeeds. + // Update immediately so native and embedded record counts cannot be observed between writes. + featureSdkCore.updateFeatureContext(Feature.SESSION_REPLAY_FEATURE_NAME, useContextThread = false) { + val viewMetadata = (it[viewId] as? MutableMap) ?: mutableMapOf() + viewMetadata[HAS_REPLAY_KEY] = true + updateRecordsCount(viewMetadata, recordsCount) + it[viewId] = viewMetadata } } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayRumContextProvider.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayRumContextProvider.kt index 10d4033d70..c9f67bfdef 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayRumContextProvider.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayRumContextProvider.kt @@ -12,7 +12,9 @@ import com.datadog.android.sessionreplay.internal.utils.RumContextProvider import com.datadog.android.sessionreplay.internal.utils.SessionReplayRumContext import java.util.UUID -internal class SessionReplayRumContextProvider : RumContextProvider, FeatureContextUpdateReceiver { +internal class SessionReplayRumContextProvider( + private val onRumViewChanged: () -> Unit = {} +) : RumContextProvider, FeatureContextUpdateReceiver { @Volatile private var rumContext = emptyMap() @@ -20,11 +22,11 @@ internal class SessionReplayRumContextProvider : RumContextProvider, FeatureCont override fun getRumContext(): SessionReplayRumContext { return rumContext.let { SessionReplayRumContext( - applicationId = it["application_id"] as? String ?: NULL_UUID, - sessionId = it["session_id"] as? String ?: NULL_UUID, - viewId = it["view_id"] as? String ?: NULL_UUID, + applicationId = it[RUM_APPLICATION_ID_CONTEXT_KEY] as? String ?: NULL_UUID, + sessionId = it[RUM_SESSION_ID_CONTEXT_KEY] as? String ?: NULL_UUID, + viewId = it[RUM_VIEW_ID_CONTEXT_KEY] as? String ?: NULL_UUID, // TODO RUM-3785 Share this property somehow, defined in RumFeature.VIEW_TIMESTAMP_OFFSET_IN_MS_KEY - viewTimeOffsetMs = it["view_timestamp_offset"] as? Long ?: 0L, + viewTimeOffsetMs = it[RUM_VIEW_TIME_OFFSET_CONTEXT_KEY] as? Long ?: 0L, viewUrl = it[RUM_VIEW_URL_CONTEXT_KEY] as? String ) } @@ -32,6 +34,11 @@ internal class SessionReplayRumContextProvider : RumContextProvider, FeatureCont override fun onContextUpdate(featureName: String, context: Map) { if (featureName == Feature.RUM_FEATURE_NAME) { + val previousViewId = rumContext[RUM_VIEW_ID_CONTEXT_KEY] as? String + val newViewId = context[RUM_VIEW_ID_CONTEXT_KEY] as? String + if (newViewId != null && newViewId != NULL_UUID && newViewId != previousViewId) { + onRumViewChanged() + } rumContext = context } } @@ -39,7 +46,11 @@ internal class SessionReplayRumContextProvider : RumContextProvider, FeatureCont companion object { val NULL_UUID = UUID(0, 0).toString() - // Mirrors RumContext.VIEW_URL in the RUM module. + // Mirrors feature-context keys from RumContext in the RUM module. + internal const val RUM_APPLICATION_ID_CONTEXT_KEY = "application_id" + internal const val RUM_SESSION_ID_CONTEXT_KEY = "session_id" + internal const val RUM_VIEW_ID_CONTEXT_KEY = "view_id" + internal const val RUM_VIEW_TIME_OFFSET_CONTEXT_KEY = "view_timestamp_offset" internal const val RUM_VIEW_URL_CONTEXT_KEY = "view_url" } } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/embedded/EmbeddedContentEvent.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/embedded/EmbeddedContentEvent.kt new file mode 100644 index 0000000000..a59404c59a --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/embedded/EmbeddedContentEvent.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.embedded + +internal sealed interface EmbeddedContentEvent { + + data class RecordBatch( + val records: List>, + val slotId: String, + val viewId: String + ) : EmbeddedContentEvent + + data class Resource( + val identifier: String, + val data: ByteArray, + val mimeType: String + ) : EmbeddedContentEvent { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Resource + + if (identifier != other.identifier) return false + if (!data.contentEquals(other.data)) return false + return mimeType == other.mimeType + } + + override fun hashCode(): Int { + var result = identifier.hashCode() + result = 31 * result + data.contentHashCode() + result = 31 * result + mimeType.hashCode() + return result + } + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/embedded/EmbeddedContentReceiver.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/embedded/EmbeddedContentReceiver.kt new file mode 100644 index 0000000000..be7c0fedaf --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/embedded/EmbeddedContentReceiver.kt @@ -0,0 +1,103 @@ +/* + * 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.embedded + +import com.datadog.android.api.InternalLogger +import com.datadog.android.core.internal.utils.JsonSerializer +import com.datadog.android.sessionreplay.internal.processor.ResourceProcessor +import com.datadog.android.sessionreplay.internal.storage.EmbeddedContentRecordWriter +import com.datadog.android.sessionreplay.internal.utils.RumContextProvider +import com.google.gson.JsonArray +import com.google.gson.JsonObject + +internal class EmbeddedContentReceiver( + private val rumContextProvider: RumContextProvider, + private val recordWriter: () -> EmbeddedContentRecordWriter, + private val resourceProcessor: () -> ResourceProcessor, + private val isRecording: () -> Boolean, + private val internalLogger: InternalLogger +) { + fun receive(event: EmbeddedContentEvent) { + if (!isRecording()) { + return + } + when (event) { + is EmbeddedContentEvent.RecordBatch -> { + if (event.records.isNotEmpty()) { + process(event) + } + } + + is EmbeddedContentEvent.Resource -> { + if (rumContextProvider.getRumContext().hasValidApplicationAndSession()) { + process(event) + } + } + } + } + + private fun process(event: EmbeddedContentEvent) { + when (event) { + is EmbeddedContentEvent.RecordBatch -> { + val rumContext = rumContextProvider.getRumContext() + if (rumContext.isNotValid()) { + return + } + val records = JsonArray() + .also { array -> + event.records.forEach { record -> + val jsonRecord = JsonSerializer.toJsonElement(record) + if (jsonRecord is JsonObject) { + jsonRecord.addProperty(SLOT_ID_KEY, event.slotId) + array.add(jsonRecord) + } else { + logInvalidRecord() + } + } + } + + if (records.size() == 0) { + return + } + + val enrichedRecord = JsonObject().apply { + addProperty(APPLICATION_ID_KEY, rumContext.applicationId) + addProperty(SESSION_ID_KEY, rumContext.sessionId) + addProperty(VIEW_ID_KEY, event.viewId) + add(RECORDS_KEY, records) + } + recordWriter().writeRaw( + enrichedRecord.toString().toByteArray(Charsets.UTF_8), + event.viewId, + records.size() + ) + } + + is EmbeddedContentEvent.Resource -> { + resourceProcessor().process(event.identifier, event.data, event.mimeType) + } + } + } + + private fun logInvalidRecord() { + internalLogger.log( + InternalLogger.Level.WARN, + InternalLogger.Target.MAINTAINER, + { INVALID_EMBEDDED_RECORD_MESSAGE } + ) + } + + companion object { + internal const val APPLICATION_ID_KEY = "application_id" + internal const val SESSION_ID_KEY = "session_id" + internal const val VIEW_ID_KEY = "view_id" + internal const val RECORDS_KEY = "records" + internal const val SLOT_ID_KEY = "slotId" + internal const val INVALID_EMBEDDED_RECORD_MESSAGE = + "Session Replay received an invalid embedded content record." + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/embedded/EmbeddedContentSlotRegistry.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/embedded/EmbeddedContentSlotRegistry.kt new file mode 100644 index 0000000000..00e24c9ae6 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/embedded/EmbeddedContentSlotRegistry.kt @@ -0,0 +1,85 @@ +/* + * 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.embedded + +import androidx.annotation.AnyThread +import androidx.annotation.UiThread +import java.lang.ref.WeakReference +import java.util.concurrent.atomic.AtomicBoolean + +internal class EmbeddedContentSlotRegistration( + val slotId: String +) { + private val active = AtomicBoolean(true) + + fun deactivate() { + active.set(false) + } + + fun isActive(): Boolean = active.get() +} + +internal class EmbeddedContentSlotRegistry { + private val registrations = mutableListOf>() + + @AnyThread + fun hasMarkedSlots(): Boolean { + return synchronized(registrations) { + removeInactiveRegistrations() + registrations.isNotEmpty() + } + } + + @AnyThread + fun isSlotMarked(slotId: String): Boolean { + return synchronized(registrations) { + removeInactiveRegistrations() + registrations.any { it.get()?.slotId == slotId } + } + } + + @UiThread + fun notifySlotChanged( + previousRegistration: EmbeddedContentSlotRegistration?, + newRegistration: EmbeddedContentSlotRegistration? + ) { + previousRegistration?.deactivate() + synchronized(registrations) { + registrations.removeAll { + val registration = it.get() + registration == null || + !registration.isActive() || + registration === previousRegistration + } + trackRegistration(newRegistration) + } + } + + @UiThread + fun track(registration: EmbeddedContentSlotRegistration) { + synchronized(registrations) { + removeInactiveRegistrations() + trackRegistration(registration) + } + } + + private fun trackRegistration(registration: EmbeddedContentSlotRegistration?) { + val isAlreadyTracked = registrations.any { it.get() === registration } + if (registration != null && registration.isActive() && !isAlreadyTracked) { + @Suppress("UnsafeThirdPartyFunctionCall") // WeakReference construction has no documented exception. + val weakRegistration = WeakReference(registration) + registrations += weakRegistration + } + } + + private fun removeInactiveRegistrations() { + registrations.removeAll { + val registration = it.get() + registration == null || !registration.isActive() + } + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/MutationResolver.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/MutationResolver.kt index 674501a303..78ff0bdb24 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/MutationResolver.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/MutationResolver.kt @@ -150,16 +150,6 @@ internal class MutationResolver(private val internalLogger: InternalLogger) { ) ) } - } else if (oldWireframe is MobileSegment.Wireframe.EmbeddedContentWireframe) { - if (oldWireframe.isVisible != false) { - updates.add( - MobileSegment.WireframeUpdateMutation.EmbeddedContentWireframeUpdate( - id = oldWireframe.id, - slotId = oldWireframe.slotId, - isVisible = false - ) - ) - } } else { // Old element was removed removes.add(MobileSegment.Remove(oldWireframe.id())) diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/RecordedDataProcessor.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/RecordedDataProcessor.kt index 807f1ce860..63b719e79a 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/RecordedDataProcessor.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/RecordedDataProcessor.kt @@ -24,12 +24,16 @@ import java.util.concurrent.TimeUnit @Suppress("TooManyFunctions") internal class RecordedDataProcessor( - private val resourceDataStoreManager: ResourceDataStoreManager, - private val resourcesWriter: ResourcesWriter, + resourceDataStoreManager: ResourceDataStoreManager, + resourcesWriter: ResourcesWriter, private val writer: RecordWriter, private val mutationResolver: MutationResolver, private val timeProvider: TimeProvider, - private val nodeFlattener: NodeFlattener = NodeFlattener() + private val nodeFlattener: NodeFlattener = NodeFlattener(), + private val resourceProcessor: ResourceProcessor = DefaultResourceProcessor( + resourceDataStoreManager, + resourcesWriter + ) ) : Processor { private var prevSnapshot: List = emptyList() private var lastSnapshotTimestamp = 0L @@ -40,24 +44,7 @@ internal class RecordedDataProcessor( override fun processResources( item: ResourceRecordedDataQueueItem ) { - val resourceHash = item.identifier - val isKnownResource = resourceDataStoreManager.isPreviouslySentResource(resourceHash) - - if (!isKnownResource) { - // the cacheResourceHash method overwrites the datastore entry and we don't want that if we haven't finished - // initializing - if (resourceDataStoreManager.isReady()) { - resourceDataStoreManager.cacheResourceHash(resourceHash) - } - - val enrichedResource = EnrichedResource( - resource = item.resourceData, - filename = resourceHash, - mimeType = item.mimeType - ) - - resourcesWriter.write(enrichedResource) - } + resourceProcessor.process(item.identifier, item.resourceData, item.mimeType) } @WorkerThread diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/ResourceProcessor.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/ResourceProcessor.kt new file mode 100644 index 0000000000..b9c11c3b08 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/ResourceProcessor.kt @@ -0,0 +1,36 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.processor + +import com.datadog.android.sessionreplay.internal.resources.ResourceDataStoreManager +import com.datadog.android.sessionreplay.internal.storage.ResourcesWriter +import com.datadog.tools.annotation.NoOpImplementation + +@NoOpImplementation +internal fun interface ResourceProcessor { + fun process(identifier: String, data: ByteArray, mimeType: String?) +} + +internal class DefaultResourceProcessor( + private val resourceDataStoreManager: ResourceDataStoreManager, + private val resourcesWriter: ResourcesWriter +) : ResourceProcessor { + + override fun process(identifier: String, data: ByteArray, mimeType: String?) { + if (!resourceDataStoreManager.markResourceAsSentIfNew(identifier)) { + return + } + + resourcesWriter.write( + EnrichedResource( + resource = data, + filename = identifier, + mimeType = mimeType + ) + ) + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/WireframeUtils.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/WireframeUtils.kt index 23badf5080..ae11a77aec 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/WireframeUtils.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/WireframeUtils.kt @@ -46,19 +46,25 @@ internal class WireframeUtils(private val boundsUtils: BoundsUtils = BoundsUtils wireframe: MobileSegment.Wireframe, topWireframes: List ): Boolean { + if (wireframe is MobileSegment.Wireframe.EmbeddedContentWireframe && + wireframe.isVisible == false + ) { + return false + } val wireframeBounds = boundsUtils.resolveBounds(wireframe) - topWireframes.forEach { + return topWireframes.any { val topBounds = boundsUtils.resolveBounds(it) - if (boundsUtils.isCovering(topBounds, wireframeBounds) && + boundsUtils.isCovering(topBounds, wireframeBounds) && it.hasOpaqueBackground() - ) { - return true - } } - return false } internal fun checkWireframeIsValid(wireframe: MobileSegment.Wireframe): Boolean { + if (wireframe is MobileSegment.Wireframe.EmbeddedContentWireframe && + wireframe.isVisible == false + ) { + return true + } val wireframeBounds = boundsUtils.resolveBounds(wireframe) return ( wireframeBounds.width > 0 && diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/Recorder.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/Recorder.kt index 0e38f0ba20..f928532ae0 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/Recorder.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/Recorder.kt @@ -18,5 +18,7 @@ internal interface Recorder { fun resumeRecorders() + fun requestCapture() + fun stopRecorders() } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/SessionReplayRecorder.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/SessionReplayRecorder.kt index a48055d562..473fc61d72 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/SessionReplayRecorder.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/SessionReplayRecorder.kt @@ -25,12 +25,14 @@ import com.datadog.android.sessionreplay.internal.LifecycleCallback import com.datadog.android.sessionreplay.internal.SessionReplayLifecycleCallback import com.datadog.android.sessionreplay.internal.TouchPrivacyManager import com.datadog.android.sessionreplay.internal.async.RecordedDataQueueHandler +import com.datadog.android.sessionreplay.internal.embedded.EmbeddedContentSlotRegistry import com.datadog.android.sessionreplay.internal.processor.MutationResolver import com.datadog.android.sessionreplay.internal.processor.RecordedDataProcessor import com.datadog.android.sessionreplay.internal.processor.ResourceQueueImpl import com.datadog.android.sessionreplay.internal.processor.RumContextDataHandler import com.datadog.android.sessionreplay.internal.recorder.callback.OnWindowRefreshedCallback import com.datadog.android.sessionreplay.internal.recorder.mapper.DecorViewMapper +import com.datadog.android.sessionreplay.internal.recorder.mapper.EmbeddedContentViewMapper import com.datadog.android.sessionreplay.internal.recorder.mapper.HiddenViewMapper import com.datadog.android.sessionreplay.internal.recorder.mapper.ViewWireframeMapper import com.datadog.android.sessionreplay.internal.recorder.resources.BitmapCachesManager @@ -93,6 +95,7 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { resourceDataStoreManager: ResourceDataStoreManager, dynamicOptimizationEnabled: Boolean, internalCallback: SessionReplayInternalCallback, + embeddedContentSlotRegistry: EmbeddedContentSlotRegistry, heatmapIdentifierRegistry: HeatmapIdentifierRegistry? = null ) { val internalLogger = sdkCore.internalLogger @@ -138,6 +141,15 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { viewBoundsResolver, drawableToColorMapper ) + val viewUtilsInternal = ViewUtilsInternal() + val embeddedContentViewMapper = EmbeddedContentViewMapper( + viewIdentifierResolver = viewIdentifierResolver, + colorStringFormatter = colorStringFormatter, + viewBoundsResolver = viewBoundsResolver, + drawableToColorMapper = drawableToColorMapper, + viewUtilsInternal = viewUtilsInternal, + embeddedContentSlotRegistry = embeddedContentSlotRegistry + ) val bitmapCachesManager = BitmapCachesManager( bitmapPool = BitmapPool(), @@ -180,8 +192,9 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { viewBoundsResolver = viewBoundsResolver, viewIdentifierResolver = viewIdentifierResolver ), - viewUtilsInternal = ViewUtilsInternal(), - internalLogger = internalLogger + viewUtilsInternal = viewUtilsInternal, + internalLogger = internalLogger, + embeddedContentViewMapper = embeddedContentViewMapper ), optionSelectorDetector = ComposedOptionSelectorDetector( customOptionSelectorDetectors + DefaultOptionSelectorDetector() @@ -194,7 +207,8 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { registry = it, internalLogger = internalLogger ) - } + }, + embeddedContentViewMapper = embeddedContentViewMapper ), recordedDataQueueHandler = recordedDataQueueHandler, sdkCore = sdkCore, @@ -287,6 +301,14 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { } } + override fun requestCapture() { + uiHandler.post { + if (shouldRecord) { + viewOnDrawInterceptor.requestCapture() + } + } + } + override fun stopRecorders() { uiHandler.post { viewOnDrawInterceptor.stopIntercepting() diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/SnapshotProducer.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/SnapshotProducer.kt index 67955fe7f9..adc4c4d595 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/SnapshotProducer.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/SnapshotProducer.kt @@ -17,6 +17,7 @@ import com.datadog.android.sessionreplay.TextAndInputPrivacy import com.datadog.android.sessionreplay.internal.TouchPrivacyManager import com.datadog.android.sessionreplay.internal.async.RecordedDataQueueRefs import com.datadog.android.sessionreplay.internal.recorder.callback.DefaultInteropViewCallback +import com.datadog.android.sessionreplay.internal.recorder.mapper.EmbeddedContentViewMapper import com.datadog.android.sessionreplay.model.MobileSegment import com.datadog.android.sessionreplay.recorder.MappingContext import com.datadog.android.sessionreplay.recorder.OptionSelectorDetector @@ -30,9 +31,20 @@ internal class SnapshotProducer( private val optionSelectorDetector: OptionSelectorDetector, private val touchPrivacyManager: TouchPrivacyManager, private val internalLogger: InternalLogger, - private val heatmapResolver: HeatmapIdentifierResolver? = null + private val heatmapResolver: HeatmapIdentifierResolver? = null, + private val embeddedContentViewMapper: EmbeddedContentViewMapper? = null ) { + @UiThread + fun beginSnapshot() { + embeddedContentViewMapper?.beginSnapshot() + } + + @UiThread + fun finishSnapshot(): Node? { + return embeddedContentViewMapper?.finishSnapshot() + } + @UiThread fun produce( rootView: View, diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/TreeViewTraversal.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/TreeViewTraversal.kt index 9c704d6fbb..7720c258b0 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/TreeViewTraversal.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/TreeViewTraversal.kt @@ -18,6 +18,7 @@ import com.datadog.android.sessionreplay.R import com.datadog.android.sessionreplay.TouchPrivacy import com.datadog.android.sessionreplay.internal.async.RecordedDataQueueRefs import com.datadog.android.sessionreplay.internal.recorder.SnapshotProducer.Companion.INVALID_PRIVACY_LEVEL_ERROR +import com.datadog.android.sessionreplay.internal.recorder.mapper.EmbeddedContentViewMapper import com.datadog.android.sessionreplay.internal.recorder.mapper.HiddenViewMapper import com.datadog.android.sessionreplay.internal.recorder.mapper.QueueStatusCallback import com.datadog.android.sessionreplay.model.MobileSegment @@ -34,17 +35,20 @@ internal class TreeViewTraversal( private val hiddenViewMapper: HiddenViewMapper, private val decorViewMapper: WireframeMapper, private val viewUtilsInternal: ViewUtilsInternal, - private val internalLogger: InternalLogger + private val internalLogger: InternalLogger, + private val embeddedContentViewMapper: EmbeddedContentViewMapper? = null ) { - @Suppress("ReturnCount") + @Suppress("ReturnCount", "LongMethod", "ComplexCondition") @UiThread fun traverse( view: View, mappingContext: MappingContext, recordedDataQueueRefs: RecordedDataQueueRefs ): TraversedTreeView { - if (viewUtilsInternal.isNotVisible(view) || + val isNotVisible = viewUtilsInternal.isNotVisible(view) + val embeddedMapper = embeddedContentViewMapper?.takeIf { it.hasSlotId(view) } + if ((embeddedMapper == null && isNotVisible) || viewUtilsInternal.isSystemNoise(view) || viewUtilsInternal.isOnSecondaryDisplay(view) ) { @@ -61,9 +65,16 @@ internal class TreeViewTraversal( updateTouchOverrideAreas(view, mappingContext) if (isHidden(view)) { + if (isNotVisible) { + return TraversedTreeView(emptyList(), TraversalStrategy.STOP_AND_DROP_NODE) + } traversalStrategy = TraversalStrategy.STOP_AND_RETURN_NODE mapper = hiddenViewMapper jobStatusCallback = noOpCallback + } else if (embeddedMapper != null) { + traversalStrategy = TraversalStrategy.STOP_AND_RETURN_NODE + mapper = embeddedMapper + jobStatusCallback = noOpCallback } else if (mapper != null) { jobStatusCallback = QueueStatusCallback(recordedDataQueueRefs) traversalStrategy = if (mapper is TraverseAllChildrenMapper) { diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/ViewOnDrawInterceptor.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/ViewOnDrawInterceptor.kt index 4686ed185e..0ce8f14892 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/ViewOnDrawInterceptor.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/ViewOnDrawInterceptor.kt @@ -63,6 +63,13 @@ internal class ViewOnDrawInterceptor( decorOnDrawListeners.clear() } + fun requestCapture() { + // Copy before callbacks because a capture may change the registered listener collection. + @Suppress("UnsafeThirdPartyFunctionCall") // WeakHashMap access is confined to the UI thread. + val listeners = decorOnDrawListeners.values.toSet() + listeners.forEach { it.onDraw() } + } + private fun stopInterceptingAndRemove(decorViews: List) { decorViews.forEach { decorView -> decorOnDrawListeners.remove(decorView)?.let { listener -> diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/listener/WindowsOnDrawListener.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/listener/WindowsOnDrawListener.kt index aaab521e14..8400b5b425 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/listener/WindowsOnDrawListener.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/listener/WindowsOnDrawListener.kt @@ -72,7 +72,8 @@ internal class WindowsOnDrawListener( withinSRBenchmarkSpan(BENCHMARK_SPAN_SNAPSHOT_PRODUCER, isContainer = true) { val recordedDataQueueRefs = RecordedDataQueueRefs(recordedDataQueueHandler) recordedDataQueueRefs.recordedDataQueueItem = item - rootViews.mapNotNull { + snapshotProducer.beginSnapshot() + val snapshotNodes = rootViews.mapNotNull { snapshotProducer.produce( rootView = it, systemInformation = systemInformation, @@ -82,6 +83,11 @@ internal class WindowsOnDrawListener( activeRumViewUrl = currentViewUrl ) } + snapshotProducer.finishSnapshot()?.let { + @Suppress("UnsafeThirdPartyFunctionCall") // Kotlin listOf cannot fail for this local value. + val finishSnapshotNodes = listOf(it) + finishSnapshotNodes + snapshotNodes + } ?: snapshotNodes } } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/EmbeddedContentViewMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/EmbeddedContentViewMapper.kt new file mode 100644 index 0000000000..23bcc99098 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/EmbeddedContentViewMapper.kt @@ -0,0 +1,188 @@ +/* + * 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.recorder.mapper + +import android.view.View +import androidx.annotation.UiThread +import com.datadog.android.api.InternalLogger +import com.datadog.android.sessionreplay.R +import com.datadog.android.sessionreplay.internal.embedded.EmbeddedContentSlotRegistration +import com.datadog.android.sessionreplay.internal.embedded.EmbeddedContentSlotRegistry +import com.datadog.android.sessionreplay.internal.recorder.Node +import com.datadog.android.sessionreplay.internal.recorder.ViewUtilsInternal +import com.datadog.android.sessionreplay.model.MobileSegment +import com.datadog.android.sessionreplay.recorder.MappingContext +import com.datadog.android.sessionreplay.recorder.mapper.BaseWireframeMapper +import com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback +import com.datadog.android.sessionreplay.utils.ColorStringFormatter +import com.datadog.android.sessionreplay.utils.DrawableToColorMapper +import com.datadog.android.sessionreplay.utils.ViewBoundsResolver +import com.datadog.android.sessionreplay.utils.ViewIdentifierResolver + +internal class EmbeddedContentViewMapper( + viewIdentifierResolver: ViewIdentifierResolver, + colorStringFormatter: ColorStringFormatter, + viewBoundsResolver: ViewBoundsResolver, + drawableToColorMapper: DrawableToColorMapper, + private val viewUtilsInternal: ViewUtilsInternal, + private val embeddedContentSlotRegistry: EmbeddedContentSlotRegistry +) : BaseWireframeMapper( + viewIdentifierResolver, + colorStringFormatter, + viewBoundsResolver, + drawableToColorMapper +) { + private val cache = EmbeddedContentViewCache(embeddedContentSlotRegistry) + private var isSnapshotActive = false + + @UiThread + fun hasSlotId(view: View): Boolean = view.sessionReplaySlotId() != null + + @UiThread + fun beginSnapshot() { + isSnapshotActive = !cache.isEmpty() || embeddedContentSlotRegistry.hasMarkedSlots() + if (isSnapshotActive) { + cache.beginSnapshot() + } + } + + @UiThread + fun finishSnapshot(): Node? { + if (!isSnapshotActive) { + return null + } + isSnapshotActive = false + val hiddenWireframes = cache.hiddenWireframes() + return hiddenWireframes.takeIf { it.isNotEmpty() }?.let { Node(wireframes = it) } + } + + @UiThread + @Suppress("ReturnCount") + override fun map( + view: View, + mappingContext: MappingContext, + asyncJobStatusCallback: AsyncJobStatusCallback, + internalLogger: InternalLogger + ): List { + val registration = view.sessionReplaySlotRegistration() ?: return emptyList() + val slotId = registration.slotId + embeddedContentSlotRegistry.track(registration) + isSnapshotActive = true + val wireframeId = viewIdentifierResolver.resolveChildUniqueIdentifier( + view, + EMBEDDED_CONTENT_KEY_NAME + ) ?: return emptyList() + cache.record(slotId, wireframeId) + + if (viewUtilsInternal.isNotVisible(view)) { + return wireframeList(hiddenWireframe(wireframeId, slotId)) + } + + val bounds = viewBoundsResolver.resolveViewGlobalBounds( + view, + mappingContext.systemInformation.screenDensity + ) + val shapeStyle = view.background?.let { + resolveShapeStyle(it, view.alpha, internalLogger) + } + + return wireframeList( + MobileSegment.Wireframe.EmbeddedContentWireframe( + id = wireframeId, + x = bounds.x, + y = bounds.y, + width = bounds.width, + height = bounds.height, + shapeStyle = shapeStyle, + slotId = slotId, + isVisible = true + ) + ) + } + + @Suppress("UnsafeThirdPartyFunctionCall") // Kotlin listOf cannot fail for this local value. + private fun wireframeList(wireframe: MobileSegment.Wireframe): List { + return listOf(wireframe) + } + + private fun hiddenWireframe( + wireframeId: Long, + slotId: String + ): MobileSegment.Wireframe.EmbeddedContentWireframe { + return MobileSegment.Wireframe.EmbeddedContentWireframe( + id = wireframeId, + x = 0, + y = 0, + width = 0, + height = 0, + slotId = slotId, + isVisible = false + ) + } + + private fun View.sessionReplaySlotId(): String? { + return getTag(R.id.datadog_session_replay_slot_id) as? String + } + + private fun View.sessionReplaySlotRegistration(): EmbeddedContentSlotRegistration? { + return getTag(R.id.datadog_session_replay_slot_registration) as? EmbeddedContentSlotRegistration + } + + internal companion object { + internal const val EMBEDDED_CONTENT_KEY_NAME = "embedded_content" + } + + private class EmbeddedContentViewCache( + private val embeddedContentSlotRegistry: EmbeddedContentSlotRegistry + ) { + private data class Entry( + val wireframeId: Long, + var lastSeenSnapshot: Long + ) + + private val entries = mutableMapOf() + private var currentSnapshot: Long = 0 + + @UiThread + fun beginSnapshot() { + currentSnapshot++ + } + + @UiThread + fun isEmpty(): Boolean = entries.isEmpty() + + @UiThread + fun record(slotId: String, wireframeId: Long) { + entries[slotId] = Entry(wireframeId, currentSnapshot) + } + + @UiThread + // Iterator is locally guarded by hasNext; this mutable map supports iterator removal. + @Suppress("UnsafeThirdPartyFunctionCall") + fun hiddenWireframes(): List { + val hiddenWireframes = mutableListOf() + val iterator = entries.entries.iterator() + while (iterator.hasNext()) { + val (slotId, entry) = iterator.next() + if (!embeddedContentSlotRegistry.isSlotMarked(slotId)) { + iterator.remove() + } else if (entry.lastSeenSnapshot != currentSnapshot) { + hiddenWireframes += MobileSegment.Wireframe.EmbeddedContentWireframe( + id = entry.wireframeId, + x = 0, + y = 0, + width = 0, + height = 0, + slotId = slotId, + isVisible = false + ) + } + } + return hiddenWireframes + } + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/resources/ResourceDataStoreManager.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/resources/ResourceDataStoreManager.kt index 9148cb82dc..eb3aaed7d0 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/resources/ResourceDataStoreManager.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/resources/ResourceDataStoreManager.kt @@ -27,8 +27,10 @@ internal class ResourceDataStoreManager( ) { @Suppress("UnsafeThirdPartyFunctionCall") // map is initialized empty private val knownResources = Collections.newSetFromMap(ConcurrentHashMap()) + private val knownResourcesLock = Any() private val storedLastUpdateTimestampMs = AtomicLong(featureSdkCore.timeProvider.getDeviceTimestampMillis()) private val isInitialized = AtomicBoolean(false) // has init finished executing its async actions + private var hasUnpersistedResourceHashes = false init { fetchStoredResourceHashes( @@ -72,9 +74,19 @@ internal class ResourceDataStoreManager( internal fun isPreviouslySentResource(resourceHash: String): Boolean = knownResources.contains(resourceHash) - internal fun cacheResourceHash(resourceHash: String) { - knownResources.add(resourceHash) - writeResourcesToStore() + internal fun markResourceAsSentIfNew(resourceHash: String): Boolean { + return synchronized(knownResourcesLock) { + if (!knownResources.add(resourceHash)) { + false + } else { + if (isInitialized.get()) { + writeResourcesToStore() + } else { + hasUnpersistedResourceHashes = true + } + true + } + } } internal fun isReady(): Boolean = @@ -83,7 +95,13 @@ internal class ResourceDataStoreManager( // region internal private fun finishedInitializingManager() { - isInitialized.set(true) + synchronized(knownResourcesLock) { + isInitialized.set(true) + if (hasUnpersistedResourceHashes) { + hasUnpersistedResourceHashes = false + writeResourcesToStore() + } + } } private fun writeResourcesToStore() { diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/storage/EmbeddedContentRecordWriter.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/storage/EmbeddedContentRecordWriter.kt new file mode 100644 index 0000000000..0604f2f775 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/storage/EmbeddedContentRecordWriter.kt @@ -0,0 +1,14 @@ +/* + * 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.storage + +import com.datadog.tools.annotation.NoOpImplementation + +@NoOpImplementation +internal fun interface EmbeddedContentRecordWriter { + fun writeRaw(record: ByteArray, viewId: String, recordsCount: Int) +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/storage/SessionReplayRecordWriter.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/storage/SessionReplayRecordWriter.kt index 83c22e6734..4c66298a9a 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/storage/SessionReplayRecordWriter.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/storage/SessionReplayRecordWriter.kt @@ -15,8 +15,9 @@ import com.datadog.android.sessionreplay.internal.processor.EnrichedRecord internal class SessionReplayRecordWriter( private val sdkCore: FeatureSdkCore, - private val recordCallback: RecordCallback -) : RecordWriter { + private val recordCallback: RecordCallback, + private val onEmbeddedRecordWritten: (String, Int) -> Unit = { _, _ -> } +) : RecordWriter, EmbeddedContentRecordWriter { override fun write(record: EnrichedRecord) { sdkCore.getFeature(Feature.SESSION_REPLAY_FEATURE_NAME) ?.withWriteContext { _, writeScope -> @@ -37,6 +38,25 @@ internal class SessionReplayRecordWriter( } } + override fun writeRaw(record: ByteArray, viewId: String, recordsCount: Int) { + sdkCore.getFeature(Feature.SESSION_REPLAY_FEATURE_NAME) + ?.withWriteContext { _, writeScope -> + writeScope { + val rawBatchEvent = RawBatchEvent(data = record) + synchronized(this@SessionReplayRecordWriter) { + val success = it.write( + event = rawBatchEvent, + batchMetadata = null, + eventType = EventType.DEFAULT + ) + if (success) { + onEmbeddedRecordWritten(viewId, recordsCount) + } + } + } + } + } + private fun updateViewSent(record: EnrichedRecord) { /** * We have to see whether it's ok that this method is being called from the background. diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/SessionReplayRumContext.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/SessionReplayRumContext.kt index a22a724be3..507ec7e0df 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/SessionReplayRumContext.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/SessionReplayRumContext.kt @@ -27,10 +27,13 @@ internal data class SessionReplayRumContext( internal fun isNotValid(): Boolean = !isValid() internal fun isValid(): Boolean = - applicationId != NULL_UUID && - sessionId != NULL_UUID && + hasValidApplicationAndSession() && viewId != NULL_UUID + internal fun hasValidApplicationAndSession(): Boolean = + applicationId != NULL_UUID && + sessionId != NULL_UUID + companion object { private val NULL_UUID = UUID(0, 0).toString() } diff --git a/features/dd-sdk-android-session-replay/src/main/res/values/ids.xml b/features/dd-sdk-android-session-replay/src/main/res/values/ids.xml index 9aa089d8fd..050e9509b9 100644 --- a/features/dd-sdk-android-session-replay/src/main/res/values/ids.xml +++ b/features/dd-sdk-android-session-replay/src/main/res/values/ids.xml @@ -9,4 +9,6 @@ + + diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/PrivacyOverrideExtensionsTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/PrivacyOverrideExtensionsTest.kt index c0ad310198..6f41fb5bd2 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/PrivacyOverrideExtensionsTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/PrivacyOverrideExtensionsTest.kt @@ -19,6 +19,7 @@ import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings import org.mockito.kotlin.eq import org.mockito.kotlin.isNull +import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.quality.Strictness @@ -42,6 +43,7 @@ internal class PrivacyOverrideExtensionsTest { // Then verify(mockView).setTag(eq(R.id.datadog_hidden), eq(true)) + verify(mockView, never()).postInvalidateOnAnimation() } @Test @@ -54,6 +56,7 @@ internal class PrivacyOverrideExtensionsTest { // Then verify(mockView).setTag(eq(R.id.datadog_hidden), isNull()) + verify(mockView, never()).postInvalidateOnAnimation() } // endregion diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/SessionReplayInternalProxyTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/SessionReplayInternalProxyTest.kt index 5862b6b24d..3ead22224e 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/SessionReplayInternalProxyTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/SessionReplayInternalProxyTest.kt @@ -6,16 +6,32 @@ package com.datadog.android.sessionreplay +import android.view.View +import com.datadog.android.api.feature.Feature +import com.datadog.android.api.feature.FeatureScope +import com.datadog.android.api.feature.FeatureSdkCore import com.datadog.android.sessionreplay.forge.ForgeConfigurator +import com.datadog.android.sessionreplay.internal.SessionReplayFeature +import com.datadog.android.sessionreplay.internal.embedded.EmbeddedContentEvent +import com.datadog.android.sessionreplay.internal.embedded.EmbeddedContentSlotRegistration import fr.xgouchet.elmyr.annotation.FloatForgery import fr.xgouchet.elmyr.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +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.Mock import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.lang.ref.WeakReference @Extensions( ExtendWith(MockitoExtension::class), @@ -31,9 +47,31 @@ internal class SessionReplayInternalProxyTest { @Mock lateinit var mockInternalCallback: SessionReplayInternalCallback + @Mock + lateinit var mockView: View + + @Mock + lateinit var mockSdkCore: FeatureSdkCore + + @Mock + lateinit var mockFeatureScope: FeatureScope + + @Mock + lateinit var mockSessionReplayFeature: SessionReplayFeature + @FloatForgery var fakeSampleRate: Float = 0f + @BeforeEach + fun `set up`() { + SessionReplay.currentRegisteredCore = null + } + + @AfterEach + fun `tear down`() { + SessionReplay.currentRegisteredCore = null + } + @Test fun `M return the same builder W setInternalCallback`() { // Given @@ -48,4 +86,167 @@ internal class SessionReplayInternalProxyTest { assertThat(result).isEqualTo(testedBuilder) assertThat(sessionReplayConfiguration.internalCallback).isEqualTo(mockInternalCallback) } + + @Test + fun `M set slot tag W setEmbeddedContentSlotId`() { + stubRegisteredFeature() + val registrationCaptor = argumentCaptor() + + // When + _SessionReplayInternalProxy.setEmbeddedContentSlotId(mockView, FAKE_SLOT_ID) + + // Then + verify(mockView).setTag(R.id.datadog_session_replay_slot_id, FAKE_SLOT_ID) + verify(mockView).setTag( + eq(R.id.datadog_session_replay_slot_registration), + registrationCaptor.capture() + ) + verify(mockView).postInvalidateOnAnimation() + verify(mockSessionReplayFeature).notifyEmbeddedContentSlotChanged( + null, + registrationCaptor.firstValue + ) + assertThat(registrationCaptor.firstValue.isActive()).isTrue() + } + + @Test + fun `M unregister previous slot W setEmbeddedContentSlotId { slot is cleared }`() { + // Given + stubRegisteredFeature() + val fakeRegistration = EmbeddedContentSlotRegistration(FAKE_SLOT_ID) + whenever(mockView.getTag(R.id.datadog_session_replay_slot_id)) + .thenReturn(FAKE_SLOT_ID) + whenever(mockView.getTag(R.id.datadog_session_replay_slot_registration)) + .thenReturn(fakeRegistration) + + // When + _SessionReplayInternalProxy.setEmbeddedContentSlotId(mockView, null) + + // Then + assertThat(fakeRegistration.isActive()).isFalse() + verify(mockSessionReplayFeature).notifyEmbeddedContentSlotChanged(fakeRegistration, null) + verify(mockView).setTag(R.id.datadog_session_replay_slot_id, null) + verify(mockView).setTag(R.id.datadog_session_replay_slot_registration, null) + verify(mockView).postInvalidateOnAnimation() + } + + @Test + fun `M deactivate registration W setEmbeddedContentSlotId { feature is unavailable }`() { + // Given + SessionReplay.currentRegisteredCore = null + val fakeRegistration = EmbeddedContentSlotRegistration(FAKE_SLOT_ID) + whenever(mockView.getTag(R.id.datadog_session_replay_slot_id)) + .thenReturn(FAKE_SLOT_ID) + whenever(mockView.getTag(R.id.datadog_session_replay_slot_registration)) + .thenReturn(fakeRegistration) + + // When + _SessionReplayInternalProxy.setEmbeddedContentSlotId(mockView, null) + + // Then + assertThat(fakeRegistration.isActive()).isFalse() + verify(mockView).setTag(R.id.datadog_session_replay_slot_id, null) + verify(mockView).setTag(R.id.datadog_session_replay_slot_registration, null) + } + + @Test + fun `M do nothing W setEmbeddedContentSlotId { slot is already assigned }`() { + // Given + stubRegisteredFeature() + whenever(mockView.getTag(R.id.datadog_session_replay_slot_id)) + .thenReturn(null, FAKE_SLOT_ID) + val registrationCaptor = argumentCaptor() + + // When + _SessionReplayInternalProxy.setEmbeddedContentSlotId(mockView, FAKE_SLOT_ID) + _SessionReplayInternalProxy.setEmbeddedContentSlotId(mockView, FAKE_SLOT_ID) + + // Then + verify(mockView, times(1)).setTag( + R.id.datadog_session_replay_slot_id, + FAKE_SLOT_ID + ) + verify(mockView, times(1)).setTag( + eq(R.id.datadog_session_replay_slot_registration), + registrationCaptor.capture() + ) + verify(mockView, times(1)).postInvalidateOnAnimation() + verify(mockSessionReplayFeature, times(1)).notifyEmbeddedContentSlotChanged( + null, + registrationCaptor.firstValue + ) + } + + @Test + fun `M send record event W addEmbeddedContentRecords`() { + // Given + stubRegisteredFeature() + val nestedData = mutableMapOf(FAKE_RECORD_VALUE_KEY to 10L) + val record = mutableMapOf(FAKE_RECORD_DATA_KEY to nestedData) + val records = mutableListOf>(record) + + // When + _SessionReplayInternalProxy.addEmbeddedContentRecords( + records = records, + slotId = FAKE_SLOT_ID, + viewId = FAKE_VIEW_ID, + sdkCore = mockSdkCore + ) + nestedData[FAKE_RECORD_VALUE_KEY] = 20L + record[FAKE_RECORD_TYPE_KEY] = 11L + records.clear() + + // Then + argumentCaptor { + verify(mockSessionReplayFeature).receiveEmbeddedContentEvent(capture()) + val event = firstValue as EmbeddedContentEvent.RecordBatch + assertThat(event.records).containsExactly( + mapOf(FAKE_RECORD_DATA_KEY to mapOf(FAKE_RECORD_VALUE_KEY to 10L)) + ) + assertThat(event.slotId).isEqualTo(FAKE_SLOT_ID) + assertThat(event.viewId).isEqualTo(FAKE_VIEW_ID) + } + } + + @Test + fun `M send resource event W addEmbeddedContentResource`() { + // Given + stubRegisteredFeature() + val resourceData = byteArrayOf(1, 2, 3) + val expectedResourceData = resourceData.copyOf() + // When + _SessionReplayInternalProxy.addEmbeddedContentResource( + identifier = FAKE_RESOURCE_ID, + resourceData = resourceData, + mimeType = FAKE_MIME_TYPE, + sdkCore = mockSdkCore + ) + resourceData.fill(0) + + // Then + argumentCaptor { + verify(mockSessionReplayFeature).receiveEmbeddedContentEvent(capture()) + val event = firstValue as EmbeddedContentEvent.Resource + assertThat(event.identifier).isEqualTo(FAKE_RESOURCE_ID) + assertThat(event.data).isEqualTo(expectedResourceData) + assertThat(event.data).isNotSameAs(resourceData) + assertThat(event.mimeType).isEqualTo(FAKE_MIME_TYPE) + } + } + + private companion object { + const val FAKE_SLOT_ID = "slot-id" + const val FAKE_VIEW_ID = "view-id" + const val FAKE_RESOURCE_ID = "resource-id" + const val FAKE_MIME_TYPE = "image/png" + const val FAKE_RECORD_DATA_KEY = "data" + const val FAKE_RECORD_VALUE_KEY = "value" + const val FAKE_RECORD_TYPE_KEY = "type" + } + + private fun stubRegisteredFeature() { + SessionReplay.currentRegisteredCore = WeakReference(mockSdkCore) + whenever(mockSdkCore.getFeature(Feature.SESSION_REPLAY_FEATURE_NAME)) doReturn mockFeatureScope + whenever(mockFeatureScope.unwrap()) doReturn mockSessionReplayFeature + } } diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/SessionReplayRecorderTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/SessionReplayRecorderTest.kt index 5b82122fff..ed017bee26 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/SessionReplayRecorderTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/SessionReplayRecorderTest.kt @@ -258,7 +258,7 @@ internal class SessionReplayRecorderTest { } @Test - fun `M intercept the active windows and decor view W resumeRecorders`() { + fun `M intercept active windows without invalidating native decors W resumeRecorders`() { // When testedSessionReplayRecorder.resumeRecorders() @@ -269,6 +269,30 @@ internal class SessionReplayRecorderTest { textAndInputPrivacy = fakeTextAndInputPrivacy, imagePrivacy = fakeImagePrivacy ) + fakeActiveWindowsDecorViews.forEach { + verify(it, never()).postInvalidateOnAnimation() + } + } + + @Test + fun `M request capture W requestCapture { recorder resumed }`() { + // Given + testedSessionReplayRecorder.resumeRecorders() + + // When + testedSessionReplayRecorder.requestCapture() + + // Then + verify(mockViewOnDrawInterceptor).requestCapture() + } + + @Test + fun `M not request capture W requestCapture { recorder stopped }`() { + // When + testedSessionReplayRecorder.requestCapture() + + // Then + verify(mockViewOnDrawInterceptor, never()).requestCapture() } @Test diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/FullSnapshotRecordForgeryFactory.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/FullSnapshotRecordForgeryFactory.kt index 6c5ce87d7f..a1c2323a05 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/FullSnapshotRecordForgeryFactory.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/FullSnapshotRecordForgeryFactory.kt @@ -14,8 +14,8 @@ internal class FullSnapshotRecordForgeryFactory : ForgeryFactory { override fun getForgery(forge: Forge): MobileSegment.MobileRecord.MobileFullSnapshotRecord { return MobileSegment.MobileRecord.MobileFullSnapshotRecord( - forge.aPositiveLong(), - MobileSegment.Data(forge.aList { forge.getForgery() }) + timestamp = forge.aPositiveLong(), + data = MobileSegment.Data(forge.aList { forge.getForgery() }) ) } } diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/IncrementalSnapshotRecordForgeryFactory.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/IncrementalSnapshotRecordForgeryFactory.kt index 153ed2a507..c19dd99b19 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/IncrementalSnapshotRecordForgeryFactory.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/IncrementalSnapshotRecordForgeryFactory.kt @@ -14,8 +14,8 @@ internal class IncrementalSnapshotRecordForgeryFactory : ForgeryFactory { override fun getForgery(forge: Forge): MobileSegment.MobileRecord.MobileIncrementalSnapshotRecord { return MobileSegment.MobileRecord.MobileIncrementalSnapshotRecord( - forge.aPositiveLong(), - forge.getForgery() + timestamp = forge.aPositiveLong(), + data = forge.getForgery() ) } } diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeatureTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeatureTest.kt index 97a524e794..2a9da3721d 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeatureTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeatureTest.kt @@ -8,12 +8,24 @@ package com.datadog.android.sessionreplay.internal import android.app.Application import com.datadog.android.api.InternalLogger +import com.datadog.android.api.context.DatadogContext +import com.datadog.android.api.feature.EventWriteScope import com.datadog.android.api.feature.Feature import com.datadog.android.api.feature.FeatureContextUpdateReceiver +import com.datadog.android.api.feature.FeatureScope import com.datadog.android.api.feature.FeatureSdkCore +import com.datadog.android.api.storage.EventBatchWriter +import com.datadog.android.api.storage.EventType +import com.datadog.android.api.storage.RawBatchEvent import com.datadog.android.sessionreplay.NoOpSessionReplayInternalCallback import com.datadog.android.sessionreplay.SessionReplayConfiguration import com.datadog.android.sessionreplay.forge.ForgeConfigurator +import com.datadog.android.sessionreplay.internal.SessionReplayRumContextProvider.Companion.RUM_APPLICATION_ID_CONTEXT_KEY +import com.datadog.android.sessionreplay.internal.SessionReplayRumContextProvider.Companion.RUM_SESSION_ID_CONTEXT_KEY +import com.datadog.android.sessionreplay.internal.SessionReplayRumContextProvider.Companion.RUM_VIEW_ID_CONTEXT_KEY +import com.datadog.android.sessionreplay.internal.embedded.EmbeddedContentEvent +import com.datadog.android.sessionreplay.internal.embedded.EmbeddedContentReceiver +import com.datadog.android.sessionreplay.internal.embedded.EmbeddedContentSlotRegistration import com.datadog.android.sessionreplay.internal.net.SegmentRequestFactory import com.datadog.android.sessionreplay.internal.recorder.NoOpRecorder import com.datadog.android.sessionreplay.internal.recorder.Recorder @@ -25,6 +37,7 @@ import com.datadog.android.utils.verifyLog import com.datadog.tools.unit.annotations.TestConfigurationsProvider import com.datadog.tools.unit.extensions.TestConfigurationExtension import com.datadog.tools.unit.extensions.config.TestConfiguration +import com.google.gson.JsonParser import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.annotation.Forgery import fr.xgouchet.elmyr.junit5.ForgeConfiguration @@ -42,6 +55,7 @@ import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq import org.mockito.kotlin.inOrder @@ -110,7 +124,7 @@ internal class SessionReplayFeatureTest { startRecordingImmediately = true, touchPrivacy = fakeConfiguration.touchPrivacy, configuredSampleRate = fakeSampleRate - ) { _, _, _, _, _ -> mockRecorder } + ) { _, _, _, _, _, _ -> mockRecorder } } @Test @@ -132,6 +146,81 @@ internal class SessionReplayFeatureTest { verify(mockSdkCore).setContextUpdateReceiver(any()) } + @Test + fun `M request embedded capture W RUM view changes { recording active }`() { + // Given + val fakeRegistration = EmbeddedContentSlotRegistration(FAKE_EMBEDDED_SLOT_ID) + testedFeature.embeddedContentSlotRegistry.notifySlotChanged(null, fakeRegistration) + testedFeature.onInitialize(appContext.mockInstance) + val contextReceiver = argumentCaptor().also { + verify(mockSdkCore).setContextUpdateReceiver(it.capture()) + }.firstValue + contextReceiver.onContextUpdate( + Feature.RUM_FEATURE_NAME, + mapOf(RUM_VIEW_ID_CONTEXT_KEY to UUID.randomUUID().toString()) + ) + testedFeature.startRecording() + + // When + contextReceiver.onContextUpdate( + Feature.RUM_FEATURE_NAME, + mapOf(RUM_VIEW_ID_CONTEXT_KEY to UUID.randomUUID().toString()) + ) + + // Then + verify(mockRecorder).requestCapture() + + // Cleanup + testedFeature.embeddedContentSlotRegistry.notifySlotChanged(fakeRegistration, null) + } + + @Test + fun `M request embedded capture W first RUM view starts { recording active }`() { + // Given + val fakeRegistration = EmbeddedContentSlotRegistration(FAKE_EMBEDDED_SLOT_ID) + testedFeature.embeddedContentSlotRegistry.notifySlotChanged(null, fakeRegistration) + testedFeature.onInitialize(appContext.mockInstance) + testedFeature.startRecording() + val contextReceiver = argumentCaptor().also { + verify(mockSdkCore).setContextUpdateReceiver(it.capture()) + }.firstValue + + // When + contextReceiver.onContextUpdate( + Feature.RUM_FEATURE_NAME, + mapOf(RUM_VIEW_ID_CONTEXT_KEY to UUID.randomUUID().toString()) + ) + + // Then + verify(mockRecorder).requestCapture() + + // Cleanup + testedFeature.embeddedContentSlotRegistry.notifySlotChanged(fakeRegistration, null) + } + + @Test + fun `M not request embedded capture W RUM view changes { recording inactive }`() { + // Given + val fakeRegistration = EmbeddedContentSlotRegistration(FAKE_EMBEDDED_SLOT_ID) + testedFeature.embeddedContentSlotRegistry.notifySlotChanged(null, fakeRegistration) + testedFeature.onInitialize(appContext.mockInstance) + val contextReceiver = argumentCaptor().also { + verify(mockSdkCore).setContextUpdateReceiver(it.capture()) + }.firstValue + + // When + contextReceiver.onContextUpdate( + Feature.RUM_FEATURE_NAME, + mapOf(RUM_VIEW_ID_CONTEXT_KEY to UUID.randomUUID().toString()) + ) + + // Then + verify(mockRecorder, never()).requestCapture() + + // Cleanup + testedFeature.embeddedContentSlotRegistry.notifySlotChanged(fakeRegistration, null) + } + @Test fun `M initialize session replay recorder W initialize()`() { // Given @@ -161,6 +250,62 @@ internal class SessionReplayFeatureTest { .isInstanceOf(SessionReplayRecorder::class.java) } + @Test + fun `M route embedded records W onReceive { feature initialized and recording }`() { + // Given + val mockFeatureScope = mock() + val mockEventWriteScope = mock() + val mockEventBatchWriter = mock() + val mockDatadogContext = mock() + whenever(mockSdkCore.getFeature(Feature.SESSION_REPLAY_FEATURE_NAME)) + .thenReturn(mockFeatureScope) + whenever(mockEventBatchWriter.write(any(), any(), any())).thenReturn(true) + whenever(mockEventWriteScope.invoke(any())) doAnswer { + val callback = it.getArgument<(EventBatchWriter) -> Unit>(0) + callback(mockEventBatchWriter) + } + whenever(mockFeatureScope.withWriteContext(eq(emptySet()), any())) doAnswer { + val callback = it.getArgument<(DatadogContext, EventWriteScope) -> Unit>(it.arguments.lastIndex) + callback(mockDatadogContext, mockEventWriteScope) + } + testedFeature.onInitialize(appContext.mockInstance) + testedFeature.startRecording() + val contextReceiver = argumentCaptor().also { + verify(mockSdkCore).setContextUpdateReceiver(it.capture()) + }.firstValue + contextReceiver.onContextUpdate( + Feature.RUM_FEATURE_NAME, + mapOf( + RUM_APPLICATION_ID_CONTEXT_KEY to UUID.randomUUID().toString(), + RUM_SESSION_ID_CONTEXT_KEY to UUID.randomUUID().toString(), + RUM_VIEW_ID_CONTEXT_KEY to UUID.randomUUID().toString() + ) + ) + // When + testedFeature.onReceive( + EmbeddedContentEvent.RecordBatch( + records = listOf(mapOf(FAKE_RECORD_TYPE_KEY to 10L)), + slotId = FAKE_EMBEDDED_SLOT_ID, + viewId = FAKE_EMBEDDED_VIEW_ID + ) + ) + + // Then + argumentCaptor { + verify(mockEventBatchWriter).write( + event = capture(), + batchMetadata = eq(null), + eventType = eq(EventType.DEFAULT) + ) + val json = JsonParser.parseString(firstValue.data.toString(Charsets.UTF_8)).asJsonObject + assertThat(json[EmbeddedContentReceiver.VIEW_ID_KEY].asString) + .isEqualTo(FAKE_EMBEDDED_VIEW_ID) + val records = json[EmbeddedContentReceiver.RECORDS_KEY].asJsonArray + assertThat(records[0].asJsonObject[EmbeddedContentReceiver.SLOT_ID_KEY].asString) + .isEqualTo(FAKE_EMBEDDED_SLOT_ID) + } + } + @Test fun `M update feature context for telemetry W initialize()`() { // Given @@ -219,7 +364,7 @@ internal class SessionReplayFeatureTest { startRecordingImmediately = true, touchPrivacy = fakeConfiguration.touchPrivacy, configuredSampleRate = fakeSampleRate - ) { _, _, _, _, _ -> mockRecorder } + ) { _, _, _, _, _, _ -> mockRecorder } // When testedFeature.onInitialize(appContext.mockInstance) @@ -744,7 +889,7 @@ internal class SessionReplayFeatureTest { touchPrivacy = fakeConfiguration.touchPrivacy, startRecordingImmediately = true, configuredSampleRate = fakeSampleRate - ) { _, _, _, _, _ -> mockRecorder } + ) { _, _, _, _, _, _ -> mockRecorder } testedFeature.onInitialize(appContext.mockInstance) val rumSessionUpdateBusMessage1 = mapOf( SessionReplayFeature.SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY to @@ -795,7 +940,7 @@ internal class SessionReplayFeatureTest { touchPrivacy = fakeConfiguration.touchPrivacy, startRecordingImmediately = false, configuredSampleRate = fakeSampleRate - ) { _, _, _, _, _ -> mockRecorder } + ) { _, _, _, _, _, _ -> mockRecorder } testedFeature.onInitialize(appContext.mockInstance) val rumSessionUpdateBusMessage1 = mapOf( SessionReplayFeature.SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY to @@ -1077,7 +1222,7 @@ internal class SessionReplayFeatureTest { touchPrivacy = fakeConfiguration.touchPrivacy, startRecordingImmediately = scenario.startRecordingImmediately, configuredSampleRate = fakeSampleRate - ) { _, _, _, _, _ -> mockRecorder } + ) { _, _, _, _, _, _ -> mockRecorder } testedFeature.onInitialize(fakeContext) testedFeature.onReceive(event) @@ -1116,7 +1261,7 @@ internal class SessionReplayFeatureTest { touchPrivacy = fakeConfiguration.touchPrivacy, startRecordingImmediately = true, configuredSampleRate = fakeSampleRate - ) { _, _, _, _, _ -> mockRecorder } + ) { _, _, _, _, _, _ -> mockRecorder } testedFeature.onInitialize(fakeContext) testedFeature.onReceive(event) testedFeature.onReceive(event) @@ -1148,7 +1293,7 @@ internal class SessionReplayFeatureTest { touchPrivacy = fakeConfiguration.touchPrivacy, startRecordingImmediately = false, configuredSampleRate = fakeSampleRate - ) { _, _, _, _, _ -> mockRecorder } + ) { _, _, _, _, _, _ -> mockRecorder } testedFeature.onInitialize(fakeContext) testedFeature.manuallyStartRecording() testedFeature.onReceive(event) @@ -1182,7 +1327,7 @@ internal class SessionReplayFeatureTest { touchPrivacy = fakeConfiguration.touchPrivacy, startRecordingImmediately = true, configuredSampleRate = fakeSampleRate - ) { _, _, _, _, _ -> mockRecorder } + ) { _, _, _, _, _, _ -> mockRecorder } testedFeature.onInitialize(fakeContext) testedFeature.onReceive(event) testedFeature.manuallyStopRecording() @@ -1219,7 +1364,7 @@ internal class SessionReplayFeatureTest { touchPrivacy = fakeConfiguration.touchPrivacy, startRecordingImmediately = true, configuredSampleRate = fakeSampleRate - ) { _, _, _, _, _ -> mockRecorder } + ) { _, _, _, _, _, _ -> mockRecorder } testedFeature.onInitialize(fakeContext) testedFeature.onReceive(event1) @@ -1305,7 +1450,7 @@ internal class SessionReplayFeatureTest { startRecordingImmediately = true, touchPrivacy = fakeConfiguration.touchPrivacy, configuredSampleRate = 100f - ) { _, _, _, _, _ -> mockRecorder } + ) { _, _, _, _, _, _ -> mockRecorder } testedFeature.onInitialize(appContext.mockInstance) testedFeature.onReceive( @@ -1344,6 +1489,10 @@ internal class SessionReplayFeatureTest { ) companion object { + private const val FAKE_EMBEDDED_SLOT_ID = "slot-id" + private const val FAKE_RECORD_TYPE_KEY = "type" + private const val FAKE_EMBEDDED_VIEW_ID = "embedded-view-id" + val appContext = ApplicationContextTestConfiguration(Application::class.java) @TestConfigurationsProvider diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayRecordCallbackTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayRecordCallbackTest.kt index 0118daa9a4..93b99317e2 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayRecordCallbackTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayRecordCallbackTest.kt @@ -157,6 +157,46 @@ internal class SessionReplayRecordCallbackTest { } } + @Suppress("UNCHECKED_CAST") + @Test + fun `M share records count W native and embedded records sent`() { + // Given + val embeddedRecordsCount = 3 + + // When + testedRecordCallback.onRecordForViewSent(fakeEnrichedRecord) + testedRecordCallback.onEmbeddedRecordsForViewSent( + fakeEnrichedRecord.viewId, + embeddedRecordsCount + ) + + // Then + argumentCaptor<(MutableMap) -> Unit> { + verify(mockDatadogCore, times(2)).updateFeatureContext( + eq(Feature.SESSION_REPLAY_FEATURE_NAME), + eq(false), + capture() + ) + + val featureContext = mutableMapOf() + allValues.forEach { it.invoke(featureContext) } + val viewMetadata = featureContext[fakeEnrichedRecord.viewId] as? Map + assertThat(viewMetadata?.get(SessionReplayRecordCallback.HAS_REPLAY_KEY)) + .isEqualTo(true) + assertThat(viewMetadata?.get(SessionReplayRecordCallback.VIEW_RECORDS_COUNT_KEY)) + .isEqualTo(fakeEnrichedRecord.records.size.toLong() + embeddedRecordsCount) + } + } + + @Test + fun `M do nothing W onEmbeddedRecordsForViewSent { empty batch }`() { + // When + testedRecordCallback.onEmbeddedRecordsForViewSent(fakeEnrichedRecord.viewId, 0) + + // Then + verifyNoInteractions(mockDatadogCore) + } + private fun Forge.forgeFakeValidEnrichedRecord(): EnrichedRecord { return getForgery() .copy(records = aList(size = anInt(min = 1, max = 10)) { getForgery() }) diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayRumContextProviderTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayRumContextProviderTest.kt index 3797e8dd0c..4e2195db98 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayRumContextProviderTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayRumContextProviderTest.kt @@ -9,6 +9,11 @@ package com.datadog.android.sessionreplay.internal import com.datadog.android.api.feature.Feature import com.datadog.android.sessionreplay.forge.ForgeConfigurator import com.datadog.android.sessionreplay.internal.SessionReplayRumContextProvider.Companion.NULL_UUID +import com.datadog.android.sessionreplay.internal.SessionReplayRumContextProvider.Companion.RUM_APPLICATION_ID_CONTEXT_KEY +import com.datadog.android.sessionreplay.internal.SessionReplayRumContextProvider.Companion.RUM_SESSION_ID_CONTEXT_KEY +import com.datadog.android.sessionreplay.internal.SessionReplayRumContextProvider.Companion.RUM_VIEW_ID_CONTEXT_KEY +import com.datadog.android.sessionreplay.internal.SessionReplayRumContextProvider.Companion.RUM_VIEW_TIME_OFFSET_CONTEXT_KEY +import com.datadog.android.sessionreplay.internal.SessionReplayRumContextProvider.Companion.RUM_VIEW_URL_CONTEXT_KEY import com.datadog.android.sessionreplay.internal.utils.SessionReplayRumContext import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.annotation.Forgery @@ -31,6 +36,81 @@ internal class SessionReplayRumContextProviderTest { private val testedSessionReplayContextProvider = SessionReplayRumContextProvider() + @Test + fun `M notify first view W onContextUpdate { new valid RUM view }`() { + // Given + var notificationCount = 0 + val testedProvider = SessionReplayRumContextProvider { notificationCount++ } + val viewId = UUID.randomUUID().toString() + + // When + testedProvider.onContextUpdate( + Feature.RUM_FEATURE_NAME, + mapOf(RUM_VIEW_ID_CONTEXT_KEY to viewId) + ) + + // Then + assertThat(notificationCount).isEqualTo(1) + } + + @Test + fun `M notify view transition W onContextUpdate { valid RUM view changes }`() { + // Given + var notificationCount = 0 + val testedProvider = SessionReplayRumContextProvider { notificationCount++ } + testedProvider.onContextUpdate( + Feature.RUM_FEATURE_NAME, + mapOf(RUM_VIEW_ID_CONTEXT_KEY to UUID.randomUUID().toString()) + ) + + // When + testedProvider.onContextUpdate( + Feature.RUM_FEATURE_NAME, + mapOf(RUM_VIEW_ID_CONTEXT_KEY to UUID.randomUUID().toString()) + ) + + // Then + assertThat(notificationCount).isEqualTo(2) + } + + @Test + fun `M not notify view change W onContextUpdate { same RUM view }`() { + // Given + var notificationCount = 0 + val testedProvider = SessionReplayRumContextProvider { notificationCount++ } + val viewId = UUID.randomUUID().toString() + testedProvider.onContextUpdate( + Feature.RUM_FEATURE_NAME, + mapOf(RUM_VIEW_ID_CONTEXT_KEY to viewId) + ) + + // When + testedProvider.onContextUpdate( + Feature.RUM_FEATURE_NAME, + mapOf(RUM_VIEW_ID_CONTEXT_KEY to viewId, RUM_VIEW_TIME_OFFSET_CONTEXT_KEY to 10L) + ) + + // Then + assertThat(notificationCount).isEqualTo(1) + } + + @Test + fun `M not notify view change W onContextUpdate { invalid view }`() { + // Given + var notificationCount = 0 + val testedProvider = SessionReplayRumContextProvider { notificationCount++ } + + // When + testedProvider.onContextUpdate( + Feature.RUM_FEATURE_NAME, + mapOf(RUM_VIEW_ID_CONTEXT_KEY to NULL_UUID) + ) + testedProvider.onContextUpdate(Feature.RUM_FEATURE_NAME, emptyMap()) + + // Then + assertThat(notificationCount).isZero() + } + @Test fun `M provide a valid Rum context W getRumContext()`( @Forgery fakeApplicationId: UUID, @@ -43,11 +123,11 @@ internal class SessionReplayRumContextProviderTest { testedSessionReplayContextProvider.onContextUpdate( Feature.RUM_FEATURE_NAME, mapOf( - "application_id" to fakeApplicationId.toString(), - "session_id" to fakeSessionId.toString(), - "view_id" to fakeViewId.toString(), - "view_timestamp_offset" to fakeViewTimeOffsetMs, - "view_url" to fakeViewUrl + RUM_APPLICATION_ID_CONTEXT_KEY to fakeApplicationId.toString(), + RUM_SESSION_ID_CONTEXT_KEY to fakeSessionId.toString(), + RUM_VIEW_ID_CONTEXT_KEY to fakeViewId.toString(), + RUM_VIEW_TIME_OFFSET_CONTEXT_KEY to fakeViewTimeOffsetMs, + RUM_VIEW_URL_CONTEXT_KEY to fakeViewUrl ) ) @@ -74,10 +154,10 @@ internal class SessionReplayRumContextProviderTest { testedSessionReplayContextProvider.onContextUpdate( Feature.RUM_FEATURE_NAME, mapOf( - "application_id" to fakeApplicationId.toString(), - "session_id" to fakeSessionId.toString(), - "view_id" to fakeViewId.toString(), - "view_timestamp_offset" to fakeViewTimeOffsetMs + RUM_APPLICATION_ID_CONTEXT_KEY to fakeApplicationId.toString(), + RUM_SESSION_ID_CONTEXT_KEY to fakeSessionId.toString(), + RUM_VIEW_ID_CONTEXT_KEY to fakeViewId.toString(), + RUM_VIEW_TIME_OFFSET_CONTEXT_KEY to fakeViewTimeOffsetMs ) ) @@ -101,11 +181,11 @@ internal class SessionReplayRumContextProviderTest { testedSessionReplayContextProvider.onContextUpdate( Feature.RUM_FEATURE_NAME, mapOf( - "application_id" to fakeApplicationId.toString(), - "session_id" to fakeSessionId.toString(), - "view_id" to fakeViewId.toString(), - "view_timestamp_offset" to fakeViewTimeOffsetMs, - "view_url" to forge.anInt() + RUM_APPLICATION_ID_CONTEXT_KEY to fakeApplicationId.toString(), + RUM_SESSION_ID_CONTEXT_KEY to fakeSessionId.toString(), + RUM_VIEW_ID_CONTEXT_KEY to fakeViewId.toString(), + RUM_VIEW_TIME_OFFSET_CONTEXT_KEY to fakeViewTimeOffsetMs, + RUM_VIEW_URL_CONTEXT_KEY to forge.anInt() ) ) @@ -128,10 +208,10 @@ internal class SessionReplayRumContextProviderTest { testedSessionReplayContextProvider.onContextUpdate( Feature.RUM_FEATURE_NAME, mapOf( - "application_id" to fakeApplicationId.toString(), - "session_id" to fakeSessionId.toString(), - "view_id" to fakeViewId.toString(), - "view_timestamp_offset" to fakeViewTimeOffsetMs + RUM_APPLICATION_ID_CONTEXT_KEY to fakeApplicationId.toString(), + RUM_SESSION_ID_CONTEXT_KEY to fakeSessionId.toString(), + RUM_VIEW_ID_CONTEXT_KEY to fakeViewId.toString(), + RUM_VIEW_TIME_OFFSET_CONTEXT_KEY to fakeViewTimeOffsetMs ) ) }.apply { @@ -160,10 +240,10 @@ internal class SessionReplayRumContextProviderTest { testedSessionReplayContextProvider.onContextUpdate( Feature.RUM_FEATURE_NAME, mapOf( - "application_id" to fakeApplicationId.toString(), - "session_id" to fakeSessionId.toString(), - "view_id" to fakeViewId.toString(), - "view_timestamp_offset" to fakeViewTimeOffsetMs + RUM_APPLICATION_ID_CONTEXT_KEY to fakeApplicationId.toString(), + RUM_SESSION_ID_CONTEXT_KEY to fakeSessionId.toString(), + RUM_VIEW_ID_CONTEXT_KEY to fakeViewId.toString(), + RUM_VIEW_TIME_OFFSET_CONTEXT_KEY to fakeViewTimeOffsetMs ) ) diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/embedded/EmbeddedContentReceiverTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/embedded/EmbeddedContentReceiverTest.kt new file mode 100644 index 0000000000..6363647f6d --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/embedded/EmbeddedContentReceiverTest.kt @@ -0,0 +1,262 @@ +/* + * 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.embedded + +import com.datadog.android.api.InternalLogger +import com.datadog.android.sessionreplay.forge.ForgeConfigurator +import com.datadog.android.sessionreplay.internal.processor.ResourceProcessor +import com.datadog.android.sessionreplay.internal.storage.EmbeddedContentRecordWriter +import com.datadog.android.sessionreplay.internal.utils.RumContextProvider +import com.datadog.android.sessionreplay.internal.utils.SessionReplayRumContext +import com.google.gson.JsonParser +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.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +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 EmbeddedContentReceiverTest { + + private lateinit var testedReceiver: EmbeddedContentReceiver + + @Mock + lateinit var mockRumContextProvider: RumContextProvider + + @Mock + lateinit var mockRecordWriter: EmbeddedContentRecordWriter + + @Mock + lateinit var mockResourceProcessor: ResourceProcessor + + @Mock + lateinit var mockInternalLogger: InternalLogger + + private var isRecording: Boolean = true + + private val fakeRumContext = SessionReplayRumContext( + applicationId = FAKE_NATIVE_APPLICATION_ID, + sessionId = FAKE_NATIVE_SESSION_ID, + viewId = FAKE_NATIVE_VIEW_ID + ) + + @BeforeEach + fun `set up`() { + whenever(mockRumContextProvider.getRumContext()).thenReturn(fakeRumContext) + testedReceiver = EmbeddedContentReceiver( + rumContextProvider = mockRumContextProvider, + recordWriter = { mockRecordWriter }, + resourceProcessor = { mockResourceProcessor }, + isRecording = { isRecording }, + internalLogger = mockInternalLogger + ) + } + + @Test + fun `M enrich and write records W receive { record batch }`() { + // Given + val event = EmbeddedContentEvent.RecordBatch( + records = listOf( + mapOf( + "timestamp" to 123L, + "type" to 10L, + FAKE_FUTURE_FIELD_NAME to FAKE_FUTURE_FIELD_VALUE + ), + mapOf("type" to 11L, EmbeddedContentReceiver.SLOT_ID_KEY to "stale-slot") + ), + slotId = FAKE_NATIVE_SLOT_ID, + viewId = FAKE_EMBEDDED_VIEW_ID + ) + + // When + testedReceiver.receive(event) + + // Then + argumentCaptor { + verify(mockRecordWriter).writeRaw(capture(), eq(FAKE_EMBEDDED_VIEW_ID), eq(2)) + val json = JsonParser.parseString(firstValue.toString(Charsets.UTF_8)).asJsonObject + assertThat(json[EmbeddedContentReceiver.APPLICATION_ID_KEY].asString) + .isEqualTo(FAKE_NATIVE_APPLICATION_ID) + assertThat(json[EmbeddedContentReceiver.SESSION_ID_KEY].asString) + .isEqualTo(FAKE_NATIVE_SESSION_ID) + assertThat(json[EmbeddedContentReceiver.VIEW_ID_KEY].asString) + .isEqualTo(FAKE_EMBEDDED_VIEW_ID) + val records = json[EmbeddedContentReceiver.RECORDS_KEY].asJsonArray + assertThat(records).hasSize(2) + assertThat(records[0].asJsonObject[EmbeddedContentReceiver.SLOT_ID_KEY].asString) + .isEqualTo(FAKE_NATIVE_SLOT_ID) + assertThat( + records[0].asJsonObject[FAKE_FUTURE_FIELD_NAME].asString + ).isEqualTo(FAKE_FUTURE_FIELD_VALUE) + assertThat(records[1].asJsonObject[EmbeddedContentReceiver.SLOT_ID_KEY].asString) + .isEqualTo(FAKE_NATIVE_SLOT_ID) + } + verifyNoInteractions(mockResourceProcessor) + } + + @Test + fun `M write records W receive { slot has no native container }`() { + // When + testedReceiver.receive( + EmbeddedContentEvent.RecordBatch( + records = listOf(mapOf("type" to 10L)), + slotId = "unmaterialized-slot", + viewId = FAKE_EMBEDDED_VIEW_ID + ) + ) + + // Then + verify(mockRecordWriter).writeRaw(any(), eq(FAKE_EMBEDDED_VIEW_ID), eq(1)) + } + + @Test + fun `M write resource W receive { resource }`() { + // Given + val resourceData = byteArrayOf(1, 2, 3) + + // When + testedReceiver.receive( + EmbeddedContentEvent.Resource( + identifier = FAKE_RESOURCE_ID, + data = resourceData, + mimeType = FAKE_MIME_TYPE + ) + ) + + // Then + verify(mockResourceProcessor).process(FAKE_RESOURCE_ID, resourceData, FAKE_MIME_TYPE) + verifyNoInteractions(mockRecordWriter) + } + + @Test + fun `M drop event W receive { recording inactive }`() { + // Given + isRecording = false + + // When + testedReceiver.receive( + EmbeddedContentEvent.RecordBatch( + records = listOf(mapOf("type" to 10L)), + slotId = FAKE_SLOT_ID, + viewId = FAKE_VIEW_ID + ) + ) + + // Then + verifyNoInteractions(mockRumContextProvider, mockRecordWriter, mockResourceProcessor) + } + + @Test + fun `M drop records W receive { invalid RUM context }`() { + // Given + whenever(mockRumContextProvider.getRumContext()).thenReturn(SessionReplayRumContext()) + + // When + testedReceiver.receive( + EmbeddedContentEvent.RecordBatch( + records = listOf(mapOf("type" to 10L)), + slotId = FAKE_SLOT_ID, + viewId = FAKE_VIEW_ID + ) + ) + + // Then + verifyNoInteractions(mockRecordWriter, mockResourceProcessor) + } + + @Test + fun `M drop records W receive { empty record batch }`() { + // When + testedReceiver.receive( + EmbeddedContentEvent.RecordBatch( + records = emptyList(), + slotId = FAKE_SLOT_ID, + viewId = FAKE_VIEW_ID + ) + ) + + // Then + verifyNoInteractions(mockRumContextProvider, mockRecordWriter, mockResourceProcessor) + } + + @Test + fun `M drop resource W receive { invalid RUM application and session context }`() { + // Given + whenever(mockRumContextProvider.getRumContext()).thenReturn(SessionReplayRumContext()) + val resourceData = byteArrayOf(1, 2, 3) + + // When + testedReceiver.receive( + EmbeddedContentEvent.Resource( + identifier = FAKE_RESOURCE_ID, + data = resourceData, + mimeType = FAKE_MIME_TYPE + ) + ) + + // Then + verifyNoInteractions(mockRecordWriter, mockResourceProcessor) + } + + @Test + fun `M write resource W receive { RUM application and session context is valid without view }`() { + // Given + whenever(mockRumContextProvider.getRumContext()).thenReturn( + SessionReplayRumContext( + applicationId = FAKE_APPLICATION_ID, + sessionId = FAKE_SESSION_ID + ) + ) + val resourceData = byteArrayOf(1, 2, 3) + + // When + testedReceiver.receive( + EmbeddedContentEvent.Resource( + identifier = FAKE_RESOURCE_ID, + data = resourceData, + mimeType = FAKE_MIME_TYPE + ) + ) + + // Then + verify(mockResourceProcessor).process(FAKE_RESOURCE_ID, resourceData, FAKE_MIME_TYPE) + verifyNoInteractions(mockRecordWriter) + } + + private companion object { + const val FAKE_APPLICATION_ID = "application-id" + const val FAKE_SESSION_ID = "session-id" + const val FAKE_VIEW_ID = "view-id" + const val FAKE_SLOT_ID = "slot-id" + const val FAKE_NATIVE_APPLICATION_ID = "native-application-id" + const val FAKE_NATIVE_SESSION_ID = "native-session-id" + const val FAKE_NATIVE_VIEW_ID = "native-view-id" + const val FAKE_EMBEDDED_VIEW_ID = "embedded-view-id" + const val FAKE_NATIVE_SLOT_ID = "native-slot" + const val FAKE_RESOURCE_ID = "resource-id" + const val FAKE_MIME_TYPE = "image/png" + const val FAKE_FUTURE_FIELD_NAME = "futureField" + const val FAKE_FUTURE_FIELD_VALUE = "preserved" + } +} diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/embedded/EmbeddedContentResourceEventTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/embedded/EmbeddedContentResourceEventTest.kt new file mode 100644 index 0000000000..7b54bec647 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/embedded/EmbeddedContentResourceEventTest.kt @@ -0,0 +1,58 @@ +/* + * 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.embedded + +import com.datadog.android.sessionreplay.forge.ForgeConfigurator +import com.datadog.tools.unit.ObjectTest +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension +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.quality.Strictness + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(ForgeConfigurator::class) +internal class EmbeddedContentResourceEventTest : ObjectTest() { + + override fun createInstance(forge: Forge): EmbeddedContentEvent.Resource { + return EmbeddedContentEvent.Resource( + identifier = forge.aString(), + data = forge.aString().toByteArray(), + mimeType = forge.aString() + ) + } + + override fun createEqualInstance( + source: EmbeddedContentEvent.Resource, + forge: Forge + ): EmbeddedContentEvent.Resource { + return source.copy(data = source.data.copyOf()) + } + + override fun createUnequalInstance( + source: EmbeddedContentEvent.Resource, + forge: Forge + ): EmbeddedContentEvent.Resource { + return when (forge.anInt(min = 0, max = 2)) { + 0 -> source.copy(identifier = source.identifier + DIFFERENT_VALUE_SUFFIX) + 1 -> source.copy(data = source.data + DIFFERENT_BYTE) + else -> source.copy(mimeType = source.mimeType + DIFFERENT_VALUE_SUFFIX) + } + } + + companion object { + private const val DIFFERENT_VALUE_SUFFIX = "-different" + private const val DIFFERENT_BYTE: Byte = 0 + } +} diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/embedded/EmbeddedContentSlotRegistryTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/embedded/EmbeddedContentSlotRegistryTest.kt new file mode 100644 index 0000000000..eba709057a --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/embedded/EmbeddedContentSlotRegistryTest.kt @@ -0,0 +1,133 @@ +/* + * 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.embedded + +import com.datadog.android.sessionreplay.forge.ForgeConfigurator +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.quality.Strictness + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(ForgeConfigurator::class) +internal class EmbeddedContentSlotRegistryTest { + + private val testedRegistry = EmbeddedContentSlotRegistry() + + @Test + fun `M keep registry state isolated W separate instances`() { + // Given + val otherRegistry = EmbeddedContentSlotRegistry() + val fakeRegistration = EmbeddedContentSlotRegistration(FAKE_SLOT_ID) + + // When + testedRegistry.notifySlotChanged(null, fakeRegistration) + + // Then + assertThat(testedRegistry.hasMarkedSlots()).isTrue() + assertThat(otherRegistry.hasMarkedSlots()).isFalse() + } + + @Test + fun `M track active registration W track`() { + // Given + val fakeRegistration = EmbeddedContentSlotRegistration(FAKE_SLOT_ID) + + // When + testedRegistry.track(fakeRegistration) + + // Then + assertThat(testedRegistry.isSlotMarked(FAKE_SLOT_ID)).isTrue() + } + + @Test + fun `M track slot W notifySlotChanged { slot is set }`() { + // Given + val fakeRegistration = EmbeddedContentSlotRegistration(FAKE_SLOT_ID) + + try { + // When + testedRegistry.notifySlotChanged(null, fakeRegistration) + + // Then + assertThat(testedRegistry.hasMarkedSlots()).isTrue() + assertThat(testedRegistry.isSlotMarked(FAKE_SLOT_ID)).isTrue() + } finally { + testedRegistry.notifySlotChanged(fakeRegistration, null) + } + } + + @Test + fun `M stop tracking slot W notifySlotChanged { slot is cleared }`() { + // Given + val fakeRegistration = EmbeddedContentSlotRegistration(FAKE_SLOT_ID) + testedRegistry.notifySlotChanged(null, fakeRegistration) + + // When + testedRegistry.notifySlotChanged(fakeRegistration, null) + + // Then + assertThat(testedRegistry.hasMarkedSlots()).isFalse() + assertThat(testedRegistry.isSlotMarked(FAKE_SLOT_ID)).isFalse() + } + + @Test + fun `M replace tracked slot W notifySlotChanged { registration changes }`() { + // Given + val fakeOldRegistration = EmbeddedContentSlotRegistration(FAKE_OLD_SLOT_ID) + val fakeNewRegistration = EmbeddedContentSlotRegistration(FAKE_NEW_SLOT_ID) + testedRegistry.notifySlotChanged(null, fakeOldRegistration) + + try { + // When + testedRegistry.notifySlotChanged( + fakeOldRegistration, + fakeNewRegistration + ) + + // Then + assertThat(testedRegistry.isSlotMarked(FAKE_OLD_SLOT_ID)).isFalse() + assertThat(testedRegistry.isSlotMarked(FAKE_NEW_SLOT_ID)).isTrue() + } finally { + testedRegistry.notifySlotChanged(fakeNewRegistration, null) + } + } + + @Test + fun `M retain replacement W notifySlotChanged { stale registration is cleared }`() { + // Given + val fakeStaleRegistration = EmbeddedContentSlotRegistration(FAKE_SLOT_ID) + val fakeCurrentRegistration = EmbeddedContentSlotRegistration(FAKE_SLOT_ID) + testedRegistry.notifySlotChanged(null, fakeStaleRegistration) + testedRegistry.notifySlotChanged(null, fakeCurrentRegistration) + + try { + // When + testedRegistry.notifySlotChanged(fakeStaleRegistration, null) + + // Then + assertThat(testedRegistry.isSlotMarked(FAKE_SLOT_ID)).isTrue() + } finally { + testedRegistry.notifySlotChanged(fakeCurrentRegistration, null) + } + } + + private companion object { + const val FAKE_SLOT_ID = "slot-id" + const val FAKE_OLD_SLOT_ID = "old-slot" + const val FAKE_NEW_SLOT_ID = "new-slot" + } +} diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/MutationResolverTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/MutationResolverTest.kt index 7028457314..5d8d4571fa 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/MutationResolverTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/MutationResolverTest.kt @@ -716,23 +716,18 @@ internal class MutationResolverTest { // endregion - // region EmbeddedContent "remove" mutations + // region EmbeddedContent remove mutations @Test - fun `M identify the updated wireframes W resolveMutations {EmbeddedContent removed at beginning}`(forge: Forge) { + fun `M identify removed wireframes W resolveMutations {EmbeddedContent removed at beginning}`(forge: Forge) { // Given val fakePrevSnapshot = forge.aList(size = forge.anInt(min = 3, max = 10)) { forge.getForgery(MobileSegment.Wireframe.EmbeddedContentWireframe::class.java) } - val fakeHiddenWireframes = forge.anInt(min = 1, max = fakePrevSnapshot.size - 1) - val fakeCurrentSnapshot = fakePrevSnapshot.drop(fakeHiddenWireframes) - val expectedUpdates = fakePrevSnapshot.take(fakeHiddenWireframes).map { - MobileSegment.WireframeUpdateMutation.EmbeddedContentWireframeUpdate( - id = it.id(), - slotId = it.slotId, - isVisible = false - ) - } + val fakeRemovedWireframes = forge.anInt(min = 1, max = fakePrevSnapshot.size - 1) + val fakeCurrentSnapshot = fakePrevSnapshot.drop(fakeRemovedWireframes) + val expectedRemoves = fakePrevSnapshot.take(fakeRemovedWireframes) + .map { MobileSegment.Remove(it.id()) } // When val mutations = testedMutationResolver.resolveMutations( @@ -742,25 +737,20 @@ internal class MutationResolverTest { // Then assertThat(mutations?.adds).isNullOrEmpty() - assertThat(mutations?.removes).isNullOrEmpty() - assertThat(mutations?.updates).isEqualTo(expectedUpdates) + assertThat(mutations?.removes).isEqualTo(expectedRemoves) + assertThat(mutations?.updates).isNullOrEmpty() } @Test - fun `M identify the updated wireframes W resolveMutations {EmbeddedContent removed at end}`(forge: Forge) { + fun `M identify removed wireframes W resolveMutations {EmbeddedContent removed at end}`(forge: Forge) { // Given val fakePrevSnapshot = forge.aList(size = forge.anInt(min = 3, max = 10)) { forge.getForgery(MobileSegment.Wireframe.EmbeddedContentWireframe::class.java) } - val fakeHiddenWireframes = forge.anInt(min = 1, max = fakePrevSnapshot.size - 1) - val fakeCurrentSnapshot = fakePrevSnapshot.dropLast(fakeHiddenWireframes) - val expectedUpdates = fakePrevSnapshot.takeLast(fakeHiddenWireframes).map { - MobileSegment.WireframeUpdateMutation.EmbeddedContentWireframeUpdate( - id = it.id(), - slotId = it.slotId, - isVisible = false - ) - } + val fakeRemovedWireframes = forge.anInt(min = 1, max = fakePrevSnapshot.size - 1) + val fakeCurrentSnapshot = fakePrevSnapshot.dropLast(fakeRemovedWireframes) + val expectedRemoves = fakePrevSnapshot.takeLast(fakeRemovedWireframes) + .map { MobileSegment.Remove(it.id()) } // When val mutations = testedMutationResolver.resolveMutations( @@ -770,8 +760,8 @@ internal class MutationResolverTest { // Then assertThat(mutations?.adds).isNullOrEmpty() - assertThat(mutations?.removes).isNullOrEmpty() - assertThat(mutations?.updates).isEqualTo(expectedUpdates) + assertThat(mutations?.removes).isEqualTo(expectedRemoves) + assertThat(mutations?.updates).isNullOrEmpty() } // endregion diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/RecordedDataProcessorTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/RecordedDataProcessorTest.kt index b0e0e93e33..d3fd8a31e7 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/RecordedDataProcessorTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/RecordedDataProcessorTest.kt @@ -40,7 +40,6 @@ import org.mockito.junit.jupiter.MockitoSettings import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.atLeastOnce -import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions @@ -1258,6 +1257,7 @@ internal class RecordedDataProcessorTest { // Given val fakeByteArray = forge.anAlphaNumericalString().toByteArray() val fakeResourceItem = createResourceItem(fakeByteArray, usedContext = initialRecordedQueuedItemContext) + whenever(mockResourceDataStoreManager.markResourceAsSentIfNew(fakeIdentifier)).thenReturn(true) // When testedProcessor.processResources(fakeResourceItem) @@ -1276,38 +1276,6 @@ internal class RecordedDataProcessorTest { assertThat(itemFilename).isEqualTo(fakeIdentifier) } - @Test - fun `M not store resource in datastore W processResources { resource not previously seen by manager not ready }`( - forge: Forge - ) { - // Given - val fakeByteArray = forge.anAlphaNumericalString().toByteArray() - val fakeResourceItem = createResourceItem(fakeByteArray, usedContext = initialRecordedQueuedItemContext) - whenever(mockResourceDataStoreManager.isReady()).thenReturn(false) - - // When - testedProcessor.processResources(fakeResourceItem) - - // Then - verify(mockResourceDataStoreManager, never()).cacheResourceHash(fakeIdentifier) - } - - @Test - fun `M store resource in datastore W processResources { resource not previously seen and manager ready }`( - forge: Forge - ) { - // Given - val fakeByteArray = forge.anAlphaNumericalString().toByteArray() - val fakeResourceItem = createResourceItem(fakeByteArray, usedContext = initialRecordedQueuedItemContext) - whenever(mockResourceDataStoreManager.isReady()).thenReturn(true) - - // When - testedProcessor.processResources(fakeResourceItem) - - // Then - verify(mockResourceDataStoreManager, times(1)).cacheResourceHash(fakeIdentifier) - } - @Test fun `M not write resource data W processResources { resource was previously seen }`( @StringForgery fakeString: String @@ -1315,8 +1283,8 @@ internal class RecordedDataProcessorTest { // Given val fakeByteArray = fakeString.toByteArray() val fakeResourceItem = createResourceItem(fakeByteArray, usedContext = initialRecordedQueuedItemContext) - whenever(mockResourceDataStoreManager.isPreviouslySentResource(fakeIdentifier)) - .thenReturn(true) + whenever(mockResourceDataStoreManager.markResourceAsSentIfNew(fakeIdentifier)) + .thenReturn(false) // When testedProcessor.processResources(fakeResourceItem) diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/ResourceProcessorTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/ResourceProcessorTest.kt new file mode 100644 index 0000000000..6821d7b36d --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/ResourceProcessorTest.kt @@ -0,0 +1,107 @@ +/* + * 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.processor + +import com.datadog.android.sessionreplay.forge.ForgeConfigurator +import com.datadog.android.sessionreplay.internal.resources.ResourceDataStoreManager +import com.datadog.android.sessionreplay.internal.storage.ResourcesWriter +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.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +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 ResourceProcessorTest { + + private lateinit var testedProcessor: DefaultResourceProcessor + + @Mock + lateinit var mockResourceDataStoreManager: ResourceDataStoreManager + + @Mock + lateinit var mockResourcesWriter: ResourcesWriter + + @BeforeEach + fun `set up`() { + testedProcessor = DefaultResourceProcessor( + resourceDataStoreManager = mockResourceDataStoreManager, + resourcesWriter = mockResourcesWriter + ) + } + + @Test + fun `M drop resource W process { resource was previously sent }`() { + // Given + whenever(mockResourceDataStoreManager.markResourceAsSentIfNew(FAKE_RESOURCE_ID)) + .thenReturn(false) + + // When + testedProcessor.process(FAKE_RESOURCE_ID, byteArrayOf(1, 2, 3), FAKE_MIME_TYPE) + + // Then + verify(mockResourceDataStoreManager).markResourceAsSentIfNew(FAKE_RESOURCE_ID) + verifyNoInteractions(mockResourcesWriter) + } + + @Test + fun `M mark and write resource W process`() { + // Given + val resourceData = byteArrayOf(1, 2, 3) + whenever(mockResourceDataStoreManager.markResourceAsSentIfNew(FAKE_RESOURCE_ID)) + .thenReturn(true) + + // When + testedProcessor.process(FAKE_RESOURCE_ID, resourceData, FAKE_MIME_TYPE) + + // Then + verify(mockResourceDataStoreManager).markResourceAsSentIfNew(FAKE_RESOURCE_ID) + argumentCaptor { + verify(mockResourcesWriter).write(capture()) + assertThat(firstValue.resource).isEqualTo(resourceData) + assertThat(firstValue.filename).isEqualTo(FAKE_RESOURCE_ID) + assertThat(firstValue.mimeType).isEqualTo(FAKE_MIME_TYPE) + } + } + + @Test + fun `M preserve null mime type W process`() { + // Given + val resourceData = byteArrayOf(1, 2, 3) + whenever(mockResourceDataStoreManager.markResourceAsSentIfNew(FAKE_RESOURCE_ID)) + .thenReturn(true) + + // When + testedProcessor.process(FAKE_RESOURCE_ID, resourceData, null) + + // Then + argumentCaptor { + verify(mockResourcesWriter).write(capture()) + assertThat(firstValue.mimeType).isNull() + } + } + + private companion object { + const val FAKE_RESOURCE_ID = "resource-id" + const val FAKE_MIME_TYPE = "image/png" + } +} diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/WireframeUtilsTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/WireframeUtilsTest.kt index 281e22951e..6ba9b2ff46 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/WireframeUtilsTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/WireframeUtilsTest.kt @@ -581,6 +581,19 @@ internal class WireframeUtilsTest { .isFalse } + @Test + fun `M return false W checkWireframeIsCovered { hidden embedded content }`( + @Forgery fakeWireframe: MobileSegment.Wireframe.EmbeddedContentWireframe, + @Forgery topWireframes: List + ) { + // Given + val hiddenWireframe = fakeWireframe.copy(isVisible = false) + + // Then + assertThat(testedWireframeUtils.checkWireframeIsCovered(hiddenWireframe, topWireframes)) + .isFalse + } + // endregion // region checkWireframesIsValid @@ -591,6 +604,10 @@ internal class WireframeUtilsTest { forge: Forge ) { // Given + assumeTrue( + fakeWireframe !is MobileSegment.Wireframe.EmbeddedContentWireframe || + fakeWireframe.isVisible != false + ) whenever(mockBoundsUtils.resolveBounds(fakeWireframe)) .thenReturn(forge.getForgery().copy(width = 0)) @@ -604,6 +621,10 @@ internal class WireframeUtilsTest { forge: Forge ) { // Given + assumeTrue( + fakeWireframe !is MobileSegment.Wireframe.EmbeddedContentWireframe || + fakeWireframe.isVisible != false + ) whenever(mockBoundsUtils.resolveBounds(fakeWireframe)) .thenReturn(forge.getForgery().copy(height = 0)) @@ -611,6 +632,21 @@ internal class WireframeUtilsTest { assertThat(testedWireframeUtils.checkWireframeIsValid(fakeWireframe)).isFalse } + @Test + fun `M return true W checkWireframeIsValid { hidden embedded content has empty bounds }`( + @Forgery fakeWireframe: MobileSegment.Wireframe.EmbeddedContentWireframe + ) { + // Given + val hiddenWireframe = fakeWireframe.copy( + width = 0, + height = 0, + isVisible = false + ) + + // Then + assertThat(testedWireframeUtils.checkWireframeIsValid(hiddenWireframe)).isTrue + } + @Test fun `M return false W checkWireframeIsValid(){ shape wireframe with no border and background }`( forge: Forge diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/SnapshotProducerTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/SnapshotProducerTest.kt index c85287ca79..ba264b60e7 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/SnapshotProducerTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/SnapshotProducerTest.kt @@ -16,6 +16,7 @@ import com.datadog.android.sessionreplay.forge.ForgeConfigurator import com.datadog.android.sessionreplay.internal.TouchPrivacyManager import com.datadog.android.sessionreplay.internal.async.RecordedDataQueueRefs import com.datadog.android.sessionreplay.internal.recorder.SnapshotProducer.Companion.INVALID_PRIVACY_LEVEL_ERROR +import com.datadog.android.sessionreplay.internal.recorder.mapper.EmbeddedContentViewMapper import com.datadog.android.sessionreplay.model.MobileSegment import com.datadog.android.sessionreplay.recorder.MappingContext import com.datadog.android.sessionreplay.recorder.SystemInformation @@ -72,6 +73,9 @@ internal class SnapshotProducerTest { @Mock lateinit var mockTouchPrivacyManager: TouchPrivacyManager + @Mock + lateinit var mockEmbeddedContentViewMapper: EmbeddedContentViewMapper + @Forgery lateinit var fakeSystemInformation: SystemInformation @@ -95,6 +99,30 @@ internal class SnapshotProducerTest { ) } + @Test + fun `M delegate embedded lifecycle W beginSnapshot and finishSnapshot`() { + // Given + val hiddenNode = Node(wireframes = fakeViewWireframes) + testedSnapshotProducer = SnapshotProducer( + mockImageWireframeHelper, + mockTreeViewTraversal, + mockOptionSelectorDetector, + mockTouchPrivacyManager, + mockInternalLogger, + embeddedContentViewMapper = mockEmbeddedContentViewMapper + ) + whenever(mockEmbeddedContentViewMapper.finishSnapshot()).thenReturn(hiddenNode) + + // When + testedSnapshotProducer.beginSnapshot() + val result = testedSnapshotProducer.finishSnapshot() + + // Then + verify(mockEmbeddedContentViewMapper).beginSnapshot() + verify(mockEmbeddedContentViewMapper).finishSnapshot() + assertThat(result).isSameAs(hiddenNode) + } + @Test fun `M produce a null Node W produce(){ STOP_AND_DROP strategy }`() { // Given diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/TreeViewTraversalTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/TreeViewTraversalTest.kt index 800bea5130..8484d54028 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/TreeViewTraversalTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/TreeViewTraversalTest.kt @@ -20,10 +20,14 @@ import com.datadog.android.api.InternalLogger import com.datadog.android.api.feature.measureMethodCallPerf import com.datadog.android.core.metrics.MethodCallSamplingRate import com.datadog.android.sessionreplay.MapperTypeWrapper +import com.datadog.android.sessionreplay.R +import com.datadog.android.sessionreplay.TouchPrivacy import com.datadog.android.sessionreplay.forge.ForgeConfigurator import com.datadog.android.sessionreplay.internal.async.RecordedDataQueueRefs import com.datadog.android.sessionreplay.internal.recorder.TreeViewTraversal.Companion.METHOD_CALL_MAP_PREFIX +import com.datadog.android.sessionreplay.internal.recorder.callback.DefaultInteropViewCallback import com.datadog.android.sessionreplay.internal.recorder.mapper.DecorViewMapper +import com.datadog.android.sessionreplay.internal.recorder.mapper.EmbeddedContentViewMapper import com.datadog.android.sessionreplay.internal.recorder.mapper.HiddenViewMapper import com.datadog.android.sessionreplay.internal.recorder.mapper.ViewWireframeMapper import com.datadog.android.sessionreplay.model.MobileSegment @@ -46,7 +50,9 @@ import org.mockito.kotlin.any import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever import org.mockito.quality.Strictness @@ -147,6 +153,126 @@ internal class TreeViewTraversalTest { .isEqualTo(TraversalStrategy.STOP_AND_RETURN_NODE) } + @Test + fun `M use embedded mapper W traverse { tagged view has specialized mapper }`(forge: Forge) { + // Given + val mockView = forge.aMockView() + val mockCustomMapper = mock>() + val mockEmbeddedContentViewMapper = mock() + val fakeWireframes: List = forge.aList { getForgery() } + whenever(mockEmbeddedContentViewMapper.hasSlotId(mockView)) doReturn true + whenever( + mockEmbeddedContentViewMapper.map( + eq(mockView), + eq(fakeMappingContext), + any(), + eq(mockInternalLogger) + ) + ) doReturn fakeWireframes + testedTreeViewTraversal = TreeViewTraversal( + mappers = listOf(MapperTypeWrapper(TextView::class.java, mockCustomMapper)), + defaultViewMapper = mockDefaultViewMapper, + hiddenViewMapper = mockHiddenViewMapper, + decorViewMapper = mockDecorViewMapper, + viewUtilsInternal = mockViewUtilsInternal, + internalLogger = mockInternalLogger, + embeddedContentViewMapper = mockEmbeddedContentViewMapper + ) + + // When + val result = testedTreeViewTraversal.traverse( + mockView, + fakeMappingContext, + mockRecordedDataQueueRefs + ) + + // Then + assertThat(result.mappedWireframes).isEqualTo(fakeWireframes) + assertThat(result.nextActionStrategy).isEqualTo(TraversalStrategy.STOP_AND_RETURN_NODE) + verifyNoInteractions(mockCustomMapper) + } + + @Test + fun `M use embedded mapper W map { Compose interop view has slot ID }`(forge: Forge) { + // Given + val mockInteropView = forge.aMockView() + val mockEmbeddedContentViewMapper = mock() + val fakeEmbeddedWireframe: MobileSegment.Wireframe.EmbeddedContentWireframe = forge.getForgery() + whenever(mockEmbeddedContentViewMapper.hasSlotId(mockInteropView)) doReturn true + whenever( + mockEmbeddedContentViewMapper.map( + eq(mockInteropView), + eq(fakeMappingContext), + any(), + eq(mockInternalLogger) + ) + ) doReturn listOf(fakeEmbeddedWireframe) + testedTreeViewTraversal = TreeViewTraversal( + mappers = emptyList(), + defaultViewMapper = mockDefaultViewMapper, + hiddenViewMapper = mockHiddenViewMapper, + decorViewMapper = mockDecorViewMapper, + viewUtilsInternal = mockViewUtilsInternal, + internalLogger = mockInternalLogger, + embeddedContentViewMapper = mockEmbeddedContentViewMapper + ) + val interopViewCallback = DefaultInteropViewCallback( + treeViewTraversal = testedTreeViewTraversal, + recordedDataQueueRefs = mockRecordedDataQueueRefs + ) + + // When + val result = interopViewCallback.map(mockInteropView, fakeMappingContext) + + // Then + assertThat(result).containsExactly(fakeEmbeddedWireframe) + verify(mockEmbeddedContentViewMapper).map( + eq(mockInteropView), + eq(fakeMappingContext), + any(), + eq(mockInternalLogger) + ) + verifyNoInteractions(mockDefaultViewMapper, mockHiddenViewMapper, mockDecorViewMapper) + } + + @Test + fun `M use hidden mapper W traverse { tagged view has privacy hide override }`(forge: Forge) { + // Given + val mockView = forge.aMockView() + val mockEmbeddedContentViewMapper = mock() + val fakeWireframes: List = forge.aList { getForgery() } + whenever(mockView.getTag(R.id.datadog_hidden)) doReturn true + whenever( + mockHiddenViewMapper.map( + eq(mockView), + eq(fakeMappingContext), + any(), + eq(mockInternalLogger) + ) + ) doReturn fakeWireframes + testedTreeViewTraversal = TreeViewTraversal( + mappers = emptyList(), + defaultViewMapper = mockDefaultViewMapper, + hiddenViewMapper = mockHiddenViewMapper, + decorViewMapper = mockDecorViewMapper, + viewUtilsInternal = mockViewUtilsInternal, + internalLogger = mockInternalLogger, + embeddedContentViewMapper = mockEmbeddedContentViewMapper + ) + + // When + val result = testedTreeViewTraversal.traverse( + mockView, + fakeMappingContext, + mockRecordedDataQueueRefs + ) + + // Then + assertThat(result.mappedWireframes).isEqualTo(fakeWireframes) + assertThat(result.nextActionStrategy).isEqualTo(TraversalStrategy.STOP_AND_RETURN_NODE) + verify(mockEmbeddedContentViewMapper, never()).map(any(), any(), any(), any()) + } + @Test fun `M default to view mapper and stop W traverse { mapper for view type not provided }`( forge: Forge @@ -359,6 +485,76 @@ internal class TreeViewTraversalTest { .isEqualTo(TraversalStrategy.STOP_AND_DROP_NODE) } + @Test + fun `M preserve native traversal W traverse { unmarked view is not visible }`(forge: Forge) { + // Given + val mockEmbeddedContentViewMapper = mock() + val fakeRoot = forge.aMockView().apply { + whenever(mockViewUtilsInternal.isNotVisible(this)).thenReturn(true) + whenever(getTag(R.id.datadog_touch_privacy)).thenReturn(TouchPrivacy.HIDE.name) + } + testedTreeViewTraversal = TreeViewTraversal( + mappers = emptyList(), + defaultViewMapper = mockDefaultViewMapper, + hiddenViewMapper = mockHiddenViewMapper, + decorViewMapper = mockDecorViewMapper, + viewUtilsInternal = mockViewUtilsInternal, + internalLogger = mockInternalLogger, + embeddedContentViewMapper = mockEmbeddedContentViewMapper + ) + + // When + val result = testedTreeViewTraversal.traverse( + fakeRoot, + fakeMappingContext, + mockRecordedDataQueueRefs + ) + + // Then + assertThat(result.mappedWireframes).isEmpty() + assertThat(result.nextActionStrategy).isEqualTo(TraversalStrategy.STOP_AND_DROP_NODE) + verifyNoInteractions(fakeMappingContext.touchPrivacyManager) + verifyNoInteractions(mockDefaultViewMapper, mockHiddenViewMapper, mockDecorViewMapper) + } + + @Test + fun `M map embedded content W traverse { marked view is not visible }`(forge: Forge) { + // Given + val fakeRoot = forge.aMockView() + val mockEmbeddedContentViewMapper = mock() + val fakeWireframes: List = forge.aList { getForgery() } + whenever(mockViewUtilsInternal.isNotVisible(fakeRoot)).thenReturn(true) + whenever(mockEmbeddedContentViewMapper.hasSlotId(fakeRoot)).thenReturn(true) + whenever( + mockEmbeddedContentViewMapper.map( + eq(fakeRoot), + eq(fakeMappingContext), + any(), + eq(mockInternalLogger) + ) + ).thenReturn(fakeWireframes) + testedTreeViewTraversal = TreeViewTraversal( + mappers = emptyList(), + defaultViewMapper = mockDefaultViewMapper, + hiddenViewMapper = mockHiddenViewMapper, + decorViewMapper = mockDecorViewMapper, + viewUtilsInternal = mockViewUtilsInternal, + internalLogger = mockInternalLogger, + embeddedContentViewMapper = mockEmbeddedContentViewMapper + ) + + // When + val result = testedTreeViewTraversal.traverse( + fakeRoot, + fakeMappingContext, + mockRecordedDataQueueRefs + ) + + // Then + assertThat(result.mappedWireframes).isEqualTo(fakeWireframes) + assertThat(result.nextActionStrategy).isEqualTo(TraversalStrategy.STOP_AND_RETURN_NODE) + } + // endregion // region System Noise diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/ViewOnDrawInterceptorTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/ViewOnDrawInterceptorTest.kt index 22f0857bfd..455312c198 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/ViewOnDrawInterceptorTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/ViewOnDrawInterceptorTest.kt @@ -27,6 +27,7 @@ import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.clearInvocations import org.mockito.kotlin.doReturn import org.mockito.kotlin.doThrow import org.mockito.kotlin.inOrder @@ -158,6 +159,19 @@ internal class ViewOnDrawInterceptorTest { } } + @Test + fun `M invoke active listener once W requestCapture { multiple decor views }`() { + // Given + testedInterceptor.intercept(fakeDecorViews, fakeTextAndInputPrivacy, fakeImagePrivacy) + clearInvocations(mockOnDrawListener) + + // When + testedInterceptor.requestCapture() + + // Then + verify(mockOnDrawListener).onDraw() + } + @Test fun `M do nothing W intercept() { view tree observer is not alive }`() { // Given @@ -283,7 +297,7 @@ internal class ViewOnDrawInterceptorTest { // region Internal private fun Forge.aMockedDecorViewsList(): List { - return aList { + return aList(size = 2) { mock { val mockViewTreeObserver: ViewTreeObserver = mock() whenever(mockViewTreeObserver.isAlive) doReturn true diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/listener/WindowsOnDrawListenerTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/listener/WindowsOnDrawListenerTest.kt index 048b7708c2..33cdbcf883 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/listener/WindowsOnDrawListenerTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/listener/WindowsOnDrawListenerTest.kt @@ -229,6 +229,25 @@ internal class WindowsOnDrawListenerTest { verify(mockRecordedDataQueueHandler).tryToConsumeItems() } + @Test + fun `M prepend hidden embedded node W onDraw { embedded view missing from snapshot }`() { + // Given + val hiddenEmbeddedNode = Node(wireframes = emptyList()) + whenever(mockRecordedDataQueueHandler.addSnapshotItem(any())) + .thenReturn(fakeSnapshotQueueItem) + whenever(mockSnapshotProducer.finishSnapshot()).thenReturn(hiddenEmbeddedNode) + fakeSnapshotQueueItem.pendingJobs.set(0) + + // When + testedListener.onDraw() + + // Then + verify(mockSnapshotProducer).beginSnapshot() + verify(mockSnapshotProducer).finishSnapshot() + assertThat(fakeSnapshotQueueItem.nodes) + .containsExactlyElementsOf(listOf(hiddenEmbeddedNode) + fakeWindowsSnapshots) + } + @Test fun `M do nothing W onDraw(){ windows are empty }`() { // When diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/EmbeddedContentViewMapperTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/EmbeddedContentViewMapperTest.kt new file mode 100644 index 0000000000..766f55f953 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/EmbeddedContentViewMapperTest.kt @@ -0,0 +1,315 @@ +/* + * 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.recorder.mapper + +import android.view.View +import com.datadog.android.sessionreplay.R +import com.datadog.android.sessionreplay.forge.ForgeConfigurator +import com.datadog.android.sessionreplay.internal.embedded.EmbeddedContentSlotRegistration +import com.datadog.android.sessionreplay.internal.embedded.EmbeddedContentSlotRegistry +import com.datadog.android.sessionreplay.internal.recorder.ViewUtilsInternal +import com.datadog.android.sessionreplay.internal.recorder.mapper.EmbeddedContentViewMapper.Companion.EMBEDDED_CONTENT_KEY_NAME +import com.datadog.android.sessionreplay.model.MobileSegment +import com.datadog.android.sessionreplay.utils.GlobalBounds +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.AfterEach +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.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +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 EmbeddedContentViewMapperTest : LegacyBaseWireframeMapperTest() { + + private lateinit var testedMapper: EmbeddedContentViewMapper + + @Mock + lateinit var mockView: View + + @Mock + lateinit var mockViewUtilsInternal: ViewUtilsInternal + + @Forgery + lateinit var fakeBounds: GlobalBounds + + @LongForgery + var fakeWireframeId: Long = 0 + + @StringForgery + lateinit var fakeSlotId: String + + private lateinit var fakeSlotRegistration: EmbeddedContentSlotRegistration + private lateinit var embeddedContentSlotRegistry: EmbeddedContentSlotRegistry + + private var slotId: String? = null + + @BeforeEach + fun `set up`() { + fakeSlotRegistration = EmbeddedContentSlotRegistration(fakeSlotId) + embeddedContentSlotRegistry = EmbeddedContentSlotRegistry() + slotId = fakeSlotId + whenever(mockView.getTag(R.id.datadog_session_replay_slot_id)) doAnswer { slotId } + whenever(mockView.getTag(R.id.datadog_session_replay_slot_registration)) doReturn fakeSlotRegistration + whenever(mockViewUtilsInternal.isNotVisible(mockView)) doReturn false + whenever( + mockViewIdentifierResolver.resolveChildUniqueIdentifier( + mockView, + EMBEDDED_CONTENT_KEY_NAME + ) + ) doReturn fakeWireframeId + whenever( + mockViewBoundsResolver.resolveViewGlobalBounds( + mockView, + fakeMappingContext.systemInformation.screenDensity + ) + ) doReturn fakeBounds + + testedMapper = EmbeddedContentViewMapper( + mockViewIdentifierResolver, + mockColorStringFormatter, + mockViewBoundsResolver, + mockDrawableToColorMapper, + mockViewUtilsInternal, + embeddedContentSlotRegistry + ) + } + + @AfterEach + fun `tear down`() { + embeddedContentSlotRegistry.notifySlotChanged(fakeSlotRegistration, null) + } + + @Test + fun `M map embedded content W map { visible view }`() { + // When + val wireframe = testedMapper.map( + mockView, + fakeMappingContext, + mockAsyncJobStatusCallback, + mockInternalLogger + ).single() + + // Then + assertThat(embeddedContentSlotRegistry.isSlotMarked(fakeSlotId)).isTrue() + assertThat(wireframe).isEqualTo( + MobileSegment.Wireframe.EmbeddedContentWireframe( + id = fakeWireframeId, + x = fakeBounds.x, + y = fakeBounds.y, + width = fakeBounds.width, + height = fakeBounds.height, + slotId = fakeSlotId, + isVisible = true + ) + ) + verify(mockViewIdentifierResolver, never()).resolveViewId(mockView) + } + + @Test + fun `M map hidden embedded content W map { invisible view }`() { + // Given + whenever(mockViewUtilsInternal.isNotVisible(mockView)) doReturn true + + // When + val wireframe = testedMapper.map( + mockView, + fakeMappingContext, + mockAsyncJobStatusCallback, + mockInternalLogger + ).single() + + // Then + assertThat(wireframe).isEqualTo( + MobileSegment.Wireframe.EmbeddedContentWireframe( + id = fakeWireframeId, + x = 0, + y = 0, + width = 0, + height = 0, + slotId = fakeSlotId, + isVisible = false + ) + ) + } + + @Test + fun `M retain hidden slot W finishSnapshot { view missing from next snapshot }`() { + // Given + testedMapper.beginSnapshot() + testedMapper.map( + mockView, + fakeMappingContext, + mockAsyncJobStatusCallback, + mockInternalLogger + ) + assertThat(testedMapper.finishSnapshot()).isNull() + + // When + testedMapper.beginSnapshot() + val hiddenNode = testedMapper.finishSnapshot() + + // Then + assertThat(hiddenNode?.wireframes).containsExactly( + MobileSegment.Wireframe.EmbeddedContentWireframe( + id = fakeWireframeId, + x = 0, + y = 0, + width = 0, + height = 0, + slotId = fakeSlotId, + isVisible = false + ) + ) + } + + @Test + fun `M remove cached slot W finishSnapshot { slot cleared }`() { + // Given + testedMapper.beginSnapshot() + testedMapper.map( + mockView, + fakeMappingContext, + mockAsyncJobStatusCallback, + mockInternalLogger + ) + + // When + embeddedContentSlotRegistry.notifySlotChanged(fakeSlotRegistration, null) + slotId = null + testedMapper.beginSnapshot() + + // Then + assertThat(testedMapper.finishSnapshot()).isNull() + } + + @Test + fun `M track views independently W finishSnapshot { multiple embedded views }`() { + // Given + val firstView = mock() + val secondView = mock() + val fakeFirstSlotId = "first-slot" + val fakeSecondSlotId = "second-slot" + val fakeFirstRegistration = EmbeddedContentSlotRegistration(fakeFirstSlotId) + val fakeSecondRegistration = EmbeddedContentSlotRegistration(fakeSecondSlotId) + whenever(firstView.getTag(R.id.datadog_session_replay_slot_id)) doReturn fakeFirstSlotId + whenever(secondView.getTag(R.id.datadog_session_replay_slot_id)) doReturn fakeSecondSlotId + whenever(firstView.getTag(R.id.datadog_session_replay_slot_registration)) doReturn fakeFirstRegistration + whenever(secondView.getTag(R.id.datadog_session_replay_slot_registration)) doReturn fakeSecondRegistration + embeddedContentSlotRegistry.notifySlotChanged(null, fakeFirstRegistration) + embeddedContentSlotRegistry.notifySlotChanged(null, fakeSecondRegistration) + whenever( + mockViewIdentifierResolver.resolveChildUniqueIdentifier(firstView, EMBEDDED_CONTENT_KEY_NAME) + ) doReturn 101L + whenever( + mockViewIdentifierResolver.resolveChildUniqueIdentifier(secondView, EMBEDDED_CONTENT_KEY_NAME) + ) doReturn 202L + whenever( + mockViewBoundsResolver.resolveViewGlobalBounds( + firstView, + fakeMappingContext.systemInformation.screenDensity + ) + ) doReturn fakeBounds + whenever( + mockViewBoundsResolver.resolveViewGlobalBounds( + secondView, + fakeMappingContext.systemInformation.screenDensity + ) + ) doReturn fakeBounds + try { + testedMapper.beginSnapshot() + val firstWireframe = testedMapper.map( + firstView, + fakeMappingContext, + mockAsyncJobStatusCallback, + mockInternalLogger + ).single() + val secondWireframe = testedMapper.map( + secondView, + fakeMappingContext, + mockAsyncJobStatusCallback, + mockInternalLogger + ).single() + assertThat(testedMapper.finishSnapshot()).isNull() + + // When + testedMapper.beginSnapshot() + testedMapper.map( + firstView, + fakeMappingContext, + mockAsyncJobStatusCallback, + mockInternalLogger + ) + val hiddenNode = testedMapper.finishSnapshot() + + // Then + val firstEmbeddedWireframe = + firstWireframe as MobileSegment.Wireframe.EmbeddedContentWireframe + val secondEmbeddedWireframe = + secondWireframe as MobileSegment.Wireframe.EmbeddedContentWireframe + assertThat(firstEmbeddedWireframe.id).isEqualTo(101L) + assertThat(firstEmbeddedWireframe.slotId).isEqualTo(fakeFirstSlotId) + assertThat(secondEmbeddedWireframe.id).isEqualTo(202L) + assertThat(secondEmbeddedWireframe.slotId).isEqualTo(fakeSecondSlotId) + assertThat(hiddenNode?.wireframes).containsExactly( + MobileSegment.Wireframe.EmbeddedContentWireframe( + id = 202L, + x = 0, + y = 0, + width = 0, + height = 0, + slotId = fakeSecondSlotId, + isVisible = false + ) + ) + } finally { + embeddedContentSlotRegistry.notifySlotChanged(fakeFirstRegistration, null) + embeddedContentSlotRegistry.notifySlotChanged(fakeSecondRegistration, null) + } + } + + @Test + fun `M ignore view W map { embedded wireframe id cannot be resolved }`() { + // Given + whenever( + mockViewIdentifierResolver.resolveChildUniqueIdentifier( + mockView, + EMBEDDED_CONTENT_KEY_NAME + ) + ) doReturn null + + // When + val wireframes = testedMapper.map( + mockView, + fakeMappingContext, + mockAsyncJobStatusCallback, + mockInternalLogger + ) + + // Then + assertThat(wireframes).isEmpty() + } +} diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/resources/ResourceDataStoreManagerMigrationTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/resources/ResourceDataStoreManagerMigrationTest.kt index 926432c874..3e1dc7fe43 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/resources/ResourceDataStoreManagerMigrationTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/resources/ResourceDataStoreManagerMigrationTest.kt @@ -78,7 +78,7 @@ internal class ResourceDataStoreManagerMigrationTest { assertThat(dataStore.removeCount).isEqualTo(1) // When - testedManager.cacheResourceHash(NEW_RESOURCE_HASH) + testedManager.markResourceAsSentIfNew(NEW_RESOURCE_HASH) // Then val migratedEntry = checkNotNull(dataStore.entry) diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/resources/ResourceDataStoreManagerTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/resources/ResourceDataStoreManagerTest.kt index 75578ddd35..4809eb1a89 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/resources/ResourceDataStoreManagerTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/resources/ResourceDataStoreManagerTest.kt @@ -40,9 +40,14 @@ import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doAnswer import org.mockito.kotlin.eq import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.mockito.quality.Strictness +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit @Extensions( ExtendWith(MockitoExtension::class), @@ -113,7 +118,7 @@ internal class ResourceDataStoreManagerTest { resourceHashesSerializer = mockResourceHashesEntrySerializer, resourceHashesDeserializer = mockResourceHashesEntryDeserializer ) - testedDataStoreManager.cacheResourceHash(fakeHash) + testedDataStoreManager.markResourceAsSentIfNew(fakeHash) // When val wasSent = testedDataStoreManager.isPreviouslySentResource(fakeHash) @@ -123,8 +128,9 @@ internal class ResourceDataStoreManagerTest { } @Test - fun `M write to datastore W cacheResourceHash`() { + fun `M write to datastore W markResourceAsSentIfNew`() { // Given + setFetchDataSuccess(null) testedDataStoreManager = ResourceDataStoreManager( featureSdkCore = mockFeatureSdkCore, resourceHashesSerializer = mockResourceHashesEntrySerializer, @@ -132,7 +138,7 @@ internal class ResourceDataStoreManagerTest { ) // When - testedDataStoreManager.cacheResourceHash(fakeHash) + testedDataStoreManager.markResourceAsSentIfNew(fakeHash) // Then verify(mockFeatureScope.dataStore).setValue( @@ -145,7 +151,123 @@ internal class ResourceDataStoreManagerTest { } @Test - fun `M do not use expired date W cacheResourceHash { datastore expired }`( + fun `M mark resource once W markResourceAsSentIfNew { concurrent calls }`() { + // Given + setFetchDataSuccess(null) + testedDataStoreManager = ResourceDataStoreManager( + featureSdkCore = mockFeatureSdkCore, + resourceHashesSerializer = mockResourceHashesEntrySerializer, + resourceHashesDeserializer = mockResourceHashesEntryDeserializer + ) + val concurrentCallCount = 20 + val executor = Executors.newFixedThreadPool(concurrentCallCount) + val startLatch = CountDownLatch(1) + val completionLatch = CountDownLatch(concurrentCallCount) + val results = ConcurrentLinkedQueue() + + try { + repeat(concurrentCallCount) { + executor.execute { + startLatch.await() + results += testedDataStoreManager.markResourceAsSentIfNew(fakeHash) + completionLatch.countDown() + } + } + + // When + startLatch.countDown() + + // Then + assertThat(completionLatch.await(5, TimeUnit.SECONDS)).isTrue() + assertThat(results.count { it }).isEqualTo(1) + } finally { + executor.shutdownNow() + } + } + + @Test + fun `M persist pending resource W init { resource marked before fetch completes }`() { + // Given + testedDataStoreManager = ResourceDataStoreManager( + featureSdkCore = mockFeatureSdkCore, + resourceHashesSerializer = mockResourceHashesEntrySerializer, + resourceHashesDeserializer = mockResourceHashesEntryDeserializer + ) + val fetchCallback = argumentCaptor>().also { + verify(mockDataStoreHandler).value( + key = eq(DATASTORE_HASHES_ENTRY_NAME), + version = anyOrNull(), + callback = it.capture(), + deserializer = any() + ) + }.firstValue + assertThat(testedDataStoreManager.markResourceAsSentIfNew(fakeHash)).isTrue() + verify(mockDataStoreHandler, never()).setValue( + key = any(), + data = any(), + version = any(), + callback = anyOrNull(), + serializer = any() + ) + + // When + fetchCallback.onSuccess(null) + + // Then + verify(mockDataStoreHandler).setValue( + key = eq(DATASTORE_HASHES_ENTRY_NAME), + data = any(), + version = eq(DATASTORE_VERSION), + callback = anyOrNull(), + serializer = eq(mockResourceHashesEntrySerializer) + ) + } + + @Test + fun `M persist stored and pending hashes W init { resource marked before valid fetch completes }`( + forge: Forge + ) { + // Given + val storedHash = "stored-$fakeHash" + val storedContent = generateDataStoreContent( + forge = forge, + isExpired = false, + currentTime = fakeCurrentTimestampMs, + resourceHashes = listOf(storedHash) + ) + testedDataStoreManager = ResourceDataStoreManager( + featureSdkCore = mockFeatureSdkCore, + resourceHashesSerializer = mockResourceHashesEntrySerializer, + resourceHashesDeserializer = mockResourceHashesEntryDeserializer + ) + val fetchCallback = argumentCaptor>().also { + verify(mockDataStoreHandler).value( + key = eq(DATASTORE_HASHES_ENTRY_NAME), + version = anyOrNull(), + callback = it.capture(), + deserializer = any() + ) + }.firstValue + assertThat(testedDataStoreManager.markResourceAsSentIfNew(fakeHash)).isTrue() + + // When + fetchCallback.onSuccess(storedContent) + + // Then + val entryCaptor = argumentCaptor() + verify(mockDataStoreHandler).setValue( + key = eq(DATASTORE_HASHES_ENTRY_NAME), + data = entryCaptor.capture(), + version = eq(DATASTORE_VERSION), + callback = anyOrNull(), + serializer = eq(mockResourceHashesEntrySerializer) + ) + assertThat(entryCaptor.firstValue.resourceHashes) + .containsExactlyInAnyOrder(storedHash, fakeHash) + } + + @Test + fun `M do not use expired date W markResourceAsSentIfNew { datastore expired }`( forge: Forge ) { // Given @@ -159,7 +281,7 @@ internal class ResourceDataStoreManagerTest { resourceHashesDeserializer = mockResourceHashesEntryDeserializer ) - testedDataStoreManager.cacheResourceHash(fakeHash) + testedDataStoreManager.markResourceAsSentIfNew(fakeHash) // Then val resourceHashesEntryCaptor = argumentCaptor() @@ -177,7 +299,7 @@ internal class ResourceDataStoreManagerTest { } @Test - fun `M refresh stored date W cacheResourceHash { valid entry was loaded }`( + fun `M refresh stored date W markResourceAsSentIfNew { valid entry was loaded }`( forge: Forge ) { // Given @@ -196,7 +318,7 @@ internal class ResourceDataStoreManagerTest { ) // When - testedDataStoreManager.cacheResourceHash(fakeHash) + testedDataStoreManager.markResourceAsSentIfNew(fakeHash) // Then val resourceHashesEntryCaptor = argumentCaptor() @@ -431,9 +553,9 @@ internal class ResourceDataStoreManagerTest { isExpired: Boolean, currentTime: Long, version: Int = DATASTORE_VERSION, - storedTimestamp: Long? = null + storedTimestamp: Long? = null, + resourceHashes: List = forge.aList { aString() }.distinct() ): DataStoreContent { - val resourceHashes = forge.aList { aString() }.distinct() val entryTime = storedTimestamp ?: if (isExpired) { currentTime - DATASTORE_EXPIRATION_MS - 1 } else { diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/storage/SessionReplayRecordWriterTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/storage/SessionReplayRecordWriterTest.kt index 6fef625248..bb9b0ea1c4 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/storage/SessionReplayRecordWriterTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/storage/SessionReplayRecordWriterTest.kt @@ -19,8 +19,11 @@ import com.datadog.android.sessionreplay.internal.RecordCallback import com.datadog.android.sessionreplay.internal.processor.EnrichedRecord import fr.xgouchet.elmyr.Forge 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.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @@ -46,6 +49,7 @@ import java.util.UUID @ForgeConfiguration(ForgeConfigurator::class) internal class SessionReplayRecordWriterTest { lateinit var testedWriter: SessionReplayRecordWriter + private val embeddedRecordWrites = mutableListOf>() @Mock lateinit var mockSdkCore: FeatureSdkCore @@ -73,7 +77,10 @@ internal class SessionReplayRecordWriterTest { whenever(mockSdkCore.getFeature(Feature.SESSION_REPLAY_FEATURE_NAME)) .thenReturn(mockSessionReplayFeature) - testedWriter = SessionReplayRecordWriter(mockSdkCore, mockRecordCallback) + embeddedRecordWrites.clear() + testedWriter = SessionReplayRecordWriter(mockSdkCore, mockRecordCallback) { viewId, recordsCount -> + embeddedRecordWrites += viewId to recordsCount + } } @Test @@ -102,6 +109,7 @@ internal class SessionReplayRecordWriterTest { verify(mockRecordCallback).onRecordForViewSent(fakeRecord) verifyNoMoreInteractions(mockRecordCallback) + assertThat(embeddedRecordWrites).isEmpty() } @Test @@ -119,6 +127,35 @@ internal class SessionReplayRecordWriterTest { verifyNoMoreInteractions(mockRecordCallback) } + @Test + fun `M write serialized record W writeRaw`( + @StringForgery fakeViewId: String, + @IntForgery(min = 1) fakeRecordsCount: Int + ) { + // Given + val fakeRecord = byteArrayOf(1, 2, 3) + whenever(mockEventWriteScope.invoke(any())) doAnswer { + val callback = it.getArgument<(EventBatchWriter) -> Unit>(0) + callback.invoke(mockEventBatchWriter) + } + whenever(mockSessionReplayFeature.withWriteContext(eq(emptySet()), any())) doAnswer { + val callback = it.getArgument<(DatadogContext, EventWriteScope) -> Unit>(it.arguments.lastIndex) + callback.invoke(fakeDatadogContext, mockEventWriteScope) + } + + // When + testedWriter.writeRaw(fakeRecord, fakeViewId, fakeRecordsCount) + + // Then + verify(mockEventBatchWriter).write( + event = RawBatchEvent(data = fakeRecord), + batchMetadata = null, + eventType = EventType.DEFAULT + ) + assertThat(embeddedRecordWrites).containsExactly(fakeViewId to fakeRecordsCount) + verifyNoMoreInteractions(mockRecordCallback) + } + @Test fun `M not call record callback W write { eventBatchWriter write failed }`(forge: Forge) { // Given @@ -149,6 +186,37 @@ internal class SessionReplayRecordWriterTest { verifyNoMoreInteractions(mockRecordCallback) } + @Test + fun `M not count embedded records W writeRaw { eventBatchWriter write failed }`( + @StringForgery fakeViewId: String, + @IntForgery(min = 1) fakeRecordsCount: Int + ) { + // Given + val fakeRecord = byteArrayOf(1, 2, 3) + whenever(mockEventBatchWriter.write(anyOrNull(), anyOrNull(), any())) + .thenReturn(false) + whenever(mockEventWriteScope.invoke(any())) doAnswer { + val callback = it.getArgument<(EventBatchWriter) -> Unit>(0) + callback.invoke(mockEventBatchWriter) + } + whenever(mockSessionReplayFeature.withWriteContext(any(), any())) doAnswer { + val callback = it.getArgument<(DatadogContext, EventWriteScope) -> Unit>(it.arguments.lastIndex) + callback.invoke(fakeDatadogContext, mockEventWriteScope) + } + + // When + testedWriter.writeRaw(fakeRecord, fakeViewId, fakeRecordsCount) + + // Then + verify(mockEventBatchWriter).write( + event = RawBatchEvent(data = fakeRecord), + batchMetadata = null, + eventType = EventType.DEFAULT + ) + assertThat(embeddedRecordWrites).isEmpty() + verifyNoMoreInteractions(mockRecordCallback) + } + private fun Forge.forgeEnrichedRecord(): EnrichedRecord { // We don't want to create a forgery for this as this lives in the session-replay module // and we will need to copy all the records forgeries. Instead we just forge this record diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/utils/SessionReplayRumContextTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/utils/SessionReplayRumContextTest.kt index 738bba7b49..bcb4de2f47 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/utils/SessionReplayRumContextTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/utils/SessionReplayRumContextTest.kt @@ -26,6 +26,45 @@ import org.mockito.quality.Strictness @ForgeConfiguration(ForgeConfigurator::class) internal class SessionReplayRumContextTest { + @Test + fun `M return true W hasValidApplicationAndSession() { application and session are set }`() { + // Given + val rumContext = SessionReplayRumContext( + applicationId = FAKE_APPLICATION_ID, + sessionId = FAKE_SESSION_ID + ) + + // When + val result = rumContext.hasValidApplicationAndSession() + + // Then + assertThat(result).isTrue() + } + + @Test + fun `M return false W hasValidApplicationAndSession() { application is missing }`() { + // Given + val rumContext = SessionReplayRumContext(sessionId = FAKE_SESSION_ID) + + // When + val result = rumContext.hasValidApplicationAndSession() + + // Then + assertThat(result).isFalse() + } + + @Test + fun `M return false W hasValidApplicationAndSession() { session is missing }`() { + // Given + val rumContext = SessionReplayRumContext(applicationId = FAKE_APPLICATION_ID) + + // When + val result = rumContext.hasValidApplicationAndSession() + + // Then + assertThat(result).isFalse() + } + @Test fun `M return true W isNotValid() { RumContext is not valid }`() { // Given @@ -41,4 +80,9 @@ internal class SessionReplayRumContextTest { ) { assertThat(fakeContext.isNotValid()).isFalse() } + + private companion object { + const val FAKE_APPLICATION_ID = "application-id" + const val FAKE_SESSION_ID = "session-id" + } } diff --git a/instrumented/integration/src/androidTest/kotlin/com/datadog/android/sdk/integration/sessionreplay/EmbeddedContentSlotTest.kt b/instrumented/integration/src/androidTest/kotlin/com/datadog/android/sdk/integration/sessionreplay/EmbeddedContentSlotTest.kt new file mode 100644 index 0000000000..26dfebcaf0 --- /dev/null +++ b/instrumented/integration/src/androidTest/kotlin/com/datadog/android/sdk/integration/sessionreplay/EmbeddedContentSlotTest.kt @@ -0,0 +1,69 @@ +/* + * 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.sdk.integration.sessionreplay + +import android.view.View +import android.widget.FrameLayout +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.datadog.android.sessionreplay.R +import com.datadog.android.sessionreplay._SessionReplayInternalProxy +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +internal class EmbeddedContentSlotTest { + + @Test + fun markedViewRetainsSlotWhenRemovedAndRestored() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val root = FrameLayout(instrumentation.targetContext) + val embeddedView = View(instrumentation.targetContext) + + instrumentation.runOnMainSync { + root.addView(embeddedView) + try { + _SessionReplayInternalProxy.setEmbeddedContentSlotId(embeddedView, FAKE_SLOT_ID) + val registration = embeddedView.getTag( + R.id.datadog_session_replay_slot_registration + ) + assertNotNull(registration) + + root.removeView(embeddedView) + assertEquals( + FAKE_SLOT_ID, + embeddedView.getTag(R.id.datadog_session_replay_slot_id) + ) + assertSame( + registration, + embeddedView.getTag(R.id.datadog_session_replay_slot_registration) + ) + + root.addView(embeddedView) + _SessionReplayInternalProxy.setEmbeddedContentSlotId(embeddedView, FAKE_SLOT_ID) + assertSame( + registration, + embeddedView.getTag(R.id.datadog_session_replay_slot_registration) + ) + + _SessionReplayInternalProxy.setEmbeddedContentSlotId(embeddedView, null) + assertNull(embeddedView.getTag(R.id.datadog_session_replay_slot_id)) + assertNull(embeddedView.getTag(R.id.datadog_session_replay_slot_registration)) + } finally { + _SessionReplayInternalProxy.setEmbeddedContentSlotId(embeddedView, null) + } + } + } + + companion object { + private const val FAKE_SLOT_ID = "flutter/slot" + } +}