diff --git a/detekt_custom_safe_calls_android.yml b/detekt_custom_safe_calls_android.yml index c1ecfb21f5..4835caa866 100644 --- a/detekt_custom_safe_calls_android.yml +++ b/detekt_custom_safe_calls_android.yml @@ -131,6 +131,7 @@ datadog: - "android.view.View.hashCode()" - "android.view.View.onDraw(android.graphics.Canvas)" - "android.view.View.performClick()" + - "android.view.View.postInvalidateOnAnimation()" - "android.view.View.post(java.lang.Runnable?)" - "android.view.View.removeOnAttachStateChangeListener(android.view.View.OnAttachStateChangeListener?)" - "android.view.View.setOnClickListener(android.view.View.OnClickListener?)" diff --git a/detekt_custom_safe_calls_third_party.yml b/detekt_custom_safe_calls_third_party.yml index e1fa0b408e..d5d5029c8e 100644 --- a/detekt_custom_safe_calls_third_party.yml +++ b/detekt_custom_safe_calls_third_party.yml @@ -345,6 +345,7 @@ datadog: - "java.lang.ref.WeakReference.constructor(android.view.Window?)" - "java.lang.ref.WeakReference.constructor(com.datadog.android.api.SdkCore?)" - "java.lang.ref.WeakReference.constructor(com.datadog.android.rum.tracking.ViewTarget?)" + - "java.lang.ref.WeakReference.constructor(com.datadog.android.sessionreplay.internal.embedded.EmbeddedContentSlotRegistration?)" - "java.lang.ref.WeakReference.constructor(kotlin.Any?)" - "java.lang.ref.WeakReference.constructor(kotlin.Nothing?)" - "java.lang.ref.WeakReference.constructor(kotlin.String?)" @@ -461,8 +462,11 @@ datadog: - "kotlin.collections.ArrayList(kotlin.collections.MutableCollection?)" - "kotlin.collections.buildMap(kotlin.Function1)" - "kotlin.collections.Collection.flatten()" + - "kotlin.collections.Collection.firstOrNull()" - "kotlin.collections.Collection.fold(kotlin.collections.MutableSet, kotlin.Function2)" - "kotlin.collections.Collection.forEach(kotlin.Function1)" + - "kotlin.collections.Collection.groupBy(kotlin.Function1)" + - "kotlin.collections.Collection.mapNotNull(kotlin.Function1)" - "kotlin.collections.Collection.isNotEmpty()" - "kotlin.collections.Collection.sumOf(kotlin.Function1)" - "kotlin.collections.Collection.toList()" @@ -502,6 +506,7 @@ datadog: - "kotlin.collections.List.firstOrNull(kotlin.Function1)" - "kotlin.collections.List.firstNotNullOfOrNull(kotlin.Function1)" - "kotlin.collections.List.flatMap(kotlin.Function1)" + - "kotlin.collections.List.flatten()" - "kotlin.collections.List.fold(com.google.gson.JsonArray, kotlin.Function2)" - "kotlin.collections.List.fold(kotlin.Long, kotlin.Function2)" - "kotlin.collections.List.fold(kotlin.String?, kotlin.Function2)" @@ -519,10 +524,12 @@ datadog: - "kotlin.collections.List.lastOrNull()" - "kotlin.collections.List.lastOrNull(kotlin.Function1)" - "kotlin.collections.List.map(kotlin.Function1)" + - "kotlin.collections.List.mapTo(kotlin.collections.MutableSet, kotlin.Function1)" - "kotlin.collections.List.mapIndexed(kotlin.Function2)" - "kotlin.collections.List.mapNotNull(kotlin.Function1)" - "kotlin.collections.List.maxByOrNull(kotlin.Function1)" - "kotlin.collections.List.maxOrNull()" + - "kotlin.collections.List.minOrNull()" - "kotlin.collections.List.orEmpty()" - "kotlin.collections.List.partition(kotlin.Function1)" - "kotlin.collections.List.reduceOrNull(kotlin.Function2)" @@ -571,6 +578,7 @@ datadog: - "kotlin.collections.MutableCollection.flatMap(kotlin.Function1)" - "kotlin.collections.MutableCollection.forEach(kotlin.Function1)" - "kotlin.collections.MutableCollection.toList()" + - "kotlin.collections.MutableCollection.toSet()" - "kotlin.collections.MutableIterator.forEach(kotlin.Function1)" - "kotlin.collections.MutableIterator.hasNext()" - "kotlin.collections.MutableList.add(com.datadog.android.api.InternalLogger.Target)" @@ -587,6 +595,7 @@ datadog: - "kotlin.collections.MutableList.add(com.datadog.android.rum.model.ActionEvent.Type)" - "kotlin.collections.MutableList.add(com.datadog.android.sessionreplay.compose.internal.data.Parameter)" - "kotlin.collections.MutableList.add(com.datadog.android.sessionreplay.compose.internal.utils.BackgroundInfo)" + - "kotlin.collections.MutableList.add(com.datadog.android.sessionreplay.internal.embedded.PendingBatch)" - "kotlin.collections.MutableList.add(com.datadog.android.sessionreplay.internal.prerequisite.SystemRequirementChecker)" - "kotlin.collections.MutableList.add(com.datadog.android.sessionreplay.internal.processor.MutationResolver.Entry)" - "kotlin.collections.MutableList.add(com.datadog.android.sessionreplay.model.MobileSegment.Add)" @@ -606,6 +615,7 @@ datadog: - "kotlin.collections.MutableList.add(kotlin.Int, com.datadog.android.sessionreplay.model.MobileSegment.Wireframe)" - "kotlin.collections.MutableList.add(kotlin.Int, com.datadog.android.sessionreplay.model.MobileSegment.Wireframe.ShapeWireframe)" - "kotlin.collections.MutableList.add(kotlin.String)" + - "kotlin.collections.MutableList.add(kotlin.Function1)" - "kotlin.collections.MutableList.addAll(kotlin.collections.Collection)" - "kotlin.collections.MutableList.any(kotlin.Function1)" - "kotlin.collections.MutableList.clear()" @@ -623,6 +633,7 @@ datadog: - "kotlin.collections.MutableList.iterator()" - "kotlin.collections.MutableList.joinToString(kotlin.CharSequence, kotlin.CharSequence, kotlin.CharSequence, kotlin.Int, kotlin.CharSequence, kotlin.Function1?)" - "kotlin.collections.MutableList.map(kotlin.Function1)" + - "kotlin.collections.MutableList.mapNotNullTo(kotlin.collections.MutableSet, kotlin.Function1)" - "kotlin.collections.MutableList.partition(kotlin.Function1)" - "kotlin.collections.MutableList.remove(java.io.File)" - "kotlin.collections.MutableList.remove(java.lang.ref.WeakReference)" @@ -699,7 +710,10 @@ datadog: - "kotlin.collections.MutableSet.contains(com.datadog.android.telemetry.internal.TelemetryEventId)" - "kotlin.collections.MutableSet.contains(kotlin.String)" - "kotlin.collections.MutableSet.contains(kotlin.String?)" + - "kotlin.collections.MutableSet.isEmpty()" + - "kotlin.collections.MutableSet.remove(kotlin.String)" - "kotlin.collections.MutableSet.filter(kotlin.Function1)" + - "kotlin.collections.MutableSet.firstOrNull()" - "kotlin.collections.MutableSet.firstOrNull(kotlin.Function1)" - "kotlin.collections.MutableSet.flatMap(kotlin.Function1)" - "kotlin.collections.MutableSet.forEach(kotlin.Function1)" @@ -713,6 +727,7 @@ datadog: - "kotlin.collections.MutableSet.remove(java.io.File)" - "kotlin.collections.MutableSet.remove(kotlin.collections.MutableMap.Mutab(...)" - "kotlin.collections.MutableSet.removeAll(kotlin.collections.Collection)" + - "kotlin.collections.MutableSet.retainAll(kotlin.collections.Collection)" - "kotlin.collections.MutableSet.toList()" - "kotlin.collections.MutableSet.lastOrNull()" - "kotlin.collections.MutableSet.sortedByDescending(kotlin.Function1)" 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 f420baab16..f30b122d01 100644 --- a/features/dd-sdk-android-session-replay/api/apiSurface +++ b/features/dd-sdk-android-session-replay/api/apiSurface @@ -24,6 +24,7 @@ const val WIREFRAME_TYPE_TEXT: String const val WIREFRAME_TYPE_IMAGE: String const val WIREFRAME_TYPE_PLACEHOLDER: String const val WIREFRAME_TYPE_WEBVIEW: String +const val WIREFRAME_TYPE_EMBEDDED_CONTENT: String const val IMAGE_DIMEN_CONSIDERED_PII_IN_DP: Int interface com.datadog.android.sessionreplay.PrivacyLevel fun android.view.View.setSessionReplayHidden(Boolean) @@ -77,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) @@ -192,14 +197,14 @@ data class com.datadog.android.sessionreplay.model.MobileSegment sealed class MobileRecord abstract fun toJson(): com.google.gson.JsonElement data class MobileFullSnapshotRecord : MobileRecord - constructor(kotlin.Long, Data) + constructor(kotlin.Long, Data, kotlin.String? = null) val type: kotlin.Long override fun toJson(): com.google.gson.JsonElement companion object fun fromJson(kotlin.String): MobileFullSnapshotRecord fun fromJsonObject(com.google.gson.JsonObject): MobileFullSnapshotRecord data class MobileIncrementalSnapshotRecord : MobileRecord - constructor(kotlin.Long, MobileIncrementalData) + constructor(kotlin.Long, MobileIncrementalData, kotlin.String? = null) val type: kotlin.Long override fun toJson(): com.google.gson.JsonElement companion object @@ -330,6 +335,13 @@ data class com.datadog.android.sessionreplay.model.MobileSegment companion object fun fromJson(kotlin.String): WebviewWireframe fun fromJsonObject(com.google.gson.JsonObject): WebviewWireframe + data class EmbeddedContentWireframe : Wireframe + constructor(kotlin.Long, kotlin.Long, kotlin.Long, kotlin.Long, kotlin.Long, WireframeClip? = null, ShapeStyle? = null, ShapeBorder? = null, kotlin.String, kotlin.Boolean? = null, kotlin.String? = null) + val type: kotlin.String + override fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): EmbeddedContentWireframe + fun fromJsonObject(com.google.gson.JsonObject): EmbeddedContentWireframe companion object fun fromJson(kotlin.String): Wireframe fun fromJsonElement(com.google.gson.JsonElement): Wireframe @@ -382,6 +394,13 @@ data class com.datadog.android.sessionreplay.model.MobileSegment companion object fun fromJson(kotlin.String): WebviewWireframeUpdate fun fromJsonObject(com.google.gson.JsonObject): WebviewWireframeUpdate + data class EmbeddedContentWireframeUpdate : WireframeUpdateMutation + constructor(kotlin.Long, kotlin.Long? = null, kotlin.Long? = null, kotlin.Long? = null, kotlin.Long? = null, WireframeClip? = null, ShapeStyle? = null, ShapeBorder? = null, kotlin.String, kotlin.Boolean? = null) + val type: kotlin.String + override fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): EmbeddedContentWireframeUpdate + fun fromJsonObject(com.google.gson.JsonObject): EmbeddedContentWireframeUpdate companion object fun fromJson(kotlin.String): WireframeUpdateMutation fun fromJsonElement(com.google.gson.JsonElement): WireframeUpdateMutation 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 9881fee8f6..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 @@ -31,6 +31,7 @@ public final class com/datadog/android/sessionreplay/MobileSegmentConstantsKt { public static final field RECORD_TYPE_META J public static final field RECORD_TYPE_VIEW_END J public static final field RECORD_TYPE_VISUAL_VIEWPORT J + public static final field WIREFRAME_TYPE_EMBEDDED_CONTENT Ljava/lang/String; public static final field WIREFRAME_TYPE_IMAGE Ljava/lang/String; public static final field WIREFRAME_TYPE_PLACEHOLDER Ljava/lang/String; public static final field WIREFRAME_TYPE_SHAPE Ljava/lang/String; @@ -146,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 @@ -560,15 +570,18 @@ public final class com/datadog/android/sessionreplay/model/MobileSegment$MobileR public final class com/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$MobileFullSnapshotRecord : com/datadog/android/sessionreplay/model/MobileSegment$MobileRecord { public static final field Companion Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$MobileFullSnapshotRecord$Companion; - public fun (JLcom/datadog/android/sessionreplay/model/MobileSegment$Data;)V + public fun (JLcom/datadog/android/sessionreplay/model/MobileSegment$Data;Ljava/lang/String;)V + public synthetic fun (JLcom/datadog/android/sessionreplay/model/MobileSegment$Data;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()J public final fun component2 ()Lcom/datadog/android/sessionreplay/model/MobileSegment$Data; - public final fun copy (JLcom/datadog/android/sessionreplay/model/MobileSegment$Data;)Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$MobileFullSnapshotRecord; - public static synthetic fun copy$default (Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$MobileFullSnapshotRecord;JLcom/datadog/android/sessionreplay/model/MobileSegment$Data;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$MobileFullSnapshotRecord; + public final fun component3 ()Ljava/lang/String; + public final fun copy (JLcom/datadog/android/sessionreplay/model/MobileSegment$Data;Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$MobileFullSnapshotRecord; + public static synthetic fun copy$default (Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$MobileFullSnapshotRecord;JLcom/datadog/android/sessionreplay/model/MobileSegment$Data;Ljava/lang/String;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$MobileFullSnapshotRecord; public fun equals (Ljava/lang/Object;)Z public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$MobileFullSnapshotRecord; public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$MobileFullSnapshotRecord; public final fun getData ()Lcom/datadog/android/sessionreplay/model/MobileSegment$Data; + public final fun getSlotId ()Ljava/lang/String; public final fun getTimestamp ()J public final fun getType ()J public fun hashCode ()I @@ -583,15 +596,18 @@ public final class com/datadog/android/sessionreplay/model/MobileSegment$MobileR public final class com/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$MobileIncrementalSnapshotRecord : com/datadog/android/sessionreplay/model/MobileSegment$MobileRecord { public static final field Companion Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$MobileIncrementalSnapshotRecord$Companion; - public fun (JLcom/datadog/android/sessionreplay/model/MobileSegment$MobileIncrementalData;)V + public fun (JLcom/datadog/android/sessionreplay/model/MobileSegment$MobileIncrementalData;Ljava/lang/String;)V + public synthetic fun (JLcom/datadog/android/sessionreplay/model/MobileSegment$MobileIncrementalData;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()J public final fun component2 ()Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileIncrementalData; - public final fun copy (JLcom/datadog/android/sessionreplay/model/MobileSegment$MobileIncrementalData;)Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$MobileIncrementalSnapshotRecord; - public static synthetic fun copy$default (Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$MobileIncrementalSnapshotRecord;JLcom/datadog/android/sessionreplay/model/MobileSegment$MobileIncrementalData;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$MobileIncrementalSnapshotRecord; + public final fun component3 ()Ljava/lang/String; + public final fun copy (JLcom/datadog/android/sessionreplay/model/MobileSegment$MobileIncrementalData;Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$MobileIncrementalSnapshotRecord; + public static synthetic fun copy$default (Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$MobileIncrementalSnapshotRecord;JLcom/datadog/android/sessionreplay/model/MobileSegment$MobileIncrementalData;Ljava/lang/String;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$MobileIncrementalSnapshotRecord; public fun equals (Ljava/lang/Object;)Z public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$MobileIncrementalSnapshotRecord; public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$MobileIncrementalSnapshotRecord; public final fun getData ()Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileIncrementalData; + public final fun getSlotId ()Ljava/lang/String; public final fun getTimestamp ()J public final fun getType ()J public fun hashCode ()I @@ -958,6 +974,48 @@ public final class com/datadog/android/sessionreplay/model/MobileSegment$Wirefra public final fun fromJsonElement (Lcom/google/gson/JsonElement;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe; } +public final class com/datadog/android/sessionreplay/model/MobileSegment$Wireframe$EmbeddedContentWireframe : com/datadog/android/sessionreplay/model/MobileSegment$Wireframe { + public static final field Companion Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$EmbeddedContentWireframe$Companion; + public fun (JJJJJLcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder;Ljava/lang/String;Ljava/lang/Boolean;Ljava/lang/String;)V + public synthetic fun (JJJJJLcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder;Ljava/lang/String;Ljava/lang/Boolean;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()J + public final fun component10 ()Ljava/lang/Boolean; + public final fun component11 ()Ljava/lang/String; + public final fun component2 ()J + public final fun component3 ()J + public final fun component4 ()J + public final fun component5 ()J + public final fun component6 ()Lcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip; + public final fun component7 ()Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle; + public final fun component8 ()Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder; + public final fun component9 ()Ljava/lang/String; + public final fun copy (JJJJJLcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder;Ljava/lang/String;Ljava/lang/Boolean;Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$EmbeddedContentWireframe; + public static synthetic fun copy$default (Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$EmbeddedContentWireframe;JJJJJLcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder;Ljava/lang/String;Ljava/lang/Boolean;Ljava/lang/String;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$EmbeddedContentWireframe; + public fun equals (Ljava/lang/Object;)Z + public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$EmbeddedContentWireframe; + public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$EmbeddedContentWireframe; + public final fun getBorder ()Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder; + public final fun getClip ()Lcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip; + public final fun getHeight ()J + public final fun getId ()J + public final fun getPermanentId ()Ljava/lang/String; + public final fun getShapeStyle ()Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle; + public final fun getSlotId ()Ljava/lang/String; + public final fun getType ()Ljava/lang/String; + public final fun getWidth ()J + public final fun getX ()J + public final fun getY ()J + public fun hashCode ()I + public final fun isVisible ()Ljava/lang/Boolean; + public fun toJson ()Lcom/google/gson/JsonElement; + public fun toString ()Ljava/lang/String; +} + +public final class com/datadog/android/sessionreplay/model/MobileSegment$Wireframe$EmbeddedContentWireframe$Companion { + public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$EmbeddedContentWireframe; + public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$EmbeddedContentWireframe; +} + public final class com/datadog/android/sessionreplay/model/MobileSegment$Wireframe$ImageWireframe : com/datadog/android/sessionreplay/model/MobileSegment$Wireframe { public static final field Companion Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$ImageWireframe$Companion; public fun (JJJJJLcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Boolean;Ljava/lang/String;)V @@ -1210,6 +1268,46 @@ public final class com/datadog/android/sessionreplay/model/MobileSegment$Wirefra public final fun fromJsonElement (Lcom/google/gson/JsonElement;)Lcom/datadog/android/sessionreplay/model/MobileSegment$WireframeUpdateMutation; } +public final class com/datadog/android/sessionreplay/model/MobileSegment$WireframeUpdateMutation$EmbeddedContentWireframeUpdate : com/datadog/android/sessionreplay/model/MobileSegment$WireframeUpdateMutation { + public static final field Companion Lcom/datadog/android/sessionreplay/model/MobileSegment$WireframeUpdateMutation$EmbeddedContentWireframeUpdate$Companion; + public fun (JLjava/lang/Long;Ljava/lang/Long;Ljava/lang/Long;Ljava/lang/Long;Lcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder;Ljava/lang/String;Ljava/lang/Boolean;)V + public synthetic fun (JLjava/lang/Long;Ljava/lang/Long;Ljava/lang/Long;Ljava/lang/Long;Lcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder;Ljava/lang/String;Ljava/lang/Boolean;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()J + public final fun component10 ()Ljava/lang/Boolean; + public final fun component2 ()Ljava/lang/Long; + public final fun component3 ()Ljava/lang/Long; + public final fun component4 ()Ljava/lang/Long; + public final fun component5 ()Ljava/lang/Long; + public final fun component6 ()Lcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip; + public final fun component7 ()Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle; + public final fun component8 ()Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder; + public final fun component9 ()Ljava/lang/String; + public final fun copy (JLjava/lang/Long;Ljava/lang/Long;Ljava/lang/Long;Ljava/lang/Long;Lcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder;Ljava/lang/String;Ljava/lang/Boolean;)Lcom/datadog/android/sessionreplay/model/MobileSegment$WireframeUpdateMutation$EmbeddedContentWireframeUpdate; + public static synthetic fun copy$default (Lcom/datadog/android/sessionreplay/model/MobileSegment$WireframeUpdateMutation$EmbeddedContentWireframeUpdate;JLjava/lang/Long;Ljava/lang/Long;Ljava/lang/Long;Ljava/lang/Long;Lcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder;Ljava/lang/String;Ljava/lang/Boolean;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/model/MobileSegment$WireframeUpdateMutation$EmbeddedContentWireframeUpdate; + public fun equals (Ljava/lang/Object;)Z + public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$WireframeUpdateMutation$EmbeddedContentWireframeUpdate; + public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$WireframeUpdateMutation$EmbeddedContentWireframeUpdate; + public final fun getBorder ()Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder; + public final fun getClip ()Lcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip; + public final fun getHeight ()Ljava/lang/Long; + public final fun getId ()J + public final fun getShapeStyle ()Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle; + public final fun getSlotId ()Ljava/lang/String; + public final fun getType ()Ljava/lang/String; + public final fun getWidth ()Ljava/lang/Long; + public final fun getX ()Ljava/lang/Long; + public final fun getY ()Ljava/lang/Long; + public fun hashCode ()I + public final fun isVisible ()Ljava/lang/Boolean; + public fun toJson ()Lcom/google/gson/JsonElement; + public fun toString ()Ljava/lang/String; +} + +public final class com/datadog/android/sessionreplay/model/MobileSegment$WireframeUpdateMutation$EmbeddedContentWireframeUpdate$Companion { + public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$WireframeUpdateMutation$EmbeddedContentWireframeUpdate; + public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$WireframeUpdateMutation$EmbeddedContentWireframeUpdate; +} + public final class com/datadog/android/sessionreplay/model/MobileSegment$WireframeUpdateMutation$ImageWireframeUpdate : com/datadog/android/sessionreplay/model/MobileSegment$WireframeUpdateMutation { public static final field Companion Lcom/datadog/android/sessionreplay/model/MobileSegment$WireframeUpdateMutation$ImageWireframeUpdate$Companion; public fun (JLjava/lang/Long;Ljava/lang/Long;Ljava/lang/Long;Ljava/lang/Long;Lcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Boolean;)V diff --git a/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/embedded-content-wireframe-schema.json b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/embedded-content-wireframe-schema.json new file mode 100644 index 0000000000..f8ad37f617 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/embedded-content-wireframe-schema.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "session-replay/mobile/embedded-content-wireframe-schema.json", + "title": "EmbeddedContentWireframe", + "type": "object", + "description": "Schema of all properties of an EmbeddedContentWireframe.", + "allOf": [ + { + "$ref": "_common-shape-wireframe-schema.json" + }, + { + "required": ["type", "slotId"], + "properties": { + "type": { + "type": "string", + "description": "The type of the wireframe.", + "const": "embedded_content", + "readOnly": true + }, + "slotId": { + "type": "string", + "description": "Unique Id of the slot containing this embedded content.", + "readOnly": true + }, + "isVisible": { + "type": "boolean", + "description": "Whether this embedded content is visible or not.", + "readOnly": true + }, + "permanentId": { + "type": "string", + "description": "A globally unique and stable identifier for this UI element, computed as the hash of the element's path. Used to correlate wireframes with RUM action events.", + "readOnly": true + } + } + } + ] +} diff --git a/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/embedded-content-wireframe-update-schema.json b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/embedded-content-wireframe-update-schema.json new file mode 100644 index 0000000000..6b0fa9b772 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/embedded-content-wireframe-update-schema.json @@ -0,0 +1,33 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "session-replay/mobile/embedded-content-wireframe-update-schema.json", + "title": "EmbeddedContentWireframeUpdate", + "type": "object", + "description": "Schema of all properties of an EmbeddedContentWireframeUpdate.", + "allOf": [ + { + "$ref": "_common-shape-wireframe-update-schema.json" + }, + { + "required": ["type", "slotId"], + "properties": { + "type": { + "type": "string", + "description": "The type of the wireframe.", + "const": "embedded_content", + "readOnly": true + }, + "slotId": { + "type": "string", + "description": "Unique Id of the slot containing this embedded content.", + "readOnly": true + }, + "isVisible": { + "type": "boolean", + "description": "Whether this embedded content is visible or not.", + "readOnly": true + } + } + } + ] +} diff --git a/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/full-snapshot-record-schema.json b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/full-snapshot-record-schema.json index 26f708d2b2..1c91e3abc2 100644 --- a/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/full-snapshot-record-schema.json +++ b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/full-snapshot-record-schema.json @@ -31,6 +31,11 @@ "readOnly": true } } + }, + "slotId": { + "type": "string", + "description": "Unique ID of the slot that generated this record.", + "readOnly": true } } } diff --git a/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/incremental-snapshot-record-schema.json b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/incremental-snapshot-record-schema.json index 664e02708b..c3d814499b 100644 --- a/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/incremental-snapshot-record-schema.json +++ b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/incremental-snapshot-record-schema.json @@ -19,6 +19,11 @@ }, "data": { "$ref": "incremental-data-schema.json" + }, + "slotId": { + "type": "string", + "description": "Unique ID of the slot that generated this record.", + "readOnly": true } } } diff --git a/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/wireframe-schema.json b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/wireframe-schema.json index b3ba3a763f..d8d7b0dbdc 100644 --- a/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/wireframe-schema.json +++ b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/wireframe-schema.json @@ -19,6 +19,9 @@ }, { "$ref": "webview-wireframe-schema.json" + }, + { + "$ref": "embedded-content-wireframe-schema.json" } ] } diff --git a/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/wireframe-update-mutation-schema.json b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/wireframe-update-mutation-schema.json index 65f921351a..10f3eebb96 100644 --- a/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/wireframe-update-mutation-schema.json +++ b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/wireframe-update-mutation-schema.json @@ -19,6 +19,9 @@ }, { "$ref": "webview-wireframe-update-schema.json" + }, + { + "$ref": "embedded-content-wireframe-update-schema.json" } ] } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/MobileSegmentConstants.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/MobileSegmentConstants.kt index 1f5ad7415d..f63f86e62f 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/MobileSegmentConstants.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/MobileSegmentConstants.kt @@ -75,3 +75,9 @@ const val WIREFRAME_TYPE_PLACEHOLDER: String = "placeholder" * @see com.datadog.android.sessionreplay.model.MobileSegment.Wireframe.WebviewWireframe */ const val WIREFRAME_TYPE_WEBVIEW: String = "webview" + +/** + * EmbeddedContentWireframe type. + * @see com.datadog.android.sessionreplay.model.MobileSegment.Wireframe.EmbeddedContentWireframe + */ +const val WIREFRAME_TYPE_EMBEDDED_CONTENT: String = "embedded_content" 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..3a85e88583 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,108 @@ 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 + ) + 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..5269340a25 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,19 +26,29 @@ 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 import com.datadog.android.sessionreplay.recorder.OptionSelectorDetector import com.datadog.android.sessionreplay.utils.DrawableToColorMapper import java.util.Locale +import java.util.concurrent.ExecutorService import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference @@ -58,6 +69,7 @@ internal class SessionReplayFeature( ) : StorageBackedFeature, FeatureEventReceiver { private val currentRumSessionId = AtomicReference() + internal val embeddedContentSlotRegistry = EmbeddedContentSlotRegistry() @Suppress("LongParameterList") internal constructor( @@ -121,7 +133,23 @@ 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 lateinit var embeddedContentExecutor: ExecutorService + private val embeddedContentReceiver = EmbeddedContentReceiver( + rumContextProvider = rumContextProvider, + recordWriter = { embeddedContentRecordWriter }, + resourceProcessor = { resourceProcessor }, + isRecording = { isRecording.get() }, + executor = { embeddedContentExecutor }, + requestCapture = { slotId -> sessionReplayRecorder.requestCapture(setOf(slotId)) }, + embeddedContentSlotRegistry = embeddedContentSlotRegistry, + internalLogger = sdkCore.internalLogger + ) // region Feature @@ -134,6 +162,9 @@ internal class SessionReplayFeature( } this.appContext = appContext + embeddedContentExecutor = sdkCore.createSingleThreadExecutorService( + EMBEDDED_CONTENT_EXECUTOR_CONTEXT + ) sdkCore.setEventReceiver(Feature.SESSION_REPLAY_FEATURE_NAME, this) val resourcesFeature = registerResourceFeature(sdkCore) @@ -144,7 +175,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 +189,8 @@ internal class SessionReplayFeature( resourceWriter = resourcesFeature.dataWriter, recordWriter = dataWriter, rumContextProvider = rumContextProvider, - application = appContext + application = appContext, + embeddedContentSlotRegistry = embeddedContentSlotRegistry ) sessionReplayRecorder.registerCallbacks() initialized.set(true) @@ -181,7 +219,12 @@ internal class SessionReplayFeature( sdkCore.removeContextUpdateReceiver(rumContextProvider) sessionReplayRecorder.unregisterCallbacks() sessionReplayRecorder.stopProcessingRecords() + if (::embeddedContentExecutor.isInitialized) { + embeddedContentExecutor.shutdown() + } dataWriter = NoOpRecordWriter() + embeddedContentRecordWriter = NoOpEmbeddedContentRecordWriter() + resourceProcessor = NoOpResourceProcessor() sessionReplayRecorder = NoOpRecorder() initialized.set(false) } @@ -190,21 +233,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 +422,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 +445,16 @@ internal class SessionReplayFeature( } } + private fun onRumViewChanged() { + if (!isRecording.get()) { + return + } + val activeSlotIds = embeddedContentSlotRegistry.activeSlotIds() + if (activeSlotIds.isNotEmpty()) { + sessionReplayRecorder.requestCapture(activeSlotIds) + } + } + // endregion // region resourcesFeature @@ -399,6 +473,8 @@ internal class SessionReplayFeature( internal companion object { + internal const val EMBEDDED_CONTENT_EXECUTOR_CONTEXT = "sr-embedded-content" + /** * Session Replay storage configuration with the following parameters: * max item size = 10 MB, 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..b37a17caa7 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,14 +34,23 @@ 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 rumContext = context + if (newViewId != null && newViewId != NULL_UUID && newViewId != previousViewId) { + onRumViewChanged() + } } } 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..f332290c5e --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/embedded/EmbeddedContentReceiver.kt @@ -0,0 +1,239 @@ +/* + * 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.core.internal.utils.executeSafe +import com.datadog.android.internal.collections.EvictingQueue +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.JsonArray +import com.google.gson.JsonObject +import java.util.concurrent.Executor + +@Suppress("TooManyFunctions") +internal class EmbeddedContentReceiver( + private val rumContextProvider: RumContextProvider, + private val recordWriter: () -> EmbeddedContentRecordWriter, + private val resourceProcessor: () -> ResourceProcessor, + private val isRecording: () -> Boolean, + private val executor: () -> Executor, + private val requestCapture: (String) -> Unit, + private val embeddedContentSlotRegistry: EmbeddedContentSlotRegistry, + private val internalLogger: InternalLogger +) { + private val timeline = EmbeddedRecordTimeline(embeddedContentSlotRegistry) + + /** + * Batches for slots that have no placeholder yet, keyed by slot ID. + * + * Bounded twice over — [MAX_PENDING_BATCHES] per slot, [MAX_PENDING_SLOTS] slots — because a + * placeholder may never come. What the bounds displace is written rather than dropped: a + * mis-ordered record beats a missing one. + */ + private val pendingBatches = mutableMapOf>() + + init { + embeddedContentSlotRegistry.addPlaceholderListener { slotId -> + releasePendingBatches(slotId) + } + embeddedContentSlotRegistry.addSnapshotListener { drawnSlotIds -> + flushAbandonedSlots(drawnSlotIds) + } + } + + fun receive(event: EmbeddedContentEvent) { + if (!isRecording()) { + return + } + when (event) { + is EmbeddedContentEvent.RecordBatch -> handleRecordBatch(event) + + is EmbeddedContentEvent.Resource -> { + if (rumContextProvider.getRumContext().hasValidApplicationAndSession()) { + val scheduledProcessor = resourceProcessor() + executor().executeSafe(PROCESS_EVENT_TASK_NAME, internalLogger) { + processResource(event, scheduledProcessor) + } + } + } + } + } + + private fun handleRecordBatch(event: EmbeddedContentEvent.RecordBatch) { + if (event.records.isEmpty()) { + return + } + val rumContext = rumContextProvider.getRumContext() + if (!rumContext.isValid()) { + return + } + val pending = PendingBatch(event, rumContext, recordWriter()) + if (timeline.placeholderCovers(event)) { + write(pending, timeline.shiftFor(pending)) + } else { + hold(pending) + } + } + + private fun hold(pending: PendingBatch) { + val slotId = pending.event.slotId + val isFirstHeld: Boolean + val displaced = synchronized(pendingBatches) { + isFirstHeld = (pendingBatches[slotId]?.size ?: 0) == 0 + enqueue(pending) + } + writeAll(displaced) + // The registry notifies its listeners outside the lock above, so a placeholder can appear + // between the check and the enqueue with no listener left to fire. Checking again covers + // that window; whichever path removes the queue is the one that writes it. + if (timeline.placeholderCovers(pending.event)) { + releasePendingBatches(slotId) + } else if (isFirstHeld) { + // Nothing else is guaranteed to draw the placeholder these batches are waiting for: the + // host may be idle behind the embedded surface, and a draw that registering the slot did + // schedule can still be skipped by the recorder's frame budget. Asking here rather than + // when the slot is registered keys the request to the invariant actually being broken, + // whatever broke it. Only for the batch that starts the queue — until it is released, + // the queue stays non-empty, so this is one request per slot per view. + requestCapture(slotId) + } + } + + /** Holds [pending] and returns whatever the bounds displaced to make room for it. */ + private fun enqueue(pending: PendingBatch): List { + val displaced = mutableListOf() + val slotId = pending.event.slotId + if (pendingBatches.size >= MAX_PENDING_SLOTS && !pendingBatches.containsKey(slotId)) { + pendingBatches.keys.firstOrNull() + ?.let { pendingBatches.remove(it) } + ?.let { displaced.addAll(it) } + } + val queue = pendingBatches.getOrPut(slotId) { EvictingQueue(MAX_PENDING_BATCHES) } + if (queue.size >= MAX_PENDING_BATCHES) { + // What EvictingQueue is about to evict, taken before add() discards it. + queue.peek()?.let { displaced.add(it) } + } + queue.add(pending) + return displaced + } + + /** + * Writes out the batches of slots that will never get a placeholder, given a snapshot carrying + * [drawnSlotIds]. + * + * A slot missing from that set is not necessarily gone — it may be laying out, or scrolled off + * screen with its placeholder still to come — so absence alone is not the test. A slot that is + * also no longer registered has been torn down, and nothing will draw it again; holding its + * batches until the bounds happen to displace them only delays the same unshifted write. + */ + private fun flushAbandonedSlots(drawnSlotIds: Set) { + val activeSlotIds = embeddedContentSlotRegistry.activeSlotIds() + val abandoned = synchronized(pendingBatches) { + pendingBatches.keys + .filter { it !in drawnSlotIds && it !in activeSlotIds } + .mapNotNull { pendingBatches.remove(it) } + .flatten() + } + writeAll(abandoned) + } + + private fun releasePendingBatches(slotId: String) { + val released = synchronized(pendingBatches) { + pendingBatches.remove(slotId) + } ?: return + writeAll(released) + } + + /** + * Writes [batches], one shift per slot and view: batches held across a view change are not on + * the same timeline as each other, and each view has its own placeholder and so its own floor. + */ + private fun writeAll(batches: Collection) { + batches.groupBy { it.event.slotId to it.event.viewId }.forEach { (_, group) -> + val shift = timeline.shiftFor(group) + group.forEach { write(it, shift) } + } + } + + private fun write(pending: PendingBatch, shift: Long) { + executor().executeSafe(PROCESS_EVENT_TASK_NAME, internalLogger) { + processRecordBatch(pending.event, pending.rumContext, pending.writer, shift) + } + } + + private fun processRecordBatch( + event: EmbeddedContentEvent.RecordBatch, + rumContext: SessionReplayRumContext, + writer: EmbeddedContentRecordWriter, + shift: Long + ) { + val records = JsonArray() + .also { array -> + event.records.forEach { record -> + val jsonRecord = JsonSerializer.toJsonElement(record) + if (jsonRecord is JsonObject) { + jsonRecord.addProperty(RECORD_SLOT_ID_KEY, event.slotId) + timeline.correctTimestamp(jsonRecord, record, rumContext.viewTimeOffsetMs, shift) + 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) + } + writer.writeRaw( + enrichedRecord.toString().toByteArray(Charsets.UTF_8), + event.viewId, + records.size() + ) + } + + private fun processResource( + event: EmbeddedContentEvent.Resource, + processor: ResourceProcessor + ) { + processor.process(event.identifier, event.data, event.mimeType) + } + + private fun logInvalidRecord() { + internalLogger.log( + InternalLogger.Level.WARN, + InternalLogger.Target.MAINTAINER, + { INVALID_EMBEDDED_RECORD_MESSAGE } + ) + } + + companion object { + // Enriched record envelope keys use the storage schema naming convention. + 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" + + // Embedded records use the Session Replay record schema naming convention. + internal const val RECORD_SLOT_ID_KEY = "slotId" + internal const val MAX_PENDING_BATCHES = 20 + internal const val MAX_PENDING_SLOTS = 10 + internal const val INVALID_EMBEDDED_RECORD_MESSAGE = + "Session Replay received an invalid embedded content record." + internal const val PROCESS_EVENT_TASK_NAME = "Embedded Session Replay event processing" + } +} 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..c82a100258 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/embedded/EmbeddedContentSlotRegistry.kt @@ -0,0 +1,143 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.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() +} + +@Suppress("TooManyFunctions") +internal class EmbeddedContentSlotRegistry { + private val registrations = mutableListOf>() + + /** The placeholder wireframe standing in for each slot, keyed by slot ID. */ + private val placeholders = mutableMapOf() + private val placeholderListeners = mutableListOf<(String) -> Unit>() + private val snapshotListeners = mutableListOf<(Set) -> Unit>() + + internal data class Placeholder(val viewId: String, val timestamp: Long) + + @AnyThread + fun placeholder(slotId: String): Placeholder? = synchronized(placeholders) { + placeholders[slotId] + } + + /** + * Records the placeholders a snapshot written for [viewId] at [timestamp] carries, as [slotIds]. + * + * [slotIds] is the complete set drawn at that moment, not an addition to it, so a slot absent + * from it has no placeholder any more. A repeat in the same view keeps the original timestamp — + * the first placeholder in a view is the one records have to follow — so listeners fire only for + * a slot's first placeholder in a view. + */ + @AnyThread + fun onPlaceholdersWritten(viewId: String, timestamp: Long, slotIds: Set) { + val newlyPlaced = synchronized(placeholders) { + placeholders.keys.retainAll(slotIds) + slotIds.filter { slotId -> + val known = placeholders[slotId] + if (known?.viewId == viewId) { + false + } else { + placeholders[slotId] = Placeholder(viewId, timestamp) + true + } + } + } + val (placeholderNotified, snapshotNotified) = synchronized(placeholders) { + placeholderListeners.toList() to snapshotListeners.toList() + } + newlyPlaced.forEach { slotId -> placeholderNotified.forEach { it(slotId) } } + // Fired for every snapshot, including one that placed nothing new: a listener waiting on a + // slot learns from the snapshots that leave it out, not from the ones that include it. + snapshotNotified.forEach { it(slotIds) } + } + + @AnyThread + fun addPlaceholderListener(listener: (String) -> Unit) { + synchronized(placeholders) { + placeholderListeners.add(listener) + } + } + + /** + * Registers [listener] to receive the complete slot set of every snapshot that reports + * placeholders, whether or not any of them are new. + */ + @AnyThread + fun addSnapshotListener(listener: (Set) -> Unit) { + synchronized(placeholders) { + snapshotListeners.add(listener) + } + } + + @AnyThread + fun hasMarkedSlots(): Boolean = activeSlotIds().isNotEmpty() + + @AnyThread + fun isSlotMarked(slotId: String): Boolean = slotId in activeSlotIds() + + @AnyThread + fun activeSlotIds(): Set { + return synchronized(registrations) { + removeInactiveRegistrations() + registrations.mapNotNullTo(mutableSetOf()) { it.get()?.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) { + registrations += WeakReference(registration) + } + } + + 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/embedded/EmbeddedRecordTimeline.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/embedded/EmbeddedRecordTimeline.kt new file mode 100644 index 0000000000..a02f697652 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/embedded/EmbeddedRecordTimeline.kt @@ -0,0 +1,83 @@ +/* + * 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.internal.storage.EmbeddedContentRecordWriter +import com.datadog.android.sessionreplay.internal.utils.SessionReplayRumContext +import com.google.gson.JsonObject + +/** A received batch, with the context and writer it was received under. */ +internal class PendingBatch( + val event: EmbeddedContentEvent.RecordBatch, + val rumContext: SessionReplayRumContext, + val writer: EmbeddedContentRecordWriter +) + +/** Puts embedded records on the same timeline as the native records they are replayed alongside. */ +internal class EmbeddedRecordTimeline( + private val embeddedContentSlotRegistry: EmbeddedContentSlotRegistry +) { + /** + * Whether the slot's placeholder has been emitted in the same view [event] is addressed to. The + * view matters as much as the slot: records only composite into a placeholder that shares their + * segment, so one left over from a previous view is no help to this batch. + */ + fun placeholderCovers(event: EmbeddedContentEvent.RecordBatch): Boolean = + embeddedContentSlotRegistry.placeholder(event.slotId)?.viewId == event.viewId + + fun shiftFor(batch: PendingBatch): Long = shiftFor(batch.event, batch.earliestTimestamp()) + + fun shiftFor(batches: Collection): Long { + val event = batches.firstOrNull()?.event ?: return NO_SHIFT + return shiftFor(event, batches.mapNotNull { it.earliestTimestamp() }.minOrNull()) + } + + /** Applies the view's server time offset, which native records carry and these do not, then [shift]. */ + fun correctTimestamp( + jsonRecord: JsonObject, + record: Map, + viewTimeOffsetMs: Long, + shift: Long + ) { + val timestamp = record.timestampOrNull() ?: return + jsonRecord.addProperty(RECORD_TIMESTAMP_KEY, timestamp + viewTimeOffsetMs + shift) + } + + /** + * How far forward records move so the earliest of them clears the slot's placeholder, or + * [NO_SHIFT] when they already do — the steady-state case. + * + * One shift for the whole set rather than a floor applied to each record: clamping pins + * everything captured before the placeholder to the same millisecond, so a gesture spread over + * several frames replays as a single jump. Moving the set keeps every original interval intact. + */ + private fun shiftFor(event: EmbeddedContentEvent.RecordBatch, earliest: Long?): Long { + val placeholder = embeddedContentSlotRegistry.placeholder(event.slotId) + ?.takeIf { it.viewId == event.viewId } + if (placeholder == null || earliest == null) { + return NO_SHIFT + } + return maxOf(NO_SHIFT, placeholder.timestamp + 1 - earliest) + } + + private fun PendingBatch.earliestTimestamp(): Long? = event.records + .mapNotNull { record -> record.timestampOrNull()?.let { it + rumContext.viewTimeOffsetMs } } + .minOrNull() + + private fun Map.timestampOrNull(): Long? = + when (val timestamp = this[RECORD_TIMESTAMP_KEY]) { + is Number -> timestamp.toLong() + is String -> timestamp.toLongOrNull() + else -> null + } + + companion object { + // Embedded records use the Session Replay record schema naming convention. + internal const val RECORD_TIMESTAMP_KEY = "timestamp" + internal const val NO_SHIFT = 0L + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/BoundsUtils.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/BoundsUtils.kt index 7df20d312b..531773fed1 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/BoundsUtils.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/BoundsUtils.kt @@ -17,6 +17,7 @@ internal object BoundsUtils { is MobileSegment.Wireframe.ImageWireframe -> wireframe.bounds() is MobileSegment.Wireframe.PlaceholderWireframe -> wireframe.bounds() is MobileSegment.Wireframe.WebviewWireframe -> wireframe.bounds() + is MobileSegment.Wireframe.EmbeddedContentWireframe -> wireframe.bounds() } } @@ -48,6 +49,10 @@ internal object BoundsUtils { return resolveBounds(x, y, width, height, clip) } + private fun MobileSegment.Wireframe.EmbeddedContentWireframe.bounds(): WireframeBounds { + return resolveBounds(x, y, width, height, clip) + } + private fun resolveBounds( x: Long, y: Long, diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/HeatmapWireframeExt.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/HeatmapWireframeExt.kt index af869514ed..d13489398c 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/HeatmapWireframeExt.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/HeatmapWireframeExt.kt @@ -15,6 +15,7 @@ internal fun MobileSegment.Wireframe.permanentId(): String? { is MobileSegment.Wireframe.ImageWireframe -> this.permanentId is MobileSegment.Wireframe.PlaceholderWireframe -> this.permanentId is MobileSegment.Wireframe.WebviewWireframe -> this.permanentId + is MobileSegment.Wireframe.EmbeddedContentWireframe -> this.permanentId } } @@ -27,5 +28,6 @@ internal fun MobileSegment.Wireframe.copyWithPermanentId( is MobileSegment.Wireframe.ImageWireframe -> this.copy(permanentId = permanentId) is MobileSegment.Wireframe.PlaceholderWireframe -> this.copy(permanentId = permanentId) is MobileSegment.Wireframe.WebviewWireframe -> this.copy(permanentId = permanentId) + is MobileSegment.Wireframe.EmbeddedContentWireframe -> this.copy(permanentId = permanentId) } } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/MobileSegmentExt.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/MobileSegmentExt.kt index 554f7e7070..97c8ff25ee 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/MobileSegmentExt.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/MobileSegmentExt.kt @@ -16,6 +16,7 @@ internal fun MobileSegment.Wireframe.copy(clip: MobileSegment.WireframeClip?): M is MobileSegment.Wireframe.ImageWireframe -> this.copy(clip = clip) is MobileSegment.Wireframe.PlaceholderWireframe -> this.copy(clip = clip) is MobileSegment.Wireframe.WebviewWireframe -> this.copy(clip = clip) + is MobileSegment.Wireframe.EmbeddedContentWireframe -> this.copy(clip = clip) } } 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 39c671c831..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 @@ -345,6 +345,53 @@ internal class MutationResolver(private val internalLogger: InternalLogger) { return mutation } + private fun resolveEmbeddedContentWireframeMutation( + prevWireframe: MobileSegment.Wireframe.EmbeddedContentWireframe, + currentWireframe: MobileSegment.Wireframe.EmbeddedContentWireframe + ): MobileSegment.WireframeUpdateMutation? { + var mutation = MobileSegment.WireframeUpdateMutation + .EmbeddedContentWireframeUpdate(currentWireframe.id, slotId = currentWireframe.slotId) + var hasMutation = prevWireframe.slotId != currentWireframe.slotId + if (prevWireframe.x != currentWireframe.x) { + mutation = mutation.copy(x = currentWireframe.x) + hasMutation = true + } + if (prevWireframe.y != currentWireframe.y) { + mutation = mutation.copy(y = currentWireframe.y) + hasMutation = true + } + if (prevWireframe.width != currentWireframe.width) { + mutation = mutation.copy(width = currentWireframe.width) + hasMutation = true + } + if (prevWireframe.height != currentWireframe.height) { + mutation = mutation.copy(height = currentWireframe.height) + hasMutation = true + } + if (prevWireframe.border != currentWireframe.border) { + mutation = mutation.copy(border = currentWireframe.border) + hasMutation = true + } + if (prevWireframe.shapeStyle != currentWireframe.shapeStyle) { + mutation = mutation.copy(shapeStyle = currentWireframe.shapeStyle) + hasMutation = true + } + if (prevWireframe.clip != currentWireframe.clip) { + mutation = mutation.copy( + clip = currentWireframe.clip + ?: MobileSegment.WireframeClip(0, 0, 0, 0) + ) + hasMutation = true + } + val wasVisible = prevWireframe.isVisible != false + val isVisible = currentWireframe.isVisible != false + if (wasVisible != isVisible) { + mutation = mutation.copy(isVisible = isVisible) + hasMutation = true + } + return mutation.takeIf { hasMutation } + } + private fun recordChangedWireframeMutations( oldElement: MobileSegment.Wireframe, newElement: MobileSegment.Wireframe, @@ -414,6 +461,11 @@ internal class MutationResolver(private val internalLogger: InternalLogger) { prevWireframe, currentWireframe as MobileSegment.Wireframe.WebviewWireframe ) + + is MobileSegment.Wireframe.EmbeddedContentWireframe -> resolveEmbeddedContentWireframeMutation( + prevWireframe, + currentWireframe as MobileSegment.Wireframe.EmbeddedContentWireframe + ) } } } @@ -472,6 +524,7 @@ internal class MutationResolver(private val internalLogger: InternalLogger) { is MobileSegment.Wireframe.ImageWireframe -> this.id is MobileSegment.Wireframe.PlaceholderWireframe -> this.id is MobileSegment.Wireframe.WebviewWireframe -> this.id + is MobileSegment.Wireframe.EmbeddedContentWireframe -> this.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 65fd6adef4..c8b1331876 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 @@ -12,6 +12,7 @@ import com.datadog.android.internal.time.TimeProvider import com.datadog.android.sessionreplay.internal.async.ResourceRecordedDataQueueItem import com.datadog.android.sessionreplay.internal.async.SnapshotRecordedDataQueueItem import com.datadog.android.sessionreplay.internal.async.TouchEventRecordedDataQueueItem +import com.datadog.android.sessionreplay.internal.embedded.EmbeddedContentSlotRegistry import com.datadog.android.sessionreplay.internal.recorder.Node import com.datadog.android.sessionreplay.internal.resources.ResourceDataStoreManager import com.datadog.android.sessionreplay.internal.storage.RecordWriter @@ -24,12 +25,17 @@ 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 embeddedContentSlotRegistry: EmbeddedContentSlotRegistry, + private val nodeFlattener: NodeFlattener = NodeFlattener(), + private val resourceProcessor: ResourceProcessor = DefaultResourceProcessor( + resourceDataStoreManager, + resourcesWriter + ) ) : Processor { private var prevSnapshot: List = emptyList() private var lastSnapshotTimestamp = 0L @@ -40,24 +46,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 @@ -142,27 +131,58 @@ internal class RecordedDataProcessor( if (fullSnapshotRequired) { records.add( MobileSegment.MobileRecord.MobileFullSnapshotRecord( - timestamp, - MobileSegment.Data(wireframes) + timestamp = timestamp, + data = MobileSegment.Data(wireframes) ) ) } else { mutationResolver.resolveMutations(prevSnapshot, wireframes)?.let { records.add( MobileSegment.MobileRecord.MobileIncrementalSnapshotRecord( - timestamp, - it + timestamp = timestamp, + data = it ) ) } } prevSnapshot = wireframes previousOrientation = systemInformation.screenOrientation - if (records.isNotEmpty()) { - writer.write(bundleRecordInEnrichedRecord(newRumContext, records)) + writeSnapshot(newRumContext, timestamp, records, wireframes) + } + + @WorkerThread + private fun writeSnapshot( + newRumContext: SessionReplayRumContext, + timestamp: Long, + records: List, + wireframes: List + ) { + if (records.isEmpty()) { + return + } + writer.write(bundleRecordInEnrichedRecord(newRumContext, records)) { + reportPlaceholders(newRumContext.viewId, timestamp, wireframes) } } + /** + * Reported only once the snapshot has actually been persisted, so that nothing is ever released + * against a placeholder that never landed: a rejected write would otherwise leave the registry + * claiming a placeholder for this view, and every later batch for that slot would be written + * unshifted into a segment with nothing to composite into. + */ + private fun reportPlaceholders( + viewId: String, + timestamp: Long, + wireframes: List + ) { + val visibleSlotIds = wireframes + .filterIsInstance() + .filter { it.isVisible == true } + .mapTo(mutableSetOf()) { it.slotId } + embeddedContentSlotRegistry.onPlaceholdersWritten(viewId, timestamp, visibleSlotIds) + } + private fun isTimeForFullSnapshot(): Boolean { return if (timeProvider.getDeviceElapsedTimeNanos() - lastSnapshotTimestamp >= FULL_SNAPSHOT_INTERVAL_IN_NS) { lastSnapshotTimestamp = timeProvider.getDeviceElapsedTimeNanos() 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 d0460c4f28..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 && @@ -78,6 +84,7 @@ internal class WireframeUtils(private val boundsUtils: BoundsUtils = BoundsUtils is MobileSegment.Wireframe.ImageWireframe -> this.clip is MobileSegment.Wireframe.PlaceholderWireframe -> this.clip is MobileSegment.Wireframe.WebviewWireframe -> this.clip + is MobileSegment.Wireframe.EmbeddedContentWireframe -> this.clip } } } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/DefaultOnDrawListenerProducer.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/DefaultOnDrawListenerProducer.kt index 7fe3f2af9e..76d853beca 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/DefaultOnDrawListenerProducer.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/DefaultOnDrawListenerProducer.kt @@ -7,7 +7,6 @@ package com.datadog.android.sessionreplay.internal.recorder import android.view.View -import android.view.ViewTreeObserver import com.datadog.android.api.feature.FeatureSdkCore import com.datadog.android.core.metrics.MethodCallSamplingRate import com.datadog.android.sessionreplay.ImagePrivacy @@ -30,7 +29,7 @@ internal class DefaultOnDrawListenerProducer( textAndInputPrivacy: TextAndInputPrivacy, imagePrivacy: ImagePrivacy, touchPrivacyManager: TouchPrivacyManager - ): ViewTreeObserver.OnDrawListener { + ): OnDemandCaptureListener { return WindowsOnDrawListener( zOrderedDecorViews = decorViews, recordedDataQueueHandler = recordedDataQueueHandler, diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/OnDemandCaptureListener.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/OnDemandCaptureListener.kt new file mode 100644 index 0000000000..bdf95edcd1 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/OnDemandCaptureListener.kt @@ -0,0 +1,23 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.recorder + +import android.view.ViewTreeObserver +import androidx.annotation.MainThread + +/** + * A draw listener that can also be asked for a snapshot outside of a draw pass. + */ +internal interface OnDemandCaptureListener : ViewTreeObserver.OnDrawListener { + /** + * Takes a snapshot right away, without going through the debouncer: a requested capture is not + * a draw, and the frame skipping the debouncer applies to draws would silently drop it. + * @return whether a snapshot was in fact taken and queued. + */ + @MainThread + fun captureNow(): Boolean +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/OnDrawListenerProducer.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/OnDrawListenerProducer.kt index 6bba481d39..6c4f6fa5be 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/OnDrawListenerProducer.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/OnDrawListenerProducer.kt @@ -7,7 +7,6 @@ package com.datadog.android.sessionreplay.internal.recorder import android.view.View -import android.view.ViewTreeObserver import com.datadog.android.sessionreplay.ImagePrivacy import com.datadog.android.sessionreplay.TextAndInputPrivacy import com.datadog.android.sessionreplay.internal.TouchPrivacyManager @@ -18,5 +17,5 @@ internal fun interface OnDrawListenerProducer { textAndInputPrivacy: TextAndInputPrivacy, imagePrivacy: ImagePrivacy, touchPrivacyManager: TouchPrivacyManager - ): ViewTreeObserver.OnDrawListener + ): OnDemandCaptureListener } 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..33e0acdc05 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(slotIds: Set) + 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..926e67c1e3 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 @@ -57,6 +59,7 @@ import com.datadog.android.sessionreplay.utils.DrawableToColorMapper import com.datadog.android.sessionreplay.utils.ViewBoundsResolver import com.datadog.android.sessionreplay.utils.ViewIdentifierResolver import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.atomic.AtomicBoolean internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { @@ -75,6 +78,18 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { private val windowFromDecorView: (View) -> Window? private var shouldRecord = false + /** Whether a capture has been asked for and not yet honoured — see [requestCapture]. */ + private val captureRequested = AtomicBoolean(false) + + /** + * The slots a standing capture request still owes a placeholder, guarded by itself. + * + * A request is satisfied by the placeholders reaching storage, not by a snapshot being taken: + * the queue can drop the snapshot before it is processed, the write can fail, and the slot can + * be absent from the tree the traversal walked. Each leaves the request outstanding. + */ + private val slotsAwaitingPlaceholder = mutableSetOf() + @Suppress("LongParameterList") constructor( appContext: Application, @@ -93,6 +108,7 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { resourceDataStoreManager: ResourceDataStoreManager, dynamicOptimizationEnabled: Boolean, internalCallback: SessionReplayInternalCallback, + embeddedContentSlotRegistry: EmbeddedContentSlotRegistry, heatmapIdentifierRegistry: HeatmapIdentifierRegistry? = null ) { val internalLogger = sdkCore.internalLogger @@ -107,7 +123,8 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { resourcesWriter, recordWriter, MutationResolver(internalLogger), - timeProvider + timeProvider, + embeddedContentSlotRegistry ) this.appContext = appContext @@ -138,6 +155,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 +206,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 +221,8 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { registry = it, internalLogger = internalLogger ) - } + }, + embeddedContentViewMapper = embeddedContentViewMapper ), recordedDataQueueHandler = recordedDataQueueHandler, sdkCore = sdkCore, @@ -227,6 +255,7 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { this.uiHandler = Handler(Looper.getMainLooper()) this.internalLogger = internalLogger this.windowFromDecorView = { WindowReflectionUtils.getWindowFromDecorView(it, internalLogger) } + embeddedContentSlotRegistry.addPlaceholderListener(::onPlaceholderWritten) } @VisibleForTesting @@ -244,6 +273,7 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { resourceResolver: ResourceResolver, uiHandler: Handler, internalLogger: InternalLogger, + embeddedContentSlotRegistry: EmbeddedContentSlotRegistry = EmbeddedContentSlotRegistry(), windowFromDecorView: (View) -> Window? = { WindowReflectionUtils.getWindowFromDecorView(it, internalLogger) } @@ -261,6 +291,7 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { this.uiHandler = uiHandler this.windowFromDecorView = windowFromDecorView this.internalLogger = internalLogger + embeddedContentSlotRegistry.addPlaceholderListener(::onPlaceholderWritten) } override fun stopProcessingRecords() { @@ -280,13 +311,91 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { override fun resumeRecorders() { uiHandler.post { shouldRecord = true - val windows = sessionReplayLifecycleCallback.getCurrentWindows() - val decorViews = windowInspector.getGlobalWindowViews(internalLogger) - windowCallbackInterceptor.intercept(windows + resolveUntrackedWindows(decorViews, windows), appContext) - viewOnDrawInterceptor.intercept(decorViews, textAndInputPrivacy, imagePrivacy) + @Suppress("ThreadSafety") // handler posts to the main looper + interceptCurrentWindows(sessionReplayLifecycleCallback.getCurrentWindows()) } } + /** + * A capture request is a standing obligation rather than a single attempt: the placeholder + * wireframe for each of [slotIds] has to reach the player before the embedded records that + * composite into it, and records are replayed in timestamp order, not write order. Dropping the + * request because the recorder cannot serve it yet would leave those records waiting on a + * placeholder that never arrives, so it survives until the placeholders are actually written. + */ + override fun requestCapture(slotIds: Set) { + synchronized(slotsAwaitingPlaceholder) { + slotsAwaitingPlaceholder.addAll(slotIds) + } + captureRequested.set(true) + uiHandler.post { + @Suppress("ThreadSafety") // handler posts to the main looper + performRequestedCapture(attempt = 0) + } + } + + /** Clears the request once every slot it was made for has its placeholder in storage. */ + private fun onPlaceholderWritten(slotId: String) { + val isSatisfied = synchronized(slotsAwaitingPlaceholder) { + slotsAwaitingPlaceholder.remove(slotId) + slotsAwaitingPlaceholder.isEmpty() + } + if (isSatisfied) { + captureRequested.set(false) + } + } + + @MainThread + private fun performRequestedCapture(attempt: Int) { + if (!shouldRecord || !captureRequested.get()) { + return + } + when (viewOnDrawInterceptor.requestCapture()) { + // Taking the snapshot does not discharge the request — only the placeholder reaching + // storage does, which happens asynchronously and may not happen at all. Retrying until + // then also drives recovery: each capture drains the processing queue, which is what + // an expired snapshot left stuck behind. + ViewOnDrawInterceptor.CaptureRequestResult.CAPTURED -> Unit + + ViewOnDrawInterceptor.CaptureRequestResult.NOT_INTERCEPTING -> { + // The request arrived before this activity's window existed. Intercepting registers + // the listeners and serves the request itself. + interceptCurrentWindows(sessionReplayLifecycleCallback.getCurrentWindows()) + } + + // Either no window survived, or the queue refused the item. Neither clears within this + // frame, so the request is given a few further chances rather than being left to + // whatever draw or batch happens to come along next. + ViewOnDrawInterceptor.CaptureRequestResult.NOT_CAPTURED -> Unit + } + if (!captureRequested.get()) { + return + } + if (attempt < MAX_CAPTURE_ATTEMPTS) { + uiHandler.postDelayed( + { + @Suppress("ThreadSafety") // handler posts to the main looper + performRequestedCapture(attempt + 1) + }, + // Doubling each time, so the last attempts fall outside the window in which the + // processing queue discards an item it could not consume in time. + CAPTURE_RETRY_DELAY_IN_MS shl attempt + ) + } else { + // Out of attempts. The slots are released rather than left pending so that a later + // request is not immediately satisfied by a placeholder owed to this one; the batches + // waiting on them are written by the receiver's own bounds. + abandonCaptureRequest() + } + } + + private fun abandonCaptureRequest() { + synchronized(slotsAwaitingPlaceholder) { + slotsAwaitingPlaceholder.clear() + } + captureRequested.set(false) + } + override fun stopRecorders() { uiHandler.post { viewOnDrawInterceptor.stopIntercepting() @@ -298,9 +407,23 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { @MainThread override fun onWindowsAdded(windows: List) { if (shouldRecord) { - val decorViews = windowInspector.getGlobalWindowViews(internalLogger) - windowCallbackInterceptor.intercept(windows + resolveUntrackedWindows(decorViews, windows), appContext) - viewOnDrawInterceptor.intercept(decorViews, textAndInputPrivacy, imagePrivacy) + interceptCurrentWindows(windows) + } + } + + /** + * Starts intercepting every window currently open, [windows] included. Any standing capture + * request is then served explicitly: intercepting does take a snapshot of its own, but that one + * goes through the debouncer, which is free to drop it. The request stays standing either way — + * it is discharged by the placeholders being written, not by this snapshot being taken. + */ + @MainThread + private fun interceptCurrentWindows(windows: List) { + val decorViews = windowInspector.getGlobalWindowViews(internalLogger) + windowCallbackInterceptor.intercept(windows + resolveUntrackedWindows(decorViews, windows), appContext) + viewOnDrawInterceptor.intercept(decorViews, textAndInputPrivacy, imagePrivacy) + if (captureRequested.get()) { + viewOnDrawInterceptor.requestCapture() } } @@ -321,4 +444,13 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { .filterNot { it in knownWindows || windowCallbackInterceptor.isExcluded(it) } .distinct() } + + internal companion object { + // A saturated queue, or a window in the middle of being replaced, clears within a frame or + // two; past that, retrying is unlikely to be what makes the difference. + internal const val MAX_CAPTURE_ATTEMPTS: Int = 4 + + // one frame time + internal const val CAPTURE_RETRY_DELAY_IN_MS: Long = 64 + } } 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..f5f565e4a2 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 @@ -8,6 +8,7 @@ package com.datadog.android.sessionreplay.internal.recorder import android.view.View import android.view.ViewTreeObserver.OnDrawListener +import androidx.annotation.MainThread import com.datadog.android.api.InternalLogger import com.datadog.android.sessionreplay.ImagePrivacy import com.datadog.android.sessionreplay.TextAndInputPrivacy @@ -19,7 +20,7 @@ internal class ViewOnDrawInterceptor( private val touchPrivacyManager: TouchPrivacyManager, private val onDrawListenerProducer: OnDrawListenerProducer ) { - internal val decorOnDrawListeners: WeakHashMap = + internal val decorOnDrawListeners: WeakHashMap = WeakHashMap() fun intercept( @@ -63,6 +64,41 @@ internal class ViewOnDrawInterceptor( decorOnDrawListeners.clear() } + /** + * Takes a snapshot of every intercepted window, without going through the debouncer, which + * would be free to drop it — see [SessionReplayRecorder.requestCapture]. + */ + @MainThread + fun requestCapture(): CaptureRequestResult { + // Copy before callbacks because a capture may change the registered listener collection. + val listeners = decorOnDrawListeners.values.toSet() + if (listeners.isEmpty()) { + return CaptureRequestResult.NOT_INTERCEPTING + } + // Every window is asked, not only up to the first that succeeds: each contributes its own + // wireframes to the snapshot. + @Suppress("UnsafeThirdPartyFunctionCall") // count only propagates what captureNow throws + return if (listeners.count { it.captureNow() } > 0) { + CaptureRequestResult.CAPTURED + } else { + CaptureRequestResult.NOT_CAPTURED + } + } + + /** + * The outcome of a [requestCapture] call. + */ + internal enum class CaptureRequestResult { + /** A snapshot was taken and queued. */ + CAPTURED, + + /** Nothing is being intercepted yet, so the caller still owes itself a capture. */ + NOT_INTERCEPTING, + + /** Listeners are registered, but none of them could produce a snapshot right now. */ + NOT_CAPTURED + } + 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..7f21e1098b 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 @@ -7,7 +7,6 @@ package com.datadog.android.sessionreplay.internal.recorder.listener import android.view.View -import android.view.ViewTreeObserver import androidx.annotation.MainThread import androidx.annotation.UiThread import com.datadog.android.api.feature.FeatureSdkCore @@ -18,6 +17,7 @@ import com.datadog.android.sessionreplay.internal.TouchPrivacyManager import com.datadog.android.sessionreplay.internal.async.RecordedDataQueueHandler import com.datadog.android.sessionreplay.internal.async.RecordedDataQueueRefs import com.datadog.android.sessionreplay.internal.recorder.Debouncer +import com.datadog.android.sessionreplay.internal.recorder.OnDemandCaptureListener import com.datadog.android.sessionreplay.internal.recorder.SnapshotProducer import com.datadog.android.sessionreplay.internal.recorder.withinSRBenchmarkSpan import com.datadog.android.sessionreplay.internal.utils.MiscUtils @@ -40,7 +40,7 @@ internal class WindowsOnDrawListener( ), private val methodCallSamplingRate: Float, private val rumContextProvider: RumContextProvider -) : ViewTreeObserver.OnDrawListener { +) : OnDemandCaptureListener { internal val weakReferencedDecorViews: List> = zOrderedDecorViews.map { WeakReference(it) } @@ -49,54 +49,74 @@ internal class WindowsOnDrawListener( debouncer.debounce(snapshotRunnable) } + @MainThread + override fun captureNow(): Boolean = takeSnapshot() + // Note: we declare the anonymous object explicitly to annotate the run method as @UiThread @Suppress("ObjectLiteralToLambda") private val snapshotRunnable: Runnable = object : Runnable { @UiThread override fun run() { - val rootViews = weakReferencedDecorViews.mapNotNull { it.get() } - - // is is very important to have the windows sorted by their z-order - val context = rootViews.firstOrNull()?.context ?: return - val systemInformation = miscUtils.resolveSystemInformation(context) - val item = recordedDataQueueHandler.addSnapshotItem(systemInformation) ?: return - - val currentViewUrl = rumContextProvider.getRumContext().viewUrl - - val nodes = sdkCore.internalLogger.measureMethodCallPerf( - METHOD_CALL_CALLER_CLASS, - METHOD_CALL_CAPTURE_RECORD, - methodCallSamplingRate - ) { - withinSRBenchmarkSpan(BENCHMARK_SPAN_SNAPSHOT_PRODUCER, isContainer = true) { - val recordedDataQueueRefs = RecordedDataQueueRefs(recordedDataQueueHandler) - recordedDataQueueRefs.recordedDataQueueItem = item - rootViews.mapNotNull { - snapshotProducer.produce( - rootView = it, - systemInformation = systemInformation, - textAndInputPrivacy = textAndInputPrivacy, - imagePrivacy = imagePrivacy, - recordedDataQueueRefs = recordedDataQueueRefs, - activeRumViewUrl = currentViewUrl - ) - } - } - } + takeSnapshot() + } + } - if (nodes.isNotEmpty()) { - item.nodes = nodes + /** + * @return whether a snapshot item was in fact queued: there may be no window left to traverse, + * or the queue may have refused the item. + */ + @UiThread + @Suppress("ReturnCount") + private fun takeSnapshot(): Boolean { + val rootViews = weakReferencedDecorViews.mapNotNull { it.get() } + + // is is very important to have the windows sorted by their z-order + val context = rootViews.firstOrNull()?.context ?: return false + val systemInformation = miscUtils.resolveSystemInformation(context) + val item = recordedDataQueueHandler.addSnapshotItem(systemInformation) ?: return false + + val currentViewUrl = rumContextProvider.getRumContext().viewUrl + + val nodes = sdkCore.internalLogger.measureMethodCallPerf( + METHOD_CALL_CALLER_CLASS, + METHOD_CALL_CAPTURE_RECORD, + methodCallSamplingRate + ) { + withinSRBenchmarkSpan(BENCHMARK_SPAN_SNAPSHOT_PRODUCER, isContainer = true) { + val recordedDataQueueRefs = RecordedDataQueueRefs(recordedDataQueueHandler) + recordedDataQueueRefs.recordedDataQueueItem = item + snapshotProducer.beginSnapshot() + val snapshotNodes = rootViews.mapNotNull { + snapshotProducer.produce( + rootView = it, + systemInformation = systemInformation, + textAndInputPrivacy = textAndInputPrivacy, + imagePrivacy = imagePrivacy, + recordedDataQueueRefs = recordedDataQueueRefs, + activeRumViewUrl = currentViewUrl + ) + } + snapshotProducer.finishSnapshot()?.let { + @Suppress("UnsafeThirdPartyFunctionCall") // Kotlin listOf cannot fail for this local value. + val finishSnapshotNodes = listOf(it) + finishSnapshotNodes + snapshotNodes + } ?: snapshotNodes } + } - item.isFinishedTraversal = true + if (nodes.isNotEmpty()) { + item.nodes = nodes + } - if (item.isReady()) { - recordedDataQueueHandler.tryToConsumeItems() - } + item.isFinishedTraversal = true - touchPrivacyManager.updateCurrentTouchOverrideAreas() + if (item.isReady()) { + recordedDataQueueHandler.tryToConsumeItems() } + + touchPrivacyManager.updateCurrentTouchOverrideAreas() + return true } companion object { 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..d99cac6c2d --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/EmbeddedContentViewMapper.kt @@ -0,0 +1,184 @@ +/* + * 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 listOf(hiddenWireframe(wireframeId, slotId)) + } + + val bounds = viewBoundsResolver.resolveViewGlobalBounds( + view, + mappingContext.systemInformation.screenDensity + ) + val shapeStyle = view.background?.let { + resolveShapeStyle(it, view.alpha, internalLogger) + } + + return listOf( + MobileSegment.Wireframe.EmbeddedContentWireframe( + id = wireframeId, + x = bounds.x, + y = bounds.y, + width = bounds.width, + height = bounds.height, + shapeStyle = shapeStyle, + slotId = slotId, + isVisible = true + ) + ) + } + + 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 activeSlotIds = embeddedContentSlotRegistry.activeSlotIds() + val iterator = entries.entries.iterator() + while (iterator.hasNext()) { + val (slotId, entry) = iterator.next() + if (slotId !in activeSlotIds) { + 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/NoOpRecordWriter.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/storage/NoOpRecordWriter.kt index 3ac971a74d..20eae5a28d 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/storage/NoOpRecordWriter.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/storage/NoOpRecordWriter.kt @@ -9,7 +9,7 @@ package com.datadog.android.sessionreplay.internal.storage import com.datadog.android.sessionreplay.internal.processor.EnrichedRecord internal class NoOpRecordWriter : RecordWriter { - override fun write(record: EnrichedRecord) { + override fun write(record: EnrichedRecord, onSuccess: () -> Unit) { // no-op } } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/storage/RecordWriter.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/storage/RecordWriter.kt index 3495ee6196..f978aa8e97 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/storage/RecordWriter.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/storage/RecordWriter.kt @@ -15,6 +15,8 @@ internal interface RecordWriter { /** * Writes the record to disk. * @param record to write + * @param onSuccess invoked once the record has actually been persisted, on the thread that + * performed the write. Never invoked if the write is rejected. */ - fun write(record: EnrichedRecord) + fun write(record: EnrichedRecord, onSuccess: () -> Unit = {}) } 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..0876d1ecd6 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,9 +15,10 @@ import com.datadog.android.sessionreplay.internal.processor.EnrichedRecord internal class SessionReplayRecordWriter( private val sdkCore: FeatureSdkCore, - private val recordCallback: RecordCallback -) : RecordWriter { - override fun write(record: EnrichedRecord) { + private val recordCallback: RecordCallback, + private val onEmbeddedRecordWritten: (String, Int) -> Unit = { _, _ -> } +) : RecordWriter, EmbeddedContentRecordWriter { + override fun write(record: EnrichedRecord, onSuccess: () -> Unit) { sdkCore.getFeature(Feature.SESSION_REPLAY_FEATURE_NAME) ?.withWriteContext { _, writeScope -> writeScope { @@ -31,6 +32,28 @@ internal class SessionReplayRecordWriter( ) if (success) { updateViewSent(record) + // Runs while this monitor is held, so whatever the callback does must + // not write back through this writer synchronously. + onSuccess() + } + } + } + } + } + + 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) } } } 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/kotlin/com/datadog/android/sessionreplay/internal/utils/WireframeExt.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/WireframeExt.kt index 7aeef128f7..4280d583db 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/WireframeExt.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/WireframeExt.kt @@ -17,6 +17,7 @@ internal fun MobileSegment.Wireframe.hasOpaqueBackground(): Boolean { is MobileSegment.Wireframe.TextWireframe -> this.hasOpaqueBackground() is MobileSegment.Wireframe.PlaceholderWireframe -> true is MobileSegment.Wireframe.WebviewWireframe -> true + is MobileSegment.Wireframe.EmbeddedContentWireframe -> this.isVisible != false } } @@ -39,6 +40,7 @@ internal fun MobileSegment.Wireframe.shapeStyle(): MobileSegment.ShapeStyle? { is MobileSegment.Wireframe.ImageWireframe -> this.shapeStyle is MobileSegment.Wireframe.PlaceholderWireframe -> null is MobileSegment.Wireframe.WebviewWireframe -> this.shapeStyle + is MobileSegment.Wireframe.EmbeddedContentWireframe -> this.shapeStyle } } @@ -49,5 +51,6 @@ internal fun MobileSegment.Wireframe.copy(shapeStyle: MobileSegment.ShapeStyle?) is MobileSegment.Wireframe.ImageWireframe -> this.copy(shapeStyle = shapeStyle) is MobileSegment.Wireframe.PlaceholderWireframe -> this is MobileSegment.Wireframe.WebviewWireframe -> this.copy(shapeStyle = shapeStyle) + is MobileSegment.Wireframe.EmbeddedContentWireframe -> this.copy(shapeStyle = shapeStyle) } } 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..4fb65947a5 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 @@ -13,6 +13,7 @@ import android.view.Window import com.datadog.android.api.InternalLogger import com.datadog.android.sessionreplay.internal.LifecycleCallback import com.datadog.android.sessionreplay.internal.async.RecordedDataQueueHandler +import com.datadog.android.sessionreplay.internal.embedded.EmbeddedContentSlotRegistry import com.datadog.android.sessionreplay.internal.recorder.SessionReplayRecorder import com.datadog.android.sessionreplay.internal.recorder.ViewOnDrawInterceptor import com.datadog.android.sessionreplay.internal.recorder.WindowCallbackInterceptor @@ -24,6 +25,7 @@ import com.datadog.tools.unit.extensions.TestConfigurationExtension import com.datadog.tools.unit.extensions.config.TestConfiguration import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.annotation.Forgery +import fr.xgouchet.elmyr.annotation.StringForgery import fr.xgouchet.elmyr.junit5.ForgeExtension import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -33,8 +35,11 @@ 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.atLeastOnce +import org.mockito.kotlin.clearInvocations import org.mockito.kotlin.mock import org.mockito.kotlin.never +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.mockito.quality.Strictness @@ -75,6 +80,17 @@ internal class SessionReplayRecorderTest { private lateinit var fakeActiveWindowsDecorViews: List private lateinit var testedSessionReplayRecorder: SessionReplayRecorder + private val fakeSlotRegistry = EmbeddedContentSlotRegistry() + + @StringForgery + private lateinit var fakeSlotId: String + + @StringForgery + private lateinit var fakeOtherSlotId: String + + @StringForgery + private lateinit var fakeViewId: String + @Mock lateinit var mockRecordedDataQueueHandler: RecordedDataQueueHandler @@ -92,6 +108,12 @@ internal class SessionReplayRecorderTest { it.getArgument(0).run() true } + whenever(mockUiHandler.postDelayed(any(), any())).then { + it.getArgument(0).run() + true + } + whenever(mockViewOnDrawInterceptor.requestCapture()) + .thenReturn(ViewOnDrawInterceptor.CaptureRequestResult.CAPTURED) testedSessionReplayRecorder = SessionReplayRecorder( appContext = appContext.mockInstance, textAndInputPrivacy = fakeTextAndInputPrivacy, @@ -104,10 +126,16 @@ internal class SessionReplayRecorderTest { recordedDataQueueHandler = mockRecordedDataQueueHandler, resourceResolver = mockResourceResolver, uiHandler = mockUiHandler, - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + embeddedContentSlotRegistry = fakeSlotRegistry ) } + /** Reports [slotIds] as drawn, which is what discharges a standing capture request. */ + private fun writePlaceholdersFor(vararg slotIds: String) { + fakeSlotRegistry.onPlaceholdersWritten(fakeViewId, PLACEHOLDER_TIMESTAMP, slotIds.toSet()) + } + @Test fun `M also intercept an already-open untracked window W resumeRecorders`(forge: Forge) { // Given @@ -258,7 +286,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 +297,120 @@ 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(setOf(fakeSlotId)) + + // Then + verify(mockViewOnDrawInterceptor, atLeastOnce()).requestCapture() + } + + @Test + fun `M intercept the current windows W requestCapture { nothing intercepted yet }`() { + // Given + testedSessionReplayRecorder.resumeRecorders() + clearInvocations(mockViewOnDrawInterceptor) + whenever(mockViewOnDrawInterceptor.requestCapture()).then { + writePlaceholdersFor(fakeSlotId) + ViewOnDrawInterceptor.CaptureRequestResult.NOT_INTERCEPTING + } + + // When + testedSessionReplayRecorder.requestCapture(setOf(fakeSlotId)) + + // Then + verify(mockViewOnDrawInterceptor).intercept( + decorViews = fakeActiveWindowsDecorViews, + textAndInputPrivacy = fakeTextAndInputPrivacy, + imagePrivacy = fakeImagePrivacy + ) + } + + @Test + fun `M retry W requestCapture { no snapshot taken }`() { + // Given + // A capture request is a standing obligation: the placeholder it produces has to reach the + // player, so it is not dropped just because the recorder could not serve it right away. + testedSessionReplayRecorder.resumeRecorders() + clearInvocations(mockViewOnDrawInterceptor) + whenever(mockViewOnDrawInterceptor.requestCapture()) + .thenReturn(ViewOnDrawInterceptor.CaptureRequestResult.NOT_CAPTURED) + + // When + testedSessionReplayRecorder.requestCapture(setOf(fakeSlotId)) + + // Then + verify(mockViewOnDrawInterceptor, times(SessionReplayRecorder.MAX_CAPTURE_ATTEMPTS + 1)) + .requestCapture() + } + + @Test + fun `M keep retrying W requestCapture { snapshot taken, no placeholder written }`() { + // Given + // A snapshot being taken proves nothing: the processing queue can drop it, the write can + // fail, and the traversal may not have walked the slot at all. + testedSessionReplayRecorder.resumeRecorders() + clearInvocations(mockViewOnDrawInterceptor) + + // When + testedSessionReplayRecorder.requestCapture(setOf(fakeSlotId)) + + // Then + verify(mockViewOnDrawInterceptor, times(SessionReplayRecorder.MAX_CAPTURE_ATTEMPTS + 1)) + .requestCapture() + } + + @Test + fun `M stop retrying W requestCapture { placeholder written }`() { + // Given + testedSessionReplayRecorder.resumeRecorders() + clearInvocations(mockViewOnDrawInterceptor) + whenever(mockViewOnDrawInterceptor.requestCapture()).then { + writePlaceholdersFor(fakeSlotId) + ViewOnDrawInterceptor.CaptureRequestResult.CAPTURED + } + + // When + testedSessionReplayRecorder.requestCapture(setOf(fakeSlotId)) + + // Then + verify(mockViewOnDrawInterceptor).requestCapture() + } + + @Test + fun `M keep retrying W requestCapture { only one of the slots placed }`() { + // Given + testedSessionReplayRecorder.resumeRecorders() + clearInvocations(mockViewOnDrawInterceptor) + whenever(mockViewOnDrawInterceptor.requestCapture()).then { + writePlaceholdersFor(fakeSlotId) + ViewOnDrawInterceptor.CaptureRequestResult.CAPTURED + } + + // When + testedSessionReplayRecorder.requestCapture(setOf(fakeSlotId, fakeOtherSlotId)) + + // Then + verify(mockViewOnDrawInterceptor, times(SessionReplayRecorder.MAX_CAPTURE_ATTEMPTS + 1)) + .requestCapture() + } + + @Test + fun `M not request capture W requestCapture { recorder stopped }`() { + // When + testedSessionReplayRecorder.requestCapture(setOf(fakeSlotId)) + + // Then + verify(mockViewOnDrawInterceptor, never()).requestCapture() } @Test @@ -413,6 +555,8 @@ internal class SessionReplayRecorderTest { } companion object { + private const val PLACEHOLDER_TIMESTAMP = 1000L + val appContext = ApplicationContextTestConfiguration(Application::class.java) @TestConfigurationsProvider diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/EmbeddedContentWireframeForgeryFactory.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/EmbeddedContentWireframeForgeryFactory.kt new file mode 100644 index 0000000000..eced583373 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/EmbeddedContentWireframeForgeryFactory.kt @@ -0,0 +1,28 @@ +/* + * 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.forge + +import com.datadog.android.sessionreplay.model.MobileSegment +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.ForgeryFactory + +internal class EmbeddedContentWireframeForgeryFactory : + ForgeryFactory { + override fun getForgery(forge: Forge): MobileSegment.Wireframe.EmbeddedContentWireframe { + return MobileSegment.Wireframe.EmbeddedContentWireframe( + id = forge.aPositiveInt().toLong(), + x = forge.aPositiveLong(), + y = forge.aPositiveLong(), + width = forge.aPositiveLong(strict = true), + height = forge.aPositiveLong(strict = true), + clip = forge.aNullable { getForgery() }, + shapeStyle = forge.aNullable { getForgery() }, + border = forge.aNullable { getForgery() }, + slotId = forge.aPositiveLong().toString() + ) + } +} diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/EmbeddedContentWireframeUpdateForgeryFactory.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/EmbeddedContentWireframeUpdateForgeryFactory.kt new file mode 100644 index 0000000000..94baaf898c --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/EmbeddedContentWireframeUpdateForgeryFactory.kt @@ -0,0 +1,31 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.forge + +import com.datadog.android.sessionreplay.model.MobileSegment +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.ForgeryFactory + +internal class EmbeddedContentWireframeUpdateForgeryFactory : + ForgeryFactory { + override fun getForgery( + forge: Forge + ): MobileSegment.WireframeUpdateMutation.EmbeddedContentWireframeUpdate { + return MobileSegment.WireframeUpdateMutation.EmbeddedContentWireframeUpdate( + id = forge.aPositiveInt().toLong(), + x = forge.aNullable { aLong() }, + y = forge.aNullable { aLong() }, + width = forge.aNullable { aPositiveLong(strict = true) }, + height = forge.aNullable { aPositiveLong(strict = true) }, + clip = forge.aNullable { getForgery() }, + shapeStyle = forge.aNullable { getForgery() }, + border = forge.aNullable { getForgery() }, + slotId = forge.aPositiveLong().toString(), + isVisible = forge.aNullable { aBool() } + ) + } +} diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/ForgeConfigurator.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/ForgeConfigurator.kt index 996433a772..edfdf1b354 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/ForgeConfigurator.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/ForgeConfigurator.kt @@ -55,6 +55,8 @@ internal class ForgeConfigurator : BaseConfigurator() { forge.addFactory(TouchEventRecordedDataQueueItemForgeryFactory()) forge.addFactory(WireframeBoundsForgeryFactory()) forge.addFactory(WebViewWireframeForgeryFactory()) + forge.addFactory(EmbeddedContentWireframeForgeryFactory()) + forge.addFactory(EmbeddedContentWireframeUpdateForgeryFactory()) forge.useJvmFactories() } 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..39214de340 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 @@ -100,6 +114,9 @@ internal class SessionReplayFeatureTest { whenever(mockSdkCore.internalLogger) doReturn mockInternalLogger whenever(mockSdkCore.timeProvider) doReturn mock() whenever(mockSdkCore.createSingleThreadExecutorService(any())) doReturn mockExecutorService + whenever(mockExecutorService.execute(any())) doAnswer { + it.getArgument(0).run() + } testedFeature = SessionReplayFeature( sdkCore = mockSdkCore, @@ -110,7 +127,7 @@ internal class SessionReplayFeatureTest { startRecordingImmediately = true, touchPrivacy = fakeConfiguration.touchPrivacy, configuredSampleRate = fakeSampleRate - ) { _, _, _, _, _ -> mockRecorder } + ) { _, _, _, _, _, _ -> mockRecorder } } @Test @@ -132,6 +149,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(setOf(FAKE_EMBEDDED_SLOT_ID)) + + // 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(setOf(FAKE_EMBEDDED_SLOT_ID)) + + // 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(any()) + + // Cleanup + testedFeature.embeddedContentSlotRegistry.notifySlotChanged(fakeRegistration, null) + } + @Test fun `M initialize session replay recorder W initialize()`() { // Given @@ -161,6 +253,69 @@ 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() + ) + ) + // Embedded records are held until the placeholder they composite into has been written. + testedFeature.embeddedContentSlotRegistry.onPlaceholdersWritten( + FAKE_EMBEDDED_VIEW_ID, + FAKE_PLACEHOLDER_TIMESTAMP, + setOf(FAKE_EMBEDDED_SLOT_ID) + ) + + // 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.RECORD_SLOT_ID_KEY].asString) + .isEqualTo(FAKE_EMBEDDED_SLOT_ID) + } + } + @Test fun `M update feature context for telemetry W initialize()`() { // Given @@ -219,7 +374,7 @@ internal class SessionReplayFeatureTest { startRecordingImmediately = true, touchPrivacy = fakeConfiguration.touchPrivacy, configuredSampleRate = fakeSampleRate - ) { _, _, _, _, _ -> mockRecorder } + ) { _, _, _, _, _, _ -> mockRecorder } // When testedFeature.onInitialize(appContext.mockInstance) @@ -280,6 +435,18 @@ internal class SessionReplayFeatureTest { verify(mockRecorder).stopProcessingRecords() } + @Test + fun `M stop embedded content processing W onStop()`() { + // Given + testedFeature.onInitialize(appContext.mockInstance) + + // When + testedFeature.onStop() + + // Then + verify(mockExecutorService).shutdown() + } + @Test fun `M invalidate the feature components W onStop()`() { // Given @@ -744,7 +911,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 +962,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 +1244,7 @@ internal class SessionReplayFeatureTest { touchPrivacy = fakeConfiguration.touchPrivacy, startRecordingImmediately = scenario.startRecordingImmediately, configuredSampleRate = fakeSampleRate - ) { _, _, _, _, _ -> mockRecorder } + ) { _, _, _, _, _, _ -> mockRecorder } testedFeature.onInitialize(fakeContext) testedFeature.onReceive(event) @@ -1116,7 +1283,7 @@ internal class SessionReplayFeatureTest { touchPrivacy = fakeConfiguration.touchPrivacy, startRecordingImmediately = true, configuredSampleRate = fakeSampleRate - ) { _, _, _, _, _ -> mockRecorder } + ) { _, _, _, _, _, _ -> mockRecorder } testedFeature.onInitialize(fakeContext) testedFeature.onReceive(event) testedFeature.onReceive(event) @@ -1148,7 +1315,7 @@ internal class SessionReplayFeatureTest { touchPrivacy = fakeConfiguration.touchPrivacy, startRecordingImmediately = false, configuredSampleRate = fakeSampleRate - ) { _, _, _, _, _ -> mockRecorder } + ) { _, _, _, _, _, _ -> mockRecorder } testedFeature.onInitialize(fakeContext) testedFeature.manuallyStartRecording() testedFeature.onReceive(event) @@ -1182,7 +1349,7 @@ internal class SessionReplayFeatureTest { touchPrivacy = fakeConfiguration.touchPrivacy, startRecordingImmediately = true, configuredSampleRate = fakeSampleRate - ) { _, _, _, _, _ -> mockRecorder } + ) { _, _, _, _, _, _ -> mockRecorder } testedFeature.onInitialize(fakeContext) testedFeature.onReceive(event) testedFeature.manuallyStopRecording() @@ -1219,7 +1386,7 @@ internal class SessionReplayFeatureTest { touchPrivacy = fakeConfiguration.touchPrivacy, startRecordingImmediately = true, configuredSampleRate = fakeSampleRate - ) { _, _, _, _, _ -> mockRecorder } + ) { _, _, _, _, _, _ -> mockRecorder } testedFeature.onInitialize(fakeContext) testedFeature.onReceive(event1) @@ -1305,7 +1472,7 @@ internal class SessionReplayFeatureTest { startRecordingImmediately = true, touchPrivacy = fakeConfiguration.touchPrivacy, configuredSampleRate = 100f - ) { _, _, _, _, _ -> mockRecorder } + ) { _, _, _, _, _, _ -> mockRecorder } testedFeature.onInitialize(appContext.mockInstance) testedFeature.onReceive( @@ -1344,6 +1511,11 @@ 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_PLACEHOLDER_TIMESTAMP = 1L + 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..dbf2e08405 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`(forge: Forge) { + // Given + val fakeEmbeddedRecordsCount = forge.anInt(min = 1, max = 10) + + // When + testedRecordCallback.onRecordForViewSent(fakeEnrichedRecord) + testedRecordCallback.onEmbeddedRecordsForViewSent( + fakeEnrichedRecord.viewId, + fakeEmbeddedRecordsCount + ) + + // 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() + fakeEmbeddedRecordsCount) + } + } + + @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..41883d37d8 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,101 @@ 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 publish new context before notifying W onContextUpdate { new valid RUM view }`() { + // Given + val viewId = UUID.randomUUID().toString() + lateinit var testedProvider: SessionReplayRumContextProvider + var notifiedViewId: String? = null + testedProvider = SessionReplayRumContextProvider { + notifiedViewId = testedProvider.getRumContext().viewId + } + + // When + testedProvider.onContextUpdate( + Feature.RUM_FEATURE_NAME, + mapOf(RUM_VIEW_ID_CONTEXT_KEY to viewId) + ) + + // Then + assertThat(notifiedViewId).isEqualTo(viewId) + } + + @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 +143,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 +174,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 +201,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 +228,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 +260,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..6361a77f07 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/embedded/EmbeddedContentReceiverTest.kt @@ -0,0 +1,666 @@ +/* + * 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.atLeastOnce +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness +import java.util.concurrent.Executor + +@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 capturedSlotIds = mutableListOf() + + private val captureRequests: Int get() = capturedSlotIds.size + + private val fakeSlotRegistry = EmbeddedContentSlotRegistry() + + 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 = receiver() + } + + @Test + fun `M enrich and write records W receive { record batch }`() { + // Given + givenPlaceholder(FAKE_NATIVE_SLOT_ID, FAKE_EMBEDDED_VIEW_ID, FAKE_PLACEHOLDER_TIMESTAMP) + 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.RECORD_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.RECORD_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.RECORD_SLOT_ID_KEY].asString) + .isEqualTo(FAKE_NATIVE_SLOT_ID) + } + verifyNoInteractions(mockResourceProcessor) + } + + @Test + fun `M process records asynchronously W receive { record batch }`() { + // Given + givenPlaceholder(FAKE_NATIVE_SLOT_ID, FAKE_EMBEDDED_VIEW_ID, FAKE_PLACEHOLDER_TIMESTAMP) + lateinit var queuedTask: Runnable + val replacementRecordWriter = mock() + var currentRecordWriter = mockRecordWriter + testedReceiver = receiver( + recordWriter = { currentRecordWriter }, + executor = { Executor { queuedTask = it } } + ) + + // When + testedReceiver.receive( + EmbeddedContentEvent.RecordBatch( + records = listOf(mapOf("type" to 10L)), + slotId = FAKE_NATIVE_SLOT_ID, + viewId = FAKE_EMBEDDED_VIEW_ID + ) + ) + + // Then + verifyNoInteractions(mockRecordWriter) + + // When + currentRecordWriter = replacementRecordWriter + queuedTask.run() + + // Then + verify(mockRecordWriter).writeRaw(any(), eq(FAKE_EMBEDDED_VIEW_ID), eq(1)) + verifyNoInteractions(replacementRecordWriter) + } + + @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 retain scheduled processor W receive { processor changes before resource processing }`() { + // Given + lateinit var queuedTask: Runnable + val replacementResourceProcessor = mock() + var currentResourceProcessor = mockResourceProcessor + val resourceData = byteArrayOf(1, 2, 3) + testedReceiver = receiver( + resourceProcessor = { currentResourceProcessor }, + executor = { Executor { queuedTask = it } } + ) + + // When + testedReceiver.receive( + EmbeddedContentEvent.Resource( + identifier = FAKE_RESOURCE_ID, + data = resourceData, + mimeType = FAKE_MIME_TYPE + ) + ) + currentResourceProcessor = replacementResourceProcessor + queuedTask.run() + + // Then + verify(mockResourceProcessor).process(FAKE_RESOURCE_ID, resourceData, FAKE_MIME_TYPE) + verifyNoInteractions(replacementResourceProcessor) + } + + @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) + } + + // region placeholder ordering + + @Test + fun `M hold records W receive { no placeholder for slot }`() { + // When + testedReceiver.receive(recordBatch(timestamps = listOf(123L))) + + // Then + verifyNoInteractions(mockRecordWriter) + } + + @Test + fun `M hold records W receive { placeholder in another view }`() { + // Given + givenPlaceholder(FAKE_NATIVE_SLOT_ID, FAKE_NATIVE_VIEW_ID, FAKE_PLACEHOLDER_TIMESTAMP) + + // When + testedReceiver.receive(recordBatch(timestamps = listOf(123L))) + + // Then + verifyNoInteractions(mockRecordWriter) + } + + @Test + fun `M write held records W onPlaceholdersWritten { placeholder emitted for the slot }`() { + // Given + testedReceiver.receive(recordBatch(timestamps = listOf(123L))) + verifyNoInteractions(mockRecordWriter) + + // When + givenPlaceholder(FAKE_NATIVE_SLOT_ID, FAKE_EMBEDDED_VIEW_ID, FAKE_PLACEHOLDER_TIMESTAMP) + + // Then + verify(mockRecordWriter).writeRaw(any(), eq(FAKE_EMBEDDED_VIEW_ID), eq(1)) + } + + @Test + fun `M keep records held W onPlaceholdersWritten { placeholder emitted for another slot }`() { + // Given + // Still registered, so its placeholder is only late, not never coming. + val fakeRegistration = EmbeddedContentSlotRegistration(FAKE_NATIVE_SLOT_ID) + fakeSlotRegistry.notifySlotChanged(null, fakeRegistration) + testedReceiver.receive(recordBatch(timestamps = listOf(123L))) + + // When + givenPlaceholder(FAKE_SLOT_ID, FAKE_EMBEDDED_VIEW_ID, FAKE_PLACEHOLDER_TIMESTAMP) + + // Then + verifyNoInteractions(mockRecordWriter) + } + + @Test + fun `M carry records past the placeholder W onPlaceholdersWritten { captured before it }`() { + // Given + testedReceiver.receive(recordBatch(timestamps = listOf(100L, 168L, 207L))) + + // When + givenPlaceholder(FAKE_NATIVE_SLOT_ID, FAKE_EMBEDDED_VIEW_ID, 1000L) + + // Then + // The earliest record lands one millisecond past the placeholder, and the 68/39 ms + // intervals between the three are preserved. + assertThat(writtenTimestamps()).containsExactly(1001L, 1069L, 1108L) + } + + @Test + fun `M leave timestamps alone W receive { records already past the placeholder }`() { + // Given + givenPlaceholder(FAKE_NATIVE_SLOT_ID, FAKE_EMBEDDED_VIEW_ID, 1000L) + + // When + testedReceiver.receive(recordBatch(timestamps = listOf(1500L, 1600L))) + + // Then + assertThat(writtenTimestamps()).containsExactly(1500L, 1600L) + } + + @Test + fun `M add view time offset W receive { record batch }`() { + // Given + whenever(mockRumContextProvider.getRumContext()) + .thenReturn(fakeRumContext.copy(viewTimeOffsetMs = FAKE_VIEW_TIME_OFFSET_MS)) + givenPlaceholder(FAKE_NATIVE_SLOT_ID, FAKE_EMBEDDED_VIEW_ID, 1000L) + + // When + testedReceiver.receive(recordBatch(timestamps = listOf(2000L))) + + // Then + assertThat(writtenTimestamps()).containsExactly(2000L + FAKE_VIEW_TIME_OFFSET_MS) + } + + @Test + fun `M shift offset-corrected timestamps W onPlaceholdersWritten { held records }`() { + // Given + whenever(mockRumContextProvider.getRumContext()) + .thenReturn(fakeRumContext.copy(viewTimeOffsetMs = FAKE_VIEW_TIME_OFFSET_MS)) + testedReceiver.receive(recordBatch(timestamps = listOf(100L, 150L))) + + // When + givenPlaceholder(FAKE_NATIVE_SLOT_ID, FAKE_EMBEDDED_VIEW_ID, 10_000L) + + // Then + // The floor applies to the offset-corrected timestamps, not the raw ones. + assertThat(writtenTimestamps()).containsExactly(10_001L, 10_051L) + } + + @Test + fun `M shift each view on its own floor W onPlaceholdersWritten { held across views }`() { + // Given + testedReceiver.receive( + recordBatch(timestamps = listOf(100L), viewId = FAKE_EMBEDDED_VIEW_ID) + ) + testedReceiver.receive( + recordBatch(timestamps = listOf(200L), viewId = FAKE_OTHER_EMBEDDED_VIEW_ID) + ) + + // When + givenPlaceholder(FAKE_NATIVE_SLOT_ID, FAKE_OTHER_EMBEDDED_VIEW_ID, 5000L) + + // Then + // Only the batch sharing the placeholder's view is shifted onto its floor; the batch from + // the earlier view has no placeholder of its own and keeps its timestamp. + verify(mockRecordWriter).writeRaw(any(), eq(FAKE_EMBEDDED_VIEW_ID), eq(1)) + verify(mockRecordWriter).writeRaw(any(), eq(FAKE_OTHER_EMBEDDED_VIEW_ID), eq(1)) + assertThat(writtenTimestamps()).containsExactlyInAnyOrder(100L, 5001L) + } + + @Test + fun `M write oldest held batch W receive { more batches than the bound }`() { + // When + repeat(EmbeddedContentReceiver.MAX_PENDING_BATCHES + 1) { index -> + testedReceiver.receive(recordBatch(timestamps = listOf(index.toLong()))) + } + + // Then + // A mis-ordered record beats a missing one: the evicted batch is written as captured. + verify(mockRecordWriter).writeRaw(any(), eq(FAKE_EMBEDDED_VIEW_ID), eq(1)) + assertThat(writtenTimestamps()).containsExactly(0L) + } + + @Test + fun `M hold every batch W receive { as many batches as the bound }`() { + // When + repeat(EmbeddedContentReceiver.MAX_PENDING_BATCHES) { index -> + testedReceiver.receive(recordBatch(timestamps = listOf(index.toLong()))) + } + + // Then + verifyNoInteractions(mockRecordWriter) + } + + @Test + fun `M write oldest slot's held batches W receive { more slots than the bound }`() { + // When + repeat(EmbeddedContentReceiver.MAX_PENDING_SLOTS + 1) { index -> + testedReceiver.receive( + recordBatch(timestamps = listOf(index.toLong()), slotId = "slot-$index") + ) + } + + // Then + // A placeholder may never come for a slot, so the oldest slot makes room for the newest and + // what it was holding is written rather than left pending forever. + verify(mockRecordWriter).writeRaw(any(), eq(FAKE_EMBEDDED_VIEW_ID), eq(1)) + assertThat(writtenTimestamps()).containsExactly(0L) + } + + @Test + fun `M hold every slot W receive { as many slots as the bound }`() { + // When + repeat(EmbeddedContentReceiver.MAX_PENDING_SLOTS) { index -> + testedReceiver.receive( + recordBatch(timestamps = listOf(index.toLong()), slotId = "slot-$index") + ) + } + + // Then + verifyNoInteractions(mockRecordWriter) + } + + @Test + fun `M write held batch once W receive { placeholder lands between the check and the hold }`() { + // Given + // The registry publishes a placeholder and notifies its listeners outside the lock the hold + // takes, so a placeholder can appear after the check found none and before the batch is + // held — with no listener left to fire for it. Consecutive returns reproduce exactly that: + // the first check sees nothing, every later one sees the placeholder. + val mockSlotRegistry = mock() + whenever(mockSlotRegistry.placeholder(FAKE_NATIVE_SLOT_ID)).thenReturn( + null, + EmbeddedContentSlotRegistry.Placeholder(FAKE_EMBEDDED_VIEW_ID, 1000L) + ) + testedReceiver = receiver(registry = mockSlotRegistry) + + // When + testedReceiver.receive(recordBatch(timestamps = listOf(100L))) + + // Then + verify(mockRecordWriter).writeRaw(any(), eq(FAKE_EMBEDDED_VIEW_ID), eq(1)) + assertThat(writtenTimestamps()).containsExactly(1001L) + } + + // endregion + + // region requested captures + + @Test + fun `M request a capture W receive { batch held with no placeholder }`() { + // When + testedReceiver.receive(recordBatch(timestamps = listOf(123L))) + + // Then + // Nothing else is guaranteed to draw the placeholder the batch is waiting for. + assertThat(captureRequests).isEqualTo(1) + } + + @Test + fun `M request a capture once W receive { several batches held for the same slot }`() { + // When + repeat(5) { index -> + testedReceiver.receive(recordBatch(timestamps = listOf(index.toLong()))) + } + + // Then + // The queue stays non-empty until it is released, so the batches after the first ask again + // for a capture that is already on its way. + assertThat(captureRequests).isEqualTo(1) + } + + @Test + fun `M request a capture per slot W receive { batches held for several slots }`() { + // When + testedReceiver.receive(recordBatch(timestamps = listOf(123L), slotId = FAKE_NATIVE_SLOT_ID)) + testedReceiver.receive(recordBatch(timestamps = listOf(123L), slotId = FAKE_SLOT_ID)) + + // Then + assertThat(captureRequests).isEqualTo(2) + } + + @Test + fun `M not request a capture W receive { placeholder covers the batch }`() { + // Given + givenPlaceholder(FAKE_NATIVE_SLOT_ID, FAKE_EMBEDDED_VIEW_ID, FAKE_PLACEHOLDER_TIMESTAMP) + + // When + testedReceiver.receive(recordBatch(timestamps = listOf(123L))) + + // Then + assertThat(captureRequests).isZero() + } + + @Test + fun `M request a capture again W receive { batch held after the queue was released }`() { + // Given + testedReceiver.receive(recordBatch(timestamps = listOf(123L))) + givenPlaceholder(FAKE_NATIVE_SLOT_ID, FAKE_EMBEDDED_VIEW_ID, FAKE_PLACEHOLDER_TIMESTAMP) + + // When + // The placeholder of the previous view is no help to a batch addressed to this one. + testedReceiver.receive( + recordBatch(timestamps = listOf(456L), viewId = FAKE_OTHER_EMBEDDED_VIEW_ID) + ) + + // Then + assertThat(captureRequests).isEqualTo(2) + } + + // endregion + + // region abandoned slots + + @Test + fun `M write held batches W snapshot { slot neither drawn nor registered }`() { + // Given + // The slot was torn down before its placeholder was ever drawn, so nothing will draw it + // again — holding its batches only delays the same unshifted write. + testedReceiver.receive(recordBatch(timestamps = listOf(123L))) + + // When + givenPlaceholder(FAKE_SLOT_ID, FAKE_EMBEDDED_VIEW_ID, FAKE_PLACEHOLDER_TIMESTAMP) + + // Then + verify(mockRecordWriter).writeRaw(any(), eq(FAKE_EMBEDDED_VIEW_ID), eq(1)) + assertThat(writtenTimestamps()).containsExactly(123L) + } + + @Test + fun `M shift held batches W snapshot { slot drawn }`() { + // Given + testedReceiver.receive(recordBatch(timestamps = listOf(1L))) + + // When + givenPlaceholder(FAKE_NATIVE_SLOT_ID, FAKE_EMBEDDED_VIEW_ID, FAKE_PLACEHOLDER_TIMESTAMP) + + // Then + // Released by the placeholder rather than flushed, so it lands after the wireframe. + assertThat(writtenTimestamps()).containsExactly(FAKE_PLACEHOLDER_TIMESTAMP + 1) + } + + // endregion + + // region Internal + + private fun receiver( + registry: EmbeddedContentSlotRegistry = fakeSlotRegistry, + recordWriter: () -> EmbeddedContentRecordWriter = { mockRecordWriter }, + resourceProcessor: () -> ResourceProcessor = { mockResourceProcessor }, + executor: () -> Executor = { Executor(Runnable::run) } + ): EmbeddedContentReceiver { + return EmbeddedContentReceiver( + rumContextProvider = mockRumContextProvider, + recordWriter = recordWriter, + resourceProcessor = resourceProcessor, + isRecording = { isRecording }, + executor = executor, + requestCapture = { slotId -> capturedSlotIds += slotId }, + embeddedContentSlotRegistry = registry, + internalLogger = mockInternalLogger + ) + } + + private fun givenPlaceholder(slotId: String, viewId: String, timestamp: Long) { + fakeSlotRegistry.onPlaceholdersWritten(viewId, timestamp, setOf(slotId)) + } + + private fun recordBatch( + timestamps: List, + slotId: String = FAKE_NATIVE_SLOT_ID, + viewId: String = FAKE_EMBEDDED_VIEW_ID + ): EmbeddedContentEvent.RecordBatch { + return EmbeddedContentEvent.RecordBatch( + records = timestamps.map { mapOf("type" to 10L, "timestamp" to it) }, + slotId = slotId, + viewId = viewId + ) + } + + /** Every record timestamp handed to the writer, in the order it was written. */ + private fun writtenTimestamps(): List { + val captor = argumentCaptor() + verify(mockRecordWriter, atLeastOnce()).writeRaw(captor.capture(), any(), any()) + return captor.allValues.flatMap { bytes -> + JsonParser.parseString(bytes.toString(Charsets.UTF_8)) + .asJsonObject[EmbeddedContentReceiver.RECORDS_KEY] + .asJsonArray + .map { it.asJsonObject[EmbeddedRecordTimeline.RECORD_TIMESTAMP_KEY].asLong } + } + } + + // endregion + + 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_OTHER_EMBEDDED_VIEW_ID = "other-embedded-view-id" + const val FAKE_PLACEHOLDER_TIMESTAMP = 100L + const val FAKE_VIEW_TIME_OFFSET_MS = 500L + 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..79577055bf --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/embedded/EmbeddedContentSlotRegistryTest.kt @@ -0,0 +1,339 @@ +/* + * 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 return active slots W activeSlotIds`() { + // Given + val fakeActiveRegistration = EmbeddedContentSlotRegistration(FAKE_SLOT_ID) + val fakeInactiveRegistration = EmbeddedContentSlotRegistration(FAKE_OLD_SLOT_ID) + testedRegistry.track(fakeActiveRegistration) + testedRegistry.track(fakeInactiveRegistration) + fakeInactiveRegistration.deactivate() + + // When + val activeSlotIds = testedRegistry.activeSlotIds() + + // Then + assertThat(activeSlotIds).containsExactly(FAKE_SLOT_ID) + } + + @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) + } + } + + // region placeholders + + @Test + fun `M report placeholder W onPlaceholdersWritten`() { + // When + testedRegistry.onPlaceholdersWritten(FAKE_VIEW_ID, FAKE_TIMESTAMP, setOf(FAKE_SLOT_ID)) + + // Then + assertThat(testedRegistry.placeholder(FAKE_SLOT_ID)) + .isEqualTo(EmbeddedContentSlotRegistry.Placeholder(FAKE_VIEW_ID, FAKE_TIMESTAMP)) + } + + @Test + fun `M report no placeholder W placeholder { slot never drawn }`() { + // When + testedRegistry.onPlaceholdersWritten(FAKE_VIEW_ID, FAKE_TIMESTAMP, setOf(FAKE_SLOT_ID)) + + // Then + assertThat(testedRegistry.placeholder(FAKE_OLD_SLOT_ID)).isNull() + } + + @Test + fun `M keep first timestamp W onPlaceholdersWritten { drawn again in same view }`() { + // Given + testedRegistry.onPlaceholdersWritten(FAKE_VIEW_ID, FAKE_TIMESTAMP, setOf(FAKE_SLOT_ID)) + + // When + testedRegistry.onPlaceholdersWritten(FAKE_VIEW_ID, FAKE_LATER_TIMESTAMP, setOf(FAKE_SLOT_ID)) + + // Then + assertThat(testedRegistry.placeholder(FAKE_SLOT_ID)?.timestamp).isEqualTo(FAKE_TIMESTAMP) + } + + @Test + fun `M replace placeholder W onPlaceholdersWritten { drawn in a new view }`() { + // Given + testedRegistry.onPlaceholdersWritten(FAKE_VIEW_ID, FAKE_TIMESTAMP, setOf(FAKE_SLOT_ID)) + + // When + testedRegistry.onPlaceholdersWritten(FAKE_NEW_VIEW_ID, FAKE_LATER_TIMESTAMP, setOf(FAKE_SLOT_ID)) + + // Then + assertThat(testedRegistry.placeholder(FAKE_SLOT_ID)) + .isEqualTo(EmbeddedContentSlotRegistry.Placeholder(FAKE_NEW_VIEW_ID, FAKE_LATER_TIMESTAMP)) + } + + @Test + fun `M drop placeholder W onPlaceholdersWritten { slot no longer drawn }`() { + // Given + testedRegistry.onPlaceholdersWritten( + FAKE_VIEW_ID, + FAKE_TIMESTAMP, + setOf(FAKE_SLOT_ID, FAKE_OLD_SLOT_ID) + ) + + // When + testedRegistry.onPlaceholdersWritten(FAKE_VIEW_ID, FAKE_LATER_TIMESTAMP, setOf(FAKE_SLOT_ID)) + + // Then + assertThat(testedRegistry.placeholder(FAKE_OLD_SLOT_ID)).isNull() + assertThat(testedRegistry.placeholder(FAKE_SLOT_ID)?.timestamp).isEqualTo(FAKE_TIMESTAMP) + } + + @Test + fun `M drop every placeholder W onPlaceholdersWritten { nothing drawn }`() { + // Given + testedRegistry.onPlaceholdersWritten(FAKE_VIEW_ID, FAKE_TIMESTAMP, setOf(FAKE_SLOT_ID)) + + // When + testedRegistry.onPlaceholdersWritten(FAKE_VIEW_ID, FAKE_LATER_TIMESTAMP, emptySet()) + + // Then + assertThat(testedRegistry.placeholder(FAKE_SLOT_ID)).isNull() + } + + @Test + fun `M notify listener W onPlaceholdersWritten { first placeholder in view }`() { + // Given + val notified = mutableListOf() + testedRegistry.addPlaceholderListener { notified.add(it) } + + // When + testedRegistry.onPlaceholdersWritten( + FAKE_VIEW_ID, + FAKE_TIMESTAMP, + setOf(FAKE_SLOT_ID, FAKE_OLD_SLOT_ID) + ) + + // Then + assertThat(notified).containsExactlyInAnyOrder(FAKE_SLOT_ID, FAKE_OLD_SLOT_ID) + } + + @Test + fun `M not notify listener W onPlaceholdersWritten { drawn again in same view }`() { + // Given + val notified = mutableListOf() + testedRegistry.onPlaceholdersWritten(FAKE_VIEW_ID, FAKE_TIMESTAMP, setOf(FAKE_SLOT_ID)) + testedRegistry.addPlaceholderListener { notified.add(it) } + + // When + testedRegistry.onPlaceholdersWritten(FAKE_VIEW_ID, FAKE_LATER_TIMESTAMP, setOf(FAKE_SLOT_ID)) + + // Then + assertThat(notified).isEmpty() + } + + @Test + fun `M notify listener W onPlaceholdersWritten { slot drawn again after being dropped }`() { + // Given + val notified = mutableListOf() + testedRegistry.onPlaceholdersWritten(FAKE_VIEW_ID, FAKE_TIMESTAMP, setOf(FAKE_SLOT_ID)) + testedRegistry.onPlaceholdersWritten(FAKE_VIEW_ID, FAKE_LATER_TIMESTAMP, emptySet()) + testedRegistry.addPlaceholderListener { notified.add(it) } + + // When + testedRegistry.onPlaceholdersWritten(FAKE_VIEW_ID, FAKE_LATEST_TIMESTAMP, setOf(FAKE_SLOT_ID)) + + // Then + assertThat(notified).containsExactly(FAKE_SLOT_ID) + assertThat(testedRegistry.placeholder(FAKE_SLOT_ID)?.timestamp).isEqualTo(FAKE_LATEST_TIMESTAMP) + } + + @Test + fun `M notify every listener W onPlaceholdersWritten { several listeners }`() { + // Given + val firstNotified = mutableListOf() + val secondNotified = mutableListOf() + testedRegistry.addPlaceholderListener { firstNotified.add(it) } + testedRegistry.addPlaceholderListener { secondNotified.add(it) } + + // When + testedRegistry.onPlaceholdersWritten(FAKE_VIEW_ID, FAKE_TIMESTAMP, setOf(FAKE_SLOT_ID)) + + // Then + assertThat(firstNotified).containsExactly(FAKE_SLOT_ID) + assertThat(secondNotified).containsExactly(FAKE_SLOT_ID) + } + + @Test + fun `M notify snapshot listener W onPlaceholdersWritten { nothing newly placed }`() { + // Given + // A listener waiting on a slot learns from the snapshots that leave it out, so every + // snapshot is reported, not only the ones that place something new. + val notified = mutableListOf>() + testedRegistry.onPlaceholdersWritten(FAKE_VIEW_ID, FAKE_TIMESTAMP, setOf(FAKE_SLOT_ID)) + testedRegistry.addSnapshotListener { notified.add(it) } + + // When + testedRegistry.onPlaceholdersWritten(FAKE_VIEW_ID, FAKE_LATER_TIMESTAMP, setOf(FAKE_SLOT_ID)) + testedRegistry.onPlaceholdersWritten(FAKE_VIEW_ID, FAKE_LATEST_TIMESTAMP, emptySet()) + + // Then + assertThat(notified).containsExactly(setOf(FAKE_SLOT_ID), emptySet()) + } + + @Test + fun `M notify every snapshot listener W onPlaceholdersWritten { several listeners }`() { + // Given + val firstNotified = mutableListOf>() + val secondNotified = mutableListOf>() + testedRegistry.addSnapshotListener { firstNotified.add(it) } + testedRegistry.addSnapshotListener { secondNotified.add(it) } + + // When + testedRegistry.onPlaceholdersWritten(FAKE_VIEW_ID, FAKE_TIMESTAMP, setOf(FAKE_SLOT_ID)) + + // Then + assertThat(firstNotified).containsExactly(setOf(FAKE_SLOT_ID)) + assertThat(secondNotified).containsExactly(setOf(FAKE_SLOT_ID)) + } + + @Test + fun `M keep placeholders isolated W separate instances`() { + // Given + val otherRegistry = EmbeddedContentSlotRegistry() + + // When + testedRegistry.onPlaceholdersWritten(FAKE_VIEW_ID, FAKE_TIMESTAMP, setOf(FAKE_SLOT_ID)) + + // Then + assertThat(otherRegistry.placeholder(FAKE_SLOT_ID)).isNull() + } + + // endregion + + 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" + const val FAKE_VIEW_ID = "view-id" + const val FAKE_NEW_VIEW_ID = "new-view-id" + const val FAKE_TIMESTAMP = 1_000L + const val FAKE_LATER_TIMESTAMP = 2_000L + const val FAKE_LATEST_TIMESTAMP = 3_000L + } +} diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/BoundUtilsTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/BoundUtilsTest.kt index be0c8a62ce..bab3e722ce 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/BoundUtilsTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/BoundUtilsTest.kt @@ -148,6 +148,7 @@ internal class BoundUtilsTest { is MobileSegment.Wireframe.ImageWireframe -> this.x is MobileSegment.Wireframe.PlaceholderWireframe -> this.x is MobileSegment.Wireframe.WebviewWireframe -> this.x + is MobileSegment.Wireframe.EmbeddedContentWireframe -> this.x } } @@ -158,6 +159,7 @@ internal class BoundUtilsTest { is MobileSegment.Wireframe.ImageWireframe -> this.y is MobileSegment.Wireframe.PlaceholderWireframe -> this.y is MobileSegment.Wireframe.WebviewWireframe -> this.y + is MobileSegment.Wireframe.EmbeddedContentWireframe -> this.y } } @@ -168,6 +170,7 @@ internal class BoundUtilsTest { is MobileSegment.Wireframe.ImageWireframe -> this.width is MobileSegment.Wireframe.PlaceholderWireframe -> this.width is MobileSegment.Wireframe.WebviewWireframe -> this.width + is MobileSegment.Wireframe.EmbeddedContentWireframe -> this.width } } @@ -178,6 +181,7 @@ internal class BoundUtilsTest { is MobileSegment.Wireframe.ImageWireframe -> this.height is MobileSegment.Wireframe.PlaceholderWireframe -> this.height is MobileSegment.Wireframe.WebviewWireframe -> this.height + is MobileSegment.Wireframe.EmbeddedContentWireframe -> this.height } } @@ -199,6 +203,8 @@ internal class BoundUtilsTest { this.copy(clip = clip, x = x, y = y, width = width, height = height) is MobileSegment.Wireframe.WebviewWireframe -> this.copy(clip = clip, x = x, y = y, width = width, height = height) + is MobileSegment.Wireframe.EmbeddedContentWireframe -> + this.copy(clip = clip, x = x, y = y, width = width, height = height) } } @@ -211,7 +217,8 @@ internal class BoundUtilsTest { WireframeExtTest.forge.getForgery(), WireframeExtTest.forge.getForgery(), WireframeExtTest.forge.getForgery(), - WireframeExtTest.forge.getForgery() + WireframeExtTest.forge.getForgery(), + WireframeExtTest.forge.getForgery() ) } } 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 9d2379d6bd..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 @@ -295,6 +295,38 @@ internal class MutationResolverTest { assertThat(mutations?.updates).isEqualTo(mutationTestData.expectedMutation) } + @ParameterizedTest + @MethodSource("embeddedContentIsVisibleMutationData") + fun `M identify the updated wireframes W resolveMutations {EmbeddedContent, isVisible}`( + mutationTestData: MutationTestData + ) { + // When + val mutations = testedMutationResolver.resolveMutations( + mutationTestData.prevSnapshot, + mutationTestData.newSnapshot + ) + + // Then + assertThat(mutations?.adds).isNullOrEmpty() + assertThat(mutations?.removes).isNullOrEmpty() + assertThat(mutations?.updates).isEqualTo(mutationTestData.expectedMutation) + } + + @ParameterizedTest + @MethodSource("embeddedContentEquivalentVisibilityData") + fun `M return null W resolveMutations {EmbeddedContent, equivalent isVisible}`( + mutationTestData: MutationTestData + ) { + // When + val mutations = testedMutationResolver.resolveMutations( + mutationTestData.prevSnapshot, + mutationTestData.newSnapshot + ) + + // Then + assertThat(mutations).isNull() + } + // endregion // region permanentId changed mutations @@ -684,6 +716,56 @@ internal class MutationResolverTest { // endregion + // region EmbeddedContent remove mutations + + @Test + 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 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( + fakePrevSnapshot, + fakeCurrentSnapshot + ) + + // Then + assertThat(mutations?.adds).isNullOrEmpty() + assertThat(mutations?.removes).isEqualTo(expectedRemoves) + assertThat(mutations?.updates).isNullOrEmpty() + } + + @Test + 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 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( + fakePrevSnapshot, + fakeCurrentSnapshot + ) + + // Then + assertThat(mutations?.adds).isNullOrEmpty() + assertThat(mutations?.removes).isEqualTo(expectedRemoves) + assertThat(mutations?.updates).isNullOrEmpty() + } + + // endregion + // region no mutation @Test @@ -777,6 +859,7 @@ internal class MutationResolverTest { is MobileSegment.Wireframe.ImageWireframe -> this.id is MobileSegment.Wireframe.PlaceholderWireframe -> this.id is MobileSegment.Wireframe.WebviewWireframe -> this.id + is MobileSegment.Wireframe.EmbeddedContentWireframe -> this.id } } @@ -798,6 +881,7 @@ internal class MutationResolverTest { is MobileSegment.Wireframe.ImageWireframe -> this.id is MobileSegment.Wireframe.PlaceholderWireframe -> this.id is MobileSegment.Wireframe.WebviewWireframe -> this.id + is MobileSegment.Wireframe.EmbeddedContentWireframe -> this.id } } @@ -879,6 +963,22 @@ internal class MutationResolverTest { ) } + val fakePrevEmbeddedContentSnapshot = + forgePrevSnapshot() + val fakeEmbeddedContentUpdatedWireframes = forgeMutatedWireframes(fakePrevEmbeddedContentSnapshot) { + it.copy(x = forge.aLong(), y = forge.aLong()) + } + val fakeCurrentEmbeddedContentSnapshot = fakeEmbeddedContentUpdatedWireframes + + fakePrevEmbeddedContentSnapshot.drop(fakeEmbeddedContentUpdatedWireframes.size) + val expectedEmbeddedContentUpdates = fakeEmbeddedContentUpdatedWireframes.map { + MobileSegment.WireframeUpdateMutation.EmbeddedContentWireframeUpdate( + id = it.id(), + slotId = it.slotId, + x = it.x, + y = it.y + ) + } + return listOf( MutationTestData( prevSnapshot = fakePrevShapeSnapshot, @@ -904,6 +1004,11 @@ internal class MutationResolverTest { prevSnapshot = fakePrevWebViewSnapshot, newSnapshot = fakeCurrentWebViewSnapshot, expectedMutation = expectedWebViewUpdates + ), + MutationTestData( + prevSnapshot = fakePrevEmbeddedContentSnapshot, + newSnapshot = fakeCurrentEmbeddedContentSnapshot, + expectedMutation = expectedEmbeddedContentUpdates ) ) } @@ -986,6 +1091,22 @@ internal class MutationResolverTest { ) } + val fakePrevEmbeddedContentSnapshot = + forgePrevSnapshot() + val fakeEmbeddedContentUpdatedWireframes = forgeMutatedWireframes(fakePrevEmbeddedContentSnapshot) { + it.copy(width = forge.aLong(), height = forge.aLong()) + } + val fakeCurrentEmbeddedContentSnapshot = fakeEmbeddedContentUpdatedWireframes + + fakePrevEmbeddedContentSnapshot.drop(fakeEmbeddedContentUpdatedWireframes.size) + val expectedEmbeddedContentUpdates = fakeEmbeddedContentUpdatedWireframes.map { + MobileSegment.WireframeUpdateMutation.EmbeddedContentWireframeUpdate( + id = it.id(), + slotId = it.slotId, + width = it.width, + height = it.height + ) + } + return listOf( MutationTestData( prevSnapshot = fakePrevShapeSnapshot, @@ -1011,6 +1132,11 @@ internal class MutationResolverTest { prevSnapshot = fakePrevWebViewSnapshot, newSnapshot = fakeCurrentWebViewSnapshot, expectedMutation = expectedWebViewUpdates + ), + MutationTestData( + prevSnapshot = fakePrevEmbeddedContentSnapshot, + newSnapshot = fakeCurrentEmbeddedContentSnapshot, + expectedMutation = expectedEmbeddedContentUpdates ) ) } @@ -1088,6 +1214,21 @@ internal class MutationResolverTest { ) } + val fakePrevEmbeddedContentSnapshot = + forgePrevSnapshot() + val fakeEmbeddedContentUpdatedWireframes = forgeMutatedWireframes(fakePrevEmbeddedContentSnapshot) { + it.copy(clip = forge.forgeDifferent(it.clip)) + } + val fakeCurrentEmbeddedContentSnapshot = fakeEmbeddedContentUpdatedWireframes + + fakePrevEmbeddedContentSnapshot.drop(fakeEmbeddedContentUpdatedWireframes.size) + val expectedEmbeddedContentUpdates = fakeEmbeddedContentUpdatedWireframes.map { + MobileSegment.WireframeUpdateMutation.EmbeddedContentWireframeUpdate( + id = it.id(), + clip = it.clip, + slotId = it.slotId + ) + } + return listOf( MutationTestData( prevSnapshot = fakePrevShapeSnapshot, @@ -1113,6 +1254,11 @@ internal class MutationResolverTest { prevSnapshot = fakePrevWebViewSnapshot, newSnapshot = fakeCurrentWebViewSnapshot, expectedMutation = expectedWebViewUpdates + ), + MutationTestData( + prevSnapshot = fakePrevEmbeddedContentSnapshot, + newSnapshot = fakeCurrentEmbeddedContentSnapshot, + expectedMutation = expectedEmbeddedContentUpdates ) ) } @@ -1195,6 +1341,22 @@ internal class MutationResolverTest { ) } + val fakePrevEmbeddedContentSnapshot = + forgePrevSnapshot() + .map { it.copy(clip = forge.getForgery()) } + val fakeEmbeddedContentUpdatedWireframes = forgeMutatedWireframes(fakePrevEmbeddedContentSnapshot) { + it.copy(clip = null) + } + val fakeCurrentEmbeddedContentSnapshot = fakeEmbeddedContentUpdatedWireframes + + fakePrevEmbeddedContentSnapshot.drop(fakeEmbeddedContentUpdatedWireframes.size) + val expectedEmbeddedContentUpdates = fakeEmbeddedContentUpdatedWireframes.map { + MobileSegment.WireframeUpdateMutation.EmbeddedContentWireframeUpdate( + id = it.id(), + clip = nullClipWireframe, + slotId = it.slotId + ) + } + return listOf( MutationTestData( prevSnapshot = fakePrevShapeSnapshot, @@ -1220,6 +1382,11 @@ internal class MutationResolverTest { prevSnapshot = fakePrevWebViewSnapshot, newSnapshot = fakeCurrentWebViewSnapshot, expectedMutation = expectedWebViewUpdates + ), + MutationTestData( + prevSnapshot = fakePrevEmbeddedContentSnapshot, + newSnapshot = fakeCurrentEmbeddedContentSnapshot, + expectedMutation = expectedEmbeddedContentUpdates ) ) } @@ -1344,6 +1511,21 @@ internal class MutationResolverTest { ) } + val fakePrevEmbeddedContentSnapshot = + forgePrevSnapshot() + val fakeEmbeddedContentUpdatedWireframes = forgeMutatedWireframes(fakePrevEmbeddedContentSnapshot) { + it.copy(shapeStyle = forge.getForgery()) + } + val fakeCurrentEmbeddedContentSnapshot = fakeEmbeddedContentUpdatedWireframes + + fakePrevEmbeddedContentSnapshot.drop(fakeEmbeddedContentUpdatedWireframes.size) + val expectedEmbeddedContentUpdates = fakeEmbeddedContentUpdatedWireframes.map { + MobileSegment.WireframeUpdateMutation.EmbeddedContentWireframeUpdate( + id = it.id(), + shapeStyle = it.shapeStyle, + slotId = it.slotId + ) + } + return listOf( MutationTestData( prevSnapshot = fakePrevShapeSnapshot, @@ -1364,10 +1546,60 @@ internal class MutationResolverTest { prevSnapshot = fakePrevWebViewSnapshot, newSnapshot = fakeCurrentWebViewSnapshot, expectedMutation = expectedWebViewUpdates + ), + MutationTestData( + prevSnapshot = fakePrevEmbeddedContentSnapshot, + newSnapshot = fakeCurrentEmbeddedContentSnapshot, + expectedMutation = expectedEmbeddedContentUpdates ) ) } + @JvmStatic + fun embeddedContentIsVisibleMutationData(): List { + ForgeConfigurator().configure(forge) + + val visibilityChanges: List> = listOf( + Triple(false, true, true), + Triple(true, false, false), + Triple(false, null, true), + Triple(null, false, false) + ) + return visibilityChanges.map { (previousVisibility, currentVisibility, expectedVisibility) -> + val fakePrevWireframe = + forge.getForgery() + .copy(isVisible = previousVisibility) + val fakeCurrentWireframe = fakePrevWireframe.copy(isVisible = currentVisibility) + MutationTestData( + prevSnapshot = listOf(fakePrevWireframe), + newSnapshot = listOf(fakeCurrentWireframe), + expectedMutation = listOf( + MobileSegment.WireframeUpdateMutation.EmbeddedContentWireframeUpdate( + id = fakeCurrentWireframe.id, + slotId = fakeCurrentWireframe.slotId, + isVisible = expectedVisibility + ) + ) + ) + } + } + + @JvmStatic + fun embeddedContentEquivalentVisibilityData(): List { + ForgeConfigurator().configure(forge) + + return listOf(true to null, null to true).map { (previousVisibility, currentVisibility) -> + val fakePrevWireframe = + forge.getForgery() + .copy(isVisible = previousVisibility) + MutationTestData( + prevSnapshot = listOf(fakePrevWireframe), + newSnapshot = listOf(fakePrevWireframe.copy(isVisible = currentVisibility)), + expectedMutation = emptyList() + ) + } + } + @JvmStatic fun typeMutationData(): List { ForgeConfigurator().configure(forge) @@ -1399,6 +1631,14 @@ internal class MutationResolverTest { id = it.id ) } + + val fakePrevEmbeddedContentSnapshot = + forgePrevSnapshot() + val fakeCurrentEmbeddedContentSnapshot = fakePrevEmbeddedContentSnapshot.map { + forge.getForgery().copy( + id = it.id + ) + } return listOf( MutationTestData( prevSnapshot = fakePrevShapeSnapshot, @@ -1424,6 +1664,11 @@ internal class MutationResolverTest { prevSnapshot = fakePrevWebViewSnapshot, newSnapshot = fakeCurrentWebViewSnapshot, expectedMutation = emptyList() + ), + MutationTestData( + prevSnapshot = fakePrevEmbeddedContentSnapshot, + newSnapshot = fakeCurrentEmbeddedContentSnapshot, + expectedMutation = emptyList() ) ) } diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/NodeFlattenerTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/NodeFlattenerTest.kt index 3da3465a17..e5e57dba5a 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/NodeFlattenerTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/NodeFlattenerTest.kt @@ -136,6 +136,35 @@ internal class NodeFlattenerTest { assertThat(wireframes).isEqualTo(expectedList) } + @Test + fun `M keep covered wireframe W flattenNode { covering embedded content is hidden }`(forge: Forge) { + // Given + val fakeBottomWireframe = forge.getForgery().copy( + x = 0, + y = 0, + width = 100, + height = 100, + clip = null, + shapeStyle = forge.getForgery() + ) + val fakeHiddenEmbeddedContent = + forge.getForgery().copy( + x = 0, + y = 0, + width = 100, + height = 100, + clip = null, + isVisible = false + ) + val fakeNode = Node(wireframes = listOf(fakeBottomWireframe, fakeHiddenEmbeddedContent)) + + // When + val wireframes = NodeFlattener().flattenNode(fakeNode) + + // Then + assertThat(wireframes).containsExactly(fakeBottomWireframe, fakeHiddenEmbeddedContent) + } + @Test fun `M not have ConcurrentModificationException W modifying nodes concurrently`(forge: Forge) { // Given @@ -332,6 +361,8 @@ internal class NodeFlattenerTest { this.copy(id = id) is MobileSegment.Wireframe.WebviewWireframe -> this.copy(id = id) + is MobileSegment.Wireframe.EmbeddedContentWireframe -> + this.copy(id = id) } } @@ -411,6 +442,14 @@ internal class NodeFlattenerTest { height = height, clip = null ) + is MobileSegment.Wireframe.EmbeddedContentWireframe -> fakeWireframe.copy( + id = id, + x = x, + y = y, + width = width, + height = height, + clip = null + ) } } @@ -426,6 +465,8 @@ internal class NodeFlattenerTest { Bounds(this.x, this.y, this.width, this.height) is MobileSegment.Wireframe.WebviewWireframe -> Bounds(this.x, this.y, this.width, this.height) + is MobileSegment.Wireframe.EmbeddedContentWireframe -> + Bounds(this.x, this.y, this.width, this.height) } } @@ -441,6 +482,8 @@ internal class NodeFlattenerTest { clip is MobileSegment.Wireframe.WebviewWireframe -> clip + is MobileSegment.Wireframe.EmbeddedContentWireframe -> + clip } } @@ -453,6 +496,7 @@ internal class NodeFlattenerTest { is MobileSegment.Wireframe.ImageWireframe -> this.id is MobileSegment.Wireframe.PlaceholderWireframe -> this.id is MobileSegment.Wireframe.WebviewWireframe -> this.id + is MobileSegment.Wireframe.EmbeddedContentWireframe -> this.id } } 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..91e749e879 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 @@ -13,6 +13,7 @@ import com.datadog.android.sessionreplay.internal.ResourcesFeature import com.datadog.android.sessionreplay.internal.async.ResourceRecordedDataQueueItem import com.datadog.android.sessionreplay.internal.async.SnapshotRecordedDataQueueItem import com.datadog.android.sessionreplay.internal.async.TouchEventRecordedDataQueueItem +import com.datadog.android.sessionreplay.internal.embedded.EmbeddedContentSlotRegistry import com.datadog.android.sessionreplay.internal.recorder.Node import com.datadog.android.sessionreplay.internal.resources.ResourceDataStoreManager import com.datadog.android.sessionreplay.internal.storage.RecordWriter @@ -40,7 +41,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 @@ -93,6 +93,8 @@ internal class RecordedDataProcessorTest { @Mock lateinit var mockTimeProvider: TimeProvider + private val fakeSlotRegistry = EmbeddedContentSlotRegistry() + private val invalidRumContext = SessionReplayRumContext() private lateinit var invalidRecordedQueuedItemContext: RecordedQueuedItemContext @@ -152,12 +154,18 @@ internal class RecordedDataProcessorTest { .thenReturn(forge.aList { forge.getForgery() }) whenever(mockMutationResolver.resolveMutations(any(), any())) .thenReturn(forge.getForgery()) + // By default writes are persisted, so the success callback runs. + whenever(mockWriter.write(any(), any())).thenAnswer { invocation -> + invocation.getArgument<() -> Unit>(1).invoke() + null + } testedProcessor = RecordedDataProcessor( resourceDataStoreManager = mockResourceDataStoreManager, resourcesWriter = mockResourcesWriter, writer = mockWriter, mutationResolver = mockMutationResolver, timeProvider = mockTimeProvider, + embeddedContentSlotRegistry = fakeSlotRegistry, nodeFlattener = mockNodeFlattener ) } @@ -175,7 +183,7 @@ internal class RecordedDataProcessorTest { // Then val captor = argumentCaptor() - verify(mockWriter).write(captor.capture()) + verify(mockWriter).write(captor.capture(), any()) assertThat(captor.firstValue.applicationId).isEqualTo(fakeRumContext.applicationId) assertThat(captor.firstValue.sessionId).isEqualTo(fakeRumContext.sessionId) assertThat(captor.firstValue.viewId).isEqualTo(fakeRumContext.viewId) @@ -204,7 +212,7 @@ internal class RecordedDataProcessorTest { // Then val captor = argumentCaptor() - verify(mockWriter).write(captor.capture()) + verify(mockWriter).write(captor.capture(), any()) assertThat(captor.firstValue.records.size).isEqualTo(3) val fullSnapshotRecord = captor.firstValue.records[2] as MobileSegment.MobileRecord.MobileFullSnapshotRecord @@ -228,7 +236,7 @@ internal class RecordedDataProcessorTest { // Then val captor = argumentCaptor() - verify(mockWriter).write(captor.capture()) + verify(mockWriter).write(captor.capture(), any()) assertThat(captor.firstValue.toJson()).contains("\"permanentId\":\"$fakePermanentId\"") } @@ -277,7 +285,7 @@ internal class RecordedDataProcessorTest { // Then val captor = argumentCaptor() // one for first view, one for first view end and one for new view - verify(mockWriter, times(3)).write(captor.capture()) + verify(mockWriter, times(3)).write(captor.capture(), any()) assertThat(captor.firstValue.applicationId).isEqualTo(fakeRumContext.applicationId) assertThat(captor.firstValue.sessionId).isEqualTo(fakeRumContext.sessionId) assertThat(captor.firstValue.viewId).isEqualTo(fakeRumContext.viewId) @@ -344,7 +352,7 @@ internal class RecordedDataProcessorTest { // Then val captor = argumentCaptor() - verify(mockWriter, times(2)).write(captor.capture()) + verify(mockWriter, times(2)).write(captor.capture(), any()) assertThat(captor.secondValue.records.size).isEqualTo(1) val fullSnapshotRecord = captor.secondValue.records[0] as MobileSegment.MobileRecord.MobileFullSnapshotRecord @@ -395,7 +403,7 @@ internal class RecordedDataProcessorTest { // Then val captor = argumentCaptor() - verify(mockWriter, times(2)).write(captor.capture()) + verify(mockWriter, times(2)).write(captor.capture(), any()) assertThat(captor.secondValue.records.size).isEqualTo(1) val fullSnapshotRecord = captor.secondValue.records[0] as MobileSegment.MobileRecord.MobileIncrementalSnapshotRecord @@ -415,7 +423,7 @@ internal class RecordedDataProcessorTest { // Then val captor = argumentCaptor() - verify(mockWriter).write(captor.capture()) + verify(mockWriter).write(captor.capture(), any()) assertThat(captor.firstValue.records.size).isEqualTo(3) val metaRecord = captor.firstValue.records[0] as MobileSegment.MobileRecord.MetaRecord assertThat(metaRecord.timestamp).isEqualTo(fakeTimestamp) @@ -452,7 +460,7 @@ internal class RecordedDataProcessorTest { // Then val captor = argumentCaptor() - verify(mockWriter).write(captor.capture()) + verify(mockWriter).write(captor.capture(), any()) assertThat(captor.firstValue.records.size).isEqualTo(3) val focusRecord = captor.firstValue.records[1] as MobileSegment.MobileRecord.FocusRecord assertThat(focusRecord.timestamp).isEqualTo(fakeTimestamp) @@ -479,7 +487,7 @@ internal class RecordedDataProcessorTest { // Then val captor = argumentCaptor() - verify(mockWriter, times(2)).write(captor.capture()) + verify(mockWriter, times(2)).write(captor.capture(), any()) assertThat(captor.secondValue.records.size).isEqualTo(1) assertThat(captor.secondValue.records[0]).isInstanceOf( MobileSegment.MobileRecord @@ -507,7 +515,7 @@ internal class RecordedDataProcessorTest { // Then val captor = argumentCaptor() - verify(mockWriter, times(2)).write(captor.capture()) + verify(mockWriter, times(2)).write(captor.capture(), any()) assertThat(captor.secondValue.records.size).isEqualTo(1) assertThat(captor.secondValue.records[0]).isInstanceOf( MobileSegment.MobileRecord @@ -553,7 +561,7 @@ internal class RecordedDataProcessorTest { // Then val captor = argumentCaptor() - verify(mockWriter, times(4)).write(captor.capture()) + verify(mockWriter, times(4)).write(captor.capture(), any()) assertThat(captor.lastValue.records.size).isEqualTo(3) val metaRecord = captor.lastValue.records[0] as MobileSegment.MobileRecord.MetaRecord assertThat(metaRecord.timestamp).isEqualTo(fakeTimestamp) @@ -600,7 +608,7 @@ internal class RecordedDataProcessorTest { // Then val captor = argumentCaptor() - verify(mockWriter, times(4)).write(captor.capture()) + verify(mockWriter, times(4)).write(captor.capture(), any()) assertThat(captor.lastValue.records.size).isEqualTo(3) val focusRecord = captor.lastValue.records[1] as MobileSegment.MobileRecord.FocusRecord assertThat(focusRecord.timestamp).isEqualTo(fakeTimestamp) @@ -646,7 +654,7 @@ internal class RecordedDataProcessorTest { // Then val captor = argumentCaptor() - verify(mockWriter, times(4)).write(captor.capture()) + verify(mockWriter, times(4)).write(captor.capture(), any()) assertThat(captor.thirdValue.records.size).isEqualTo(1) assertThat(captor.firstValue.applicationId).isEqualTo(fakeRumContext.applicationId) assertThat(captor.firstValue.sessionId).isEqualTo(fakeRumContext.sessionId) @@ -704,7 +712,7 @@ internal class RecordedDataProcessorTest { // Then val captor = argumentCaptor() - verify(mockWriter, times(2)).write(captor.capture()) + verify(mockWriter, times(2)).write(captor.capture(), any()) assertThat(captor.secondValue.records.size).isEqualTo(1) val incrementalSnapshotRecord = captor.secondValue.records[0] as MobileSegment.MobileRecord.MobileIncrementalSnapshotRecord @@ -755,7 +763,7 @@ internal class RecordedDataProcessorTest { // We should only send the FullSnapshotRecord. The IncrementalSnapshotRecord will not be // send as there was no mutation data detected. val captor = argumentCaptor() - verify(mockWriter, times(1)).write(captor.capture()) + verify(mockWriter, times(1)).write(captor.capture(), any()) assertThat(captor.firstValue.records.size).isEqualTo(3) assertThat(captor.firstValue.records[2]) .isInstanceOf(MobileSegment.MobileRecord.MobileFullSnapshotRecord::class.java) @@ -787,7 +795,7 @@ internal class RecordedDataProcessorTest { // Then val captor = argumentCaptor() - verify(mockWriter).write(captor.capture()) + verify(mockWriter).write(captor.capture(), any()) assertThat(captor.firstValue.applicationId).isEqualTo(fakeRumContext.applicationId) assertThat(captor.firstValue.sessionId).isEqualTo(fakeRumContext.sessionId) assertThat(captor.firstValue.viewId).isEqualTo(fakeRumContext.viewId) @@ -821,7 +829,7 @@ internal class RecordedDataProcessorTest { // Then val captor = argumentCaptor() - verify(mockWriter).write(captor.capture()) + verify(mockWriter).write(captor.capture(), any()) assertThat(captor.firstValue.applicationId).isEqualTo(fakeRumContext.applicationId) assertThat(captor.firstValue.sessionId).isEqualTo(fakeRumContext.sessionId) assertThat(captor.firstValue.viewId).isEqualTo(fakeRumContext.viewId) @@ -855,7 +863,7 @@ internal class RecordedDataProcessorTest { // Then val captor = argumentCaptor() - verify(mockWriter, times(2)).write(captor.capture()) + verify(mockWriter, times(2)).write(captor.capture(), any()) assertThat(captor.firstValue.applicationId).isEqualTo(fakeRumContext.applicationId) assertThat(captor.firstValue.sessionId).isEqualTo(fakeRumContext.sessionId) assertThat(captor.firstValue.viewId).isEqualTo(fakeRumContext.viewId) @@ -892,7 +900,7 @@ internal class RecordedDataProcessorTest { // Then val captor = argumentCaptor() - verify(mockWriter, times(2)).write(captor.capture()) + verify(mockWriter, times(2)).write(captor.capture(), any()) assertThat(captor.firstValue.applicationId).isEqualTo(fakeRumContext.applicationId) assertThat(captor.firstValue.sessionId).isEqualTo(fakeRumContext.sessionId) assertThat(captor.firstValue.viewId).isEqualTo(fakeRumContext.viewId) @@ -928,7 +936,7 @@ internal class RecordedDataProcessorTest { // Then val captor = argumentCaptor() - verify(mockWriter, times(2)).write(captor.capture()) + verify(mockWriter, times(2)).write(captor.capture(), any()) assertThat(captor.firstValue.applicationId).isEqualTo(fakeRumContext.applicationId) assertThat(captor.firstValue.sessionId).isEqualTo(fakeRumContext.sessionId) assertThat(captor.firstValue.viewId).isEqualTo(fakeRumContext.viewId) @@ -971,7 +979,7 @@ internal class RecordedDataProcessorTest { // Then val captor = argumentCaptor() - verify(mockWriter, times(3)).write(captor.capture()) + verify(mockWriter, times(3)).write(captor.capture(), any()) val relevantItem = captor.lastValue assertThat(relevantItem.records.size).isEqualTo(3) assertThat(relevantItem.records[0]).isInstanceOf(MobileSegment.MobileRecord.MetaRecord::class.java) @@ -1013,7 +1021,7 @@ internal class RecordedDataProcessorTest { val captor = argumentCaptor() // one for first view, one for first view end and one for new view - verify(mockWriter, times(3)).write(captor.capture()) + verify(mockWriter, times(3)).write(captor.capture(), any()) assertThat(captor.firstValue.applicationId).isEqualTo(fakeRumContext.applicationId) assertThat(captor.firstValue.sessionId).isEqualTo(fakeRumContext.sessionId) assertThat(captor.firstValue.viewId).isEqualTo(fakeRumContext.viewId) @@ -1072,7 +1080,7 @@ internal class RecordedDataProcessorTest { // Then val captor = argumentCaptor() - verify(mockWriter, atLeastOnce()).write(captor.capture()) + verify(mockWriter, atLeastOnce()).write(captor.capture(), any()) val initialSnapshot = captor.allValues[0].records val endSnapshot = captor.allValues[1].records @@ -1127,7 +1135,7 @@ internal class RecordedDataProcessorTest { // Then val captor = argumentCaptor() - verify(mockWriter, atLeastOnce()).write(captor.capture()) + verify(mockWriter, atLeastOnce()).write(captor.capture(), any()) val initialSnapshot = captor.allValues[0].records val touchEvent = captor.allValues[1].records @@ -1182,7 +1190,7 @@ internal class RecordedDataProcessorTest { // Then val captor = argumentCaptor() - verify(mockWriter, atLeastOnce()).write(captor.capture()) + verify(mockWriter, atLeastOnce()).write(captor.capture(), any()) val firstSnapshot = captor.allValues[0].records val endSnapshot = captor.allValues[1].records @@ -1237,7 +1245,7 @@ internal class RecordedDataProcessorTest { // Then val captor = argumentCaptor() - verify(mockWriter, atLeastOnce()).write(captor.capture()) + verify(mockWriter, atLeastOnce()).write(captor.capture(), any()) val firstSnapshot = captor.allValues[0].records val firstTouchEvent = captor.allValues[1].records @@ -1258,6 +1266,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) @@ -1277,58 +1286,131 @@ internal class RecordedDataProcessorTest { } @Test - fun `M not store resource in datastore W processResources { resource not previously seen by manager not ready }`( - forge: Forge + fun `M not write resource data W processResources { resource was previously seen }`( + @StringForgery fakeString: String ) { // Given - val fakeByteArray = forge.anAlphaNumericalString().toByteArray() + val fakeByteArray = fakeString.toByteArray() val fakeResourceItem = createResourceItem(fakeByteArray, usedContext = initialRecordedQueuedItemContext) - whenever(mockResourceDataStoreManager.isReady()).thenReturn(false) + whenever(mockResourceDataStoreManager.markResourceAsSentIfNew(fakeIdentifier)) + .thenReturn(false) // When testedProcessor.processResources(fakeResourceItem) // Then - verify(mockResourceDataStoreManager, never()).cacheResourceHash(fakeIdentifier) + verifyNoInteractions(mockResourcesWriter) } + // endregion + + // region embedded content placeholders + @Test - fun `M store resource in datastore W processResources { resource not previously seen and manager ready }`( + fun `M report placeholder to the registry W process { snapshot with visible placeholder }`( + @StringForgery fakeSlotId: String, forge: Forge ) { // Given - val fakeByteArray = forge.anAlphaNumericalString().toByteArray() - val fakeResourceItem = createResourceItem(fakeByteArray, usedContext = initialRecordedQueuedItemContext) - whenever(mockResourceDataStoreManager.isReady()).thenReturn(true) + currentRecordedQueuedItemContext = mockRumContextDataHandler.createRumContextData() + ?: fail("RumContextData is null") + givenFlattenedWireframes(forge.anEmbeddedContentWireframe(fakeSlotId, isVisible = true)) // When - testedProcessor.processResources(fakeResourceItem) + testedProcessor.processScreenSnapshots(createSnapshotItem(fakeSnapshot1)) // Then - verify(mockResourceDataStoreManager, times(1)).cacheResourceHash(fakeIdentifier) + assertThat(fakeSlotRegistry.placeholder(fakeSlotId)) + .isEqualTo( + EmbeddedContentSlotRegistry.Placeholder(fakeRumContext.viewId, fakeTimestamp) + ) } @Test - fun `M not write resource data W processResources { resource was previously seen }`( - @StringForgery fakeString: String + fun `M report no placeholder to the registry W process { snapshot with hidden placeholder }`( + @StringForgery fakeSlotId: String, + forge: Forge ) { // Given - val fakeByteArray = fakeString.toByteArray() - val fakeResourceItem = createResourceItem(fakeByteArray, usedContext = initialRecordedQueuedItemContext) - whenever(mockResourceDataStoreManager.isPreviouslySentResource(fakeIdentifier)) - .thenReturn(true) + currentRecordedQueuedItemContext = mockRumContextDataHandler.createRumContextData() + ?: fail("RumContextData is null") + givenFlattenedWireframes(forge.anEmbeddedContentWireframe(fakeSlotId, isVisible = false)) // When - testedProcessor.processResources(fakeResourceItem) + testedProcessor.processScreenSnapshots(createSnapshotItem(fakeSnapshot1)) // Then - verifyNoInteractions(mockResourcesWriter) + assertThat(fakeSlotRegistry.placeholder(fakeSlotId)).isNull() + } + + @Test + fun `M report placeholders after writing W process { snapshot with visible placeholder }`( + @StringForgery fakeSlotId: String, + forge: Forge + ) { + // Given + // Nothing may be released against a placeholder whose record has not been written yet. + currentRecordedQueuedItemContext = mockRumContextDataHandler.createRumContextData() + ?: fail("RumContextData is null") + givenFlattenedWireframes(forge.anEmbeddedContentWireframe(fakeSlotId, isVisible = true)) + var written = false + var writtenWhenNotified: Boolean? = null + whenever(mockWriter.write(any(), any())).thenAnswer { invocation -> + written = true + invocation.getArgument<() -> Unit>(1).invoke() + null + } + fakeSlotRegistry.addPlaceholderListener { writtenWhenNotified = written } + + // When + testedProcessor.processScreenSnapshots(createSnapshotItem(fakeSnapshot1)) + + // Then + assertThat(writtenWhenNotified).isTrue() + } + + @Test + fun `M report no placeholder W process { snapshot write rejected }`( + @StringForgery fakeSlotId: String, + forge: Forge + ) { + // Given + currentRecordedQueuedItemContext = mockRumContextDataHandler.createRumContextData() + ?: fail("RumContextData is null") + givenFlattenedWireframes(forge.anEmbeddedContentWireframe(fakeSlotId, isVisible = true)) + // A rejected write never invokes the success callback. + whenever(mockWriter.write(any(), any())).thenAnswer { null } + + // When + testedProcessor.processScreenSnapshots(createSnapshotItem(fakeSnapshot1)) + + // Then + assertThat(fakeSlotRegistry.placeholder(fakeSlotId)).isNull() } // endregion // region Internal + private fun givenFlattenedWireframes(vararg wireframes: MobileSegment.Wireframe) { + whenever(mockNodeFlattener.flattenNode(any())).thenReturn(wireframes.toList()) + } + + private fun Forge.anEmbeddedContentWireframe( + slotId: String, + isVisible: Boolean + ): MobileSegment.Wireframe.EmbeddedContentWireframe { + return MobileSegment.Wireframe.EmbeddedContentWireframe( + id = aLong(min = 0), + x = aLong(min = 0), + y = aLong(min = 0), + width = aLong(min = 0), + height = aLong(min = 0), + slotId = slotId, + isVisible = isVisible + ) + } + private fun Forge.aSingleLevelSnapshot(): Node { return Node( wireframes = listOf( 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 14f028df43..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 @@ -694,6 +730,7 @@ internal class WireframeUtilsTest { is MobileSegment.Wireframe.ImageWireframe -> clip?.normalized() is MobileSegment.Wireframe.PlaceholderWireframe -> clip?.normalized() is MobileSegment.Wireframe.WebviewWireframe -> clip?.normalized() + is MobileSegment.Wireframe.EmbeddedContentWireframe -> clip?.normalized() } } @@ -736,6 +773,7 @@ internal class WireframeUtilsTest { is MobileSegment.Wireframe.PlaceholderWireframe -> this is MobileSegment.Wireframe.WebviewWireframe -> this + is MobileSegment.Wireframe.EmbeddedContentWireframe -> this } } 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..a5e6e718d6 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,11 +27,14 @@ 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 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 @@ -52,7 +55,7 @@ internal class ViewOnDrawInterceptorTest { lateinit var mockOnDrawListenerProducer: OnDrawListenerProducer @Mock - lateinit var mockOnDrawListener: ViewTreeObserver.OnDrawListener + lateinit var mockOnDrawListener: OnDemandCaptureListener @Mock lateinit var mockTouchPrivacyManager: TouchPrivacyManager @@ -77,6 +80,7 @@ internal class ViewOnDrawInterceptorTest { touchPrivacyManager = mockTouchPrivacyManager ) ) doReturn mockOnDrawListener + whenever(mockOnDrawListener.captureNow()) doReturn true testedInterceptor = ViewOnDrawInterceptor( internalLogger = mockInternalLogger, @@ -125,7 +129,7 @@ internal class ViewOnDrawInterceptorTest { @Test fun `M force onDraw on the listener when registered()`() { // Given - val mockOnDrawListener = mock() + val mockOnDrawListener = mock() testedInterceptor = ViewOnDrawInterceptor( internalLogger = mockInternalLogger, touchPrivacyManager = mockTouchPrivacyManager @@ -158,6 +162,61 @@ internal class ViewOnDrawInterceptorTest { } } + @Test + fun `M capture on the active listener once W requestCapture { multiple decor views }`() { + // Given + testedInterceptor.intercept(fakeDecorViews, fakeTextAndInputPrivacy, fakeImagePrivacy) + clearInvocations(mockOnDrawListener) + + // When + val result = testedInterceptor.requestCapture() + + // Then + // The debouncer is bypassed on purpose: it is free to drop a debounced frame. + verify(mockOnDrawListener).captureNow() + verify(mockOnDrawListener, never()).onDraw() + assertThat(result).isEqualTo(ViewOnDrawInterceptor.CaptureRequestResult.CAPTURED) + } + + @Test + fun `M report not captured W requestCapture { listener took no snapshot }`() { + // Given + testedInterceptor.intercept(fakeDecorViews, fakeTextAndInputPrivacy, fakeImagePrivacy) + clearInvocations(mockOnDrawListener) + whenever(mockOnDrawListener.captureNow()) doReturn false + + // When + val result = testedInterceptor.requestCapture() + + // Then + assertThat(result).isEqualTo(ViewOnDrawInterceptor.CaptureRequestResult.NOT_CAPTURED) + } + + @Test + fun `M report not intercepting W requestCapture { nothing intercepted }`() { + // When + val result = testedInterceptor.requestCapture() + + // Then + assertThat(result).isEqualTo(ViewOnDrawInterceptor.CaptureRequestResult.NOT_INTERCEPTING) + verifyNoInteractions(mockOnDrawListener) + } + + @Test + fun `M report not intercepting W requestCapture { after stopIntercepting }`() { + // Given + testedInterceptor.intercept(fakeDecorViews, fakeTextAndInputPrivacy, fakeImagePrivacy) + testedInterceptor.stopIntercepting() + clearInvocations(mockOnDrawListener) + + // When + val result = testedInterceptor.requestCapture() + + // Then + assertThat(result).isEqualTo(ViewOnDrawInterceptor.CaptureRequestResult.NOT_INTERCEPTING) + verifyNoInteractions(mockOnDrawListener) + } + @Test fun `M do nothing W intercept() { view tree observer is not alive }`() { // Given @@ -283,7 +342,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..e490d1f206 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 @@ -205,6 +205,50 @@ internal class WindowsOnDrawListenerTest { verify(mockRecordedDataQueueHandler).addSnapshotItem(fakeSystemInformation) } + @Test + fun `M take and add to queue without debouncing W captureNow()`() { + // Given + // The debouncer is free to drop a debounced frame, which would lose the requested capture. + whenever(mockRecordedDataQueueHandler.addSnapshotItem(any())) + .thenReturn(fakeSnapshotQueueItem) + + // When + val captured = testedListener.captureNow() + + // Then + assertThat(captured).isTrue() + verify(mockRecordedDataQueueHandler).addSnapshotItem(fakeSystemInformation) + verifyNoInteractions(mockDebouncer) + } + + @Test + fun `M report no capture W captureNow() { queue refused the item }`() { + // Given + whenever(mockRecordedDataQueueHandler.addSnapshotItem(any())) + .thenReturn(null) + + // When + val captured = testedListener.captureNow() + + // Then + assertThat(captured).isFalse() + verifyNoInteractions(mockSnapshotProducer) + } + + @Test + fun `M report no capture W captureNow() { windows lost the strong reference }`() { + // Given + testedListener.weakReferencedDecorViews.forEach { it.clear() } + + // When + val captured = testedListener.captureNow() + + // Then + assertThat(captured).isFalse() + verifyNoInteractions(mockRecordedDataQueueHandler) + verifyNoInteractions(mockSnapshotProducer) + } + @Test fun `M update queue with correct nodes W onDraw()`() { // Given @@ -229,6 +273,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..83dd06b954 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,81 @@ internal class SessionReplayRecordWriterTest { verifyNoMoreInteractions(mockRecordCallback) } + @Test + fun `M call onSuccess W write`(forge: Forge) { + // Given + val fakeRecord = forge.forgeEnrichedRecord() + 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) + } + var onSuccessCalls = 0 + + // When + testedWriter.write(fakeRecord) { onSuccessCalls++ } + + // Then + assertThat(onSuccessCalls).isEqualTo(1) + } + + @Test + fun `M not call onSuccess W write { eventBatchWriter write failed }`(forge: Forge) { + // Given + whenever(mockEventBatchWriter.write(anyOrNull(), anyOrNull(), any())) + .thenReturn(false) + val fakeRecord = forge.forgeEnrichedRecord() + 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) + } + var onSuccessCalls = 0 + + // When + testedWriter.write(fakeRecord) { onSuccessCalls++ } + + // Then + assertThat(onSuccessCalls).isZero() + } + + @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/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/utils/WireframeExtTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/utils/WireframeExtTest.kt index de2e50290f..dfaf419ba0 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/utils/WireframeExtTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/utils/WireframeExtTest.kt @@ -164,6 +164,21 @@ internal class WireframeExtTest { assertThat(fakeTestWireframe.hasOpaqueBackground()).isTrue } + @Test + fun `M return true W hasOpaqueBackground { visible EmbeddedContentWireframe }`( + @Forgery fakeWireframe: MobileSegment.Wireframe.EmbeddedContentWireframe + ) { + assertThat(fakeWireframe.copy(isVisible = true).hasOpaqueBackground()).isTrue + assertThat(fakeWireframe.copy(isVisible = null).hasOpaqueBackground()).isTrue + } + + @Test + fun `M return false W hasOpaqueBackground { hidden EmbeddedContentWireframe }`( + @Forgery fakeWireframe: MobileSegment.Wireframe.EmbeddedContentWireframe + ) { + assertThat(fakeWireframe.copy(isVisible = false).hasOpaqueBackground()).isFalse + } + // endregion // region shapeStyle @@ -207,6 +222,7 @@ internal class WireframeExtTest { is MobileSegment.Wireframe.ImageWireframe -> this.copy(shapeStyle = shapeStyle) is MobileSegment.Wireframe.PlaceholderWireframe -> this is MobileSegment.Wireframe.WebviewWireframe -> this.copy(shapeStyle = shapeStyle) + is MobileSegment.Wireframe.EmbeddedContentWireframe -> this.copy(shapeStyle = shapeStyle) } } diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/model/EmbeddedContentModelTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/model/EmbeddedContentModelTest.kt new file mode 100644 index 0000000000..452c4e562d --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/model/EmbeddedContentModelTest.kt @@ -0,0 +1,99 @@ +/* + * 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.model + +import com.datadog.android.sessionreplay.WIREFRAME_TYPE_EMBEDDED_CONTENT +import com.datadog.android.sessionreplay.forge.ForgeConfigurator +import fr.xgouchet.elmyr.annotation.Forgery +import fr.xgouchet.elmyr.annotation.LongForgery +import fr.xgouchet.elmyr.annotation.StringForgery +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@ExtendWith(ForgeExtension::class) +@ForgeConfiguration(ForgeConfigurator::class) +internal class EmbeddedContentModelTest { + + @Test + fun `M round trip embedded content W serialize and deserialize`( + @Forgery fakeWireframe: MobileSegment.Wireframe.EmbeddedContentWireframe + ) { + // Given + val wireframeWithVisibility = fakeWireframe.copy(isVisible = false) + + // When + val json = wireframeWithVisibility.toJson() + + // Then + assertThat(json.asJsonObject.get("type").asString) + .isEqualTo(WIREFRAME_TYPE_EMBEDDED_CONTENT) + assertThat(MobileSegment.Wireframe.fromJsonElement(json)).isEqualTo(wireframeWithVisibility) + } + + @Test + fun `M round trip embedded content update W serialize and deserialize`( + @Forgery fakeUpdate: MobileSegment.WireframeUpdateMutation.EmbeddedContentWireframeUpdate + ) { + // When + val json = fakeUpdate.toJson() + + // Then + assertThat(json.asJsonObject.get("type").asString) + .isEqualTo(WIREFRAME_TYPE_EMBEDDED_CONTENT) + assertThat(MobileSegment.WireframeUpdateMutation.fromJsonElement(json)).isEqualTo(fakeUpdate) + } + + @Test + fun `M round trip slot id W serialize and deserialize { full snapshot }`( + @LongForgery fakeTimestamp: Long, + @StringForgery fakeSlotId: String + ) { + // Given + val recordWithSlot = MobileSegment.MobileRecord.MobileFullSnapshotRecord( + timestamp = fakeTimestamp, + data = MobileSegment.Data(emptyList()), + slotId = fakeSlotId + ) + + // When + val deserialized = MobileSegment.MobileRecord.MobileFullSnapshotRecord.fromJson( + recordWithSlot.toJson().toString() + ) + + // Then + assertThat(deserialized).isEqualTo(recordWithSlot) + } + + @Test + fun `M round trip slot id W serialize and deserialize { incremental snapshot }`( + @LongForgery fakeTimestamp: Long, + @LongForgery fakeWidth: Long, + @LongForgery fakeHeight: Long, + @StringForgery fakeSlotId: String + ) { + // Given + val recordWithSlot = MobileSegment.MobileRecord.MobileIncrementalSnapshotRecord( + timestamp = fakeTimestamp, + data = MobileSegment.MobileIncrementalData.ViewportResizeData( + width = fakeWidth, + height = fakeHeight + ), + slotId = fakeSlotId + ) + + // When + val deserialized = MobileSegment.MobileRecord.MobileIncrementalSnapshotRecord.fromJson( + recordWithSlot.toJson().toString() + ) + + // Then + assertThat(deserialized).isEqualTo(recordWithSlot) + } +} 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" + } +}