From f99d7836d3adb267751096e29cdb8ee7f0aa8944 Mon Sep 17 00:00:00 2001 From: jonathanmos <48201295+jonathanmos@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:56:15 +0300 Subject: [PATCH 01/12] PANA-8391: Add embedded content wireframe model support --- .../api/apiSurface | 19 +- .../api/dd-sdk-android-session-replay.api | 101 ++++++- .../embedded-content-wireframe-schema.json | 38 +++ ...edded-content-wireframe-update-schema.json | 33 +++ .../mobile/full-snapshot-record-schema.json | 5 + .../incremental-snapshot-record-schema.json | 5 + .../mobile/wireframe-schema.json | 3 + .../wireframe-update-mutation-schema.json | 3 + .../sessionreplay/MobileSegmentConstants.kt | 6 + .../internal/processor/BoundsUtils.kt | 5 + .../internal/processor/HeatmapWireframeExt.kt | 2 + .../internal/processor/MobileSegmentExt.kt | 1 + .../internal/processor/MutationResolver.kt | 63 +++++ .../processor/RecordedDataProcessor.kt | 8 +- .../internal/processor/WireframeUtils.kt | 1 + .../internal/utils/WireframeExt.kt | 3 + .../EmbeddedContentWireframeForgeryFactory.kt | 28 ++ ...dedContentWireframeUpdateForgeryFactory.kt | 31 +++ .../sessionreplay/forge/ForgeConfigurator.kt | 2 + .../internal/processor/BoundUtilsTest.kt | 9 +- .../processor/MutationResolverTest.kt | 255 ++++++++++++++++++ .../internal/processor/NodeFlattenerTest.kt | 15 ++ .../internal/processor/WireframeUtilsTest.kt | 2 + .../internal/utils/WireframeExtTest.kt | 1 + .../model/EmbeddedContentModelTest.kt | 99 +++++++ 25 files changed, 725 insertions(+), 13 deletions(-) create mode 100644 features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/embedded-content-wireframe-schema.json create mode 100644 features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/embedded-content-wireframe-update-schema.json create mode 100644 features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/EmbeddedContentWireframeForgeryFactory.kt create mode 100644 features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/EmbeddedContentWireframeUpdateForgeryFactory.kt create mode 100644 features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/model/EmbeddedContentModelTest.kt diff --git a/features/dd-sdk-android-session-replay/api/apiSurface b/features/dd-sdk-android-session-replay/api/apiSurface index f420baab16..6e785f579d 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) @@ -192,14 +193,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 +331,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 +390,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..3e2bfc8ecc 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; @@ -560,15 +561,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 +587,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 +965,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 +1259,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/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..674501a303 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/MutationResolver.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/MutationResolver.kt @@ -150,6 +150,16 @@ internal class MutationResolver(private val internalLogger: InternalLogger) { ) ) } + } else if (oldWireframe is MobileSegment.Wireframe.EmbeddedContentWireframe) { + if (oldWireframe.isVisible != false) { + updates.add( + MobileSegment.WireframeUpdateMutation.EmbeddedContentWireframeUpdate( + id = oldWireframe.id, + slotId = oldWireframe.slotId, + isVisible = false + ) + ) + } } else { // Old element was removed removes.add(MobileSegment.Remove(oldWireframe.id())) @@ -345,6 +355,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 +471,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 +534,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..807f1ce860 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 @@ -142,16 +142,16 @@ 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 ) ) } 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..23badf5080 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 @@ -78,6 +78,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/utils/WireframeExt.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/WireframeExt.kt index 7aeef128f7..7d931d78ca 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 -> true } } @@ -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/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/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..7028457314 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,66 @@ internal class MutationResolverTest { // endregion + // region EmbeddedContent "remove" mutations + + @Test + fun `M identify the updated wireframes W resolveMutations {EmbeddedContent removed at beginning}`(forge: Forge) { + // Given + val fakePrevSnapshot = forge.aList(size = forge.anInt(min = 3, max = 10)) { + forge.getForgery(MobileSegment.Wireframe.EmbeddedContentWireframe::class.java) + } + val fakeHiddenWireframes = forge.anInt(min = 1, max = fakePrevSnapshot.size - 1) + val fakeCurrentSnapshot = fakePrevSnapshot.drop(fakeHiddenWireframes) + val expectedUpdates = fakePrevSnapshot.take(fakeHiddenWireframes).map { + MobileSegment.WireframeUpdateMutation.EmbeddedContentWireframeUpdate( + id = it.id(), + slotId = it.slotId, + isVisible = false + ) + } + + // When + val mutations = testedMutationResolver.resolveMutations( + fakePrevSnapshot, + fakeCurrentSnapshot + ) + + // Then + assertThat(mutations?.adds).isNullOrEmpty() + assertThat(mutations?.removes).isNullOrEmpty() + assertThat(mutations?.updates).isEqualTo(expectedUpdates) + } + + @Test + fun `M identify the updated wireframes W resolveMutations {EmbeddedContent removed at end}`(forge: Forge) { + // Given + val fakePrevSnapshot = forge.aList(size = forge.anInt(min = 3, max = 10)) { + forge.getForgery(MobileSegment.Wireframe.EmbeddedContentWireframe::class.java) + } + val fakeHiddenWireframes = forge.anInt(min = 1, max = fakePrevSnapshot.size - 1) + val fakeCurrentSnapshot = fakePrevSnapshot.dropLast(fakeHiddenWireframes) + val expectedUpdates = fakePrevSnapshot.takeLast(fakeHiddenWireframes).map { + MobileSegment.WireframeUpdateMutation.EmbeddedContentWireframeUpdate( + id = it.id(), + slotId = it.slotId, + isVisible = false + ) + } + + // When + val mutations = testedMutationResolver.resolveMutations( + fakePrevSnapshot, + fakeCurrentSnapshot + ) + + // Then + assertThat(mutations?.adds).isNullOrEmpty() + assertThat(mutations?.removes).isNullOrEmpty() + assertThat(mutations?.updates).isEqualTo(expectedUpdates) + } + + // endregion + // region no mutation @Test @@ -777,6 +869,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 +891,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 +973,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 +1014,11 @@ internal class MutationResolverTest { prevSnapshot = fakePrevWebViewSnapshot, newSnapshot = fakeCurrentWebViewSnapshot, expectedMutation = expectedWebViewUpdates + ), + MutationTestData( + prevSnapshot = fakePrevEmbeddedContentSnapshot, + newSnapshot = fakeCurrentEmbeddedContentSnapshot, + expectedMutation = expectedEmbeddedContentUpdates ) ) } @@ -986,6 +1101,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 +1142,11 @@ internal class MutationResolverTest { prevSnapshot = fakePrevWebViewSnapshot, newSnapshot = fakeCurrentWebViewSnapshot, expectedMutation = expectedWebViewUpdates + ), + MutationTestData( + prevSnapshot = fakePrevEmbeddedContentSnapshot, + newSnapshot = fakeCurrentEmbeddedContentSnapshot, + expectedMutation = expectedEmbeddedContentUpdates ) ) } @@ -1088,6 +1224,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 +1264,11 @@ internal class MutationResolverTest { prevSnapshot = fakePrevWebViewSnapshot, newSnapshot = fakeCurrentWebViewSnapshot, expectedMutation = expectedWebViewUpdates + ), + MutationTestData( + prevSnapshot = fakePrevEmbeddedContentSnapshot, + newSnapshot = fakeCurrentEmbeddedContentSnapshot, + expectedMutation = expectedEmbeddedContentUpdates ) ) } @@ -1195,6 +1351,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 +1392,11 @@ internal class MutationResolverTest { prevSnapshot = fakePrevWebViewSnapshot, newSnapshot = fakeCurrentWebViewSnapshot, expectedMutation = expectedWebViewUpdates + ), + MutationTestData( + prevSnapshot = fakePrevEmbeddedContentSnapshot, + newSnapshot = fakeCurrentEmbeddedContentSnapshot, + expectedMutation = expectedEmbeddedContentUpdates ) ) } @@ -1344,6 +1521,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 +1556,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 +1641,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 +1674,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..79ffc47b5d 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 @@ -332,6 +332,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 +413,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 +436,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 +453,8 @@ internal class NodeFlattenerTest { clip is MobileSegment.Wireframe.WebviewWireframe -> clip + is MobileSegment.Wireframe.EmbeddedContentWireframe -> + clip } } @@ -453,6 +467,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/WireframeUtilsTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/WireframeUtilsTest.kt index 14f028df43..281e22951e 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 @@ -694,6 +694,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 +737,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/utils/WireframeExtTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/utils/WireframeExtTest.kt index de2e50290f..bf2a47b966 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 @@ -207,6 +207,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) + } +} From ca508d7585b2942e8e468f24e794f61f42086600 Mon Sep 17 00:00:00 2001 From: jonathanmos <48201295+jonathanmos@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:35:46 +0300 Subject: [PATCH 02/12] PANA-8391: Prevent hidden embedded content from occluding wireframes --- .../internal/utils/WireframeExt.kt | 2 +- .../internal/processor/NodeFlattenerTest.kt | 29 +++++++++++++++++++ .../internal/utils/WireframeExtTest.kt | 15 ++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) 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 7d931d78ca..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,7 +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 -> true + is MobileSegment.Wireframe.EmbeddedContentWireframe -> this.isVisible != false } } 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 79ffc47b5d..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 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 bf2a47b966..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 From 0df20401bfc083d427013460d9f9d9a950dc41c3 Mon Sep 17 00:00:00 2001 From: jonathanmos <48201295+jonathanmos@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:09:47 +0300 Subject: [PATCH 03/12] PANA-8412: Add embedded content bridge APIs --- .../api/apiSurface | 4 + .../api/dd-sdk-android-session-replay.api | 16 ++ .../build.gradle.kts | 3 + ...ddedContentSlotRegistryInstrumentedTest.kt | 52 ++++++ .../_SessionReplayInternalProxy.kt | 118 +++++++++++++ .../internal/embedded/EmbeddedContentEvent.kt | 40 +++++ .../embedded/EmbeddedContentSlotRegistry.kt | 57 ++++++ .../internal/recorder/Recorder.kt | 2 + .../recorder/SessionReplayRecorder.kt | 8 + .../recorder/ViewOnDrawInterceptor.kt | 7 + .../src/main/res/values/ids.xml | 2 + .../PrivacyOverrideExtensionsTest.kt | 3 + .../SessionReplayInternalProxyTest.kt | 162 ++++++++++++++++++ .../SessionReplayRecorderTest.kt | 21 +++ .../EmbeddedContentSlotRegistryTest.kt | 105 ++++++++++++ .../recorder/ViewOnDrawInterceptorTest.kt | 16 +- 16 files changed, 615 insertions(+), 1 deletion(-) create mode 100644 features/dd-sdk-android-session-replay/src/androidTest/kotlin/com/datadog/android/sessionreplay/internal/embedded/EmbeddedContentSlotRegistryInstrumentedTest.kt create mode 100644 features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/embedded/EmbeddedContentEvent.kt create mode 100644 features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/embedded/EmbeddedContentSlotRegistry.kt create mode 100644 features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/embedded/EmbeddedContentSlotRegistryTest.kt diff --git a/features/dd-sdk-android-session-replay/api/apiSurface b/features/dd-sdk-android-session-replay/api/apiSurface index 6e785f579d..f30b122d01 100644 --- a/features/dd-sdk-android-session-replay/api/apiSurface +++ b/features/dd-sdk-android-session-replay/api/apiSurface @@ -78,6 +78,10 @@ enum com.datadog.android.sessionreplay.TouchPrivacy : PrivacyLevel class com.datadog.android.sessionreplay._SessionReplayInternalProxy constructor(SessionReplayConfiguration.Builder) fun setInternalCallback(SessionReplayInternalCallback): SessionReplayConfiguration.Builder + companion object + fun setEmbeddedContentSlotId(android.view.View, String?) + fun addEmbeddedContentRecords(List>, String, String, com.datadog.android.api.SdkCore = Datadog.getInstance()) + fun addEmbeddedContentResource(String, ByteArray, String, com.datadog.android.api.SdkCore = Datadog.getInstance()) class com.datadog.android.sessionreplay.internal.TouchPrivacyManager constructor(com.datadog.android.sessionreplay.TouchPrivacy) fun addTouchOverrideArea(android.graphics.Rect, com.datadog.android.sessionreplay.TouchPrivacy) diff --git a/features/dd-sdk-android-session-replay/api/dd-sdk-android-session-replay.api b/features/dd-sdk-android-session-replay/api/dd-sdk-android-session-replay.api index 3e2bfc8ecc..1ea49fa445 100644 --- a/features/dd-sdk-android-session-replay/api/dd-sdk-android-session-replay.api +++ b/features/dd-sdk-android-session-replay/api/dd-sdk-android-session-replay.api @@ -147,10 +147,26 @@ 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 static final fun addEmbeddedContentRecords (Ljava/util/List;Ljava/lang/String;Ljava/lang/String;)V + public static final fun addEmbeddedContentRecords (Ljava/util/List;Ljava/lang/String;Ljava/lang/String;Lcom/datadog/android/api/SdkCore;)V + public static final fun addEmbeddedContentResource (Ljava/lang/String;[BLjava/lang/String;)V + public static final fun addEmbeddedContentResource (Ljava/lang/String;[BLjava/lang/String;Lcom/datadog/android/api/SdkCore;)V + public static final fun setEmbeddedContentSlotId (Landroid/view/View;Ljava/lang/String;)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;)V + 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;)V + public final fun addEmbeddedContentResource (Ljava/lang/String;[BLjava/lang/String;Lcom/datadog/android/api/SdkCore;)V + public static synthetic fun addEmbeddedContentResource$default (Lcom/datadog/android/sessionreplay/_SessionReplayInternalProxy$Companion;Ljava/lang/String;[BLjava/lang/String;Lcom/datadog/android/api/SdkCore;ILjava/lang/Object;)V + public final fun setEmbeddedContentSlotId (Landroid/view/View;Ljava/lang/String;)V +} + public final class com/datadog/android/sessionreplay/internal/TouchPrivacyManager { public fun (Lcom/datadog/android/sessionreplay/TouchPrivacy;)V public final fun addTouchOverrideArea (Landroid/graphics/Rect;Lcom/datadog/android/sessionreplay/TouchPrivacy;)V diff --git a/features/dd-sdk-android-session-replay/build.gradle.kts b/features/dd-sdk-android-session-replay/build.gradle.kts index 84d44ff0e6..24d2eace6d 100644 --- a/features/dd-sdk-android-session-replay/build.gradle.kts +++ b/features/dd-sdk-android-session-replay/build.gradle.kts @@ -48,6 +48,7 @@ plugins { android { defaultConfig { consumerProguardFiles("consumer-rules.pro") + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } namespace = "com.datadog.android.sessionreplay" @@ -73,6 +74,8 @@ dependencies { } testImplementation(testFixtures(project(":dd-sdk-android-core"))) unmock(libs.robolectric) + + androidTestImplementation(libs.bundles.integrationTests) } unMock { diff --git a/features/dd-sdk-android-session-replay/src/androidTest/kotlin/com/datadog/android/sessionreplay/internal/embedded/EmbeddedContentSlotRegistryInstrumentedTest.kt b/features/dd-sdk-android-session-replay/src/androidTest/kotlin/com/datadog/android/sessionreplay/internal/embedded/EmbeddedContentSlotRegistryInstrumentedTest.kt new file mode 100644 index 0000000000..10b22d246e --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/androidTest/kotlin/com/datadog/android/sessionreplay/internal/embedded/EmbeddedContentSlotRegistryInstrumentedTest.kt @@ -0,0 +1,52 @@ +/* + * 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 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.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +internal class EmbeddedContentSlotRegistryInstrumentedTest { + + @Test + fun markedViewRetainsSlotWhenRemovedAndRestored() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val root = FrameLayout(context) + val embeddedView = View(context) + val fakeSlotId = "flutter/slot" + + InstrumentationRegistry.getInstrumentation().runOnMainSync { + root.addView(embeddedView) + _SessionReplayInternalProxy.setEmbeddedContentSlotId(embeddedView, fakeSlotId) + + assertTrue(EmbeddedContentSlotRegistry.hasMarkedSlots()) + + root.removeView(embeddedView) + assertEquals( + fakeSlotId, + embeddedView.getTag(R.id.datadog_session_replay_slot_id) + ) + + root.addView(embeddedView) + assertTrue(EmbeddedContentSlotRegistry.hasMarkedSlots()) + assertTrue(EmbeddedContentSlotRegistry.isSlotMarked(fakeSlotId)) + + _SessionReplayInternalProxy.setEmbeddedContentSlotId(embeddedView, null) + assertFalse(EmbeddedContentSlotRegistry.hasMarkedSlots()) + assertFalse(EmbeddedContentSlotRegistry.isSlotMarked(fakeSlotId)) + } + } +} 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..4d607e6b2f 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.embedded.EmbeddedContentEvent +import com.datadog.android.sessionreplay.internal.embedded.EmbeddedContentSlotRegistration +import com.datadog.android.sessionreplay.internal.embedded.EmbeddedContentSlotRegistry /** * This class exposes internal methods that are used by other Datadog modules and cross platform @@ -35,4 +45,112 @@ 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. + */ + @JvmStatic + @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) + } + view.setTag(R.id.datadog_session_replay_slot_id, slotId) + view.setTag(R.id.datadog_session_replay_slot_registration, newRegistration) + EmbeddedContentSlotRegistry.notifySlotChanged( + previousRegistration = previousRegistration, + newRegistration = newRegistration + ) + @Suppress("UnsafeThirdPartyFunctionCall") // Android documents no exception for scheduling invalidation. + view.postInvalidateOnAnimation() + } + + /** + * Queues Session Replay records produced by an embedded renderer. + */ + @JvmStatic + @AnyThread + @JvmOverloads + 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. + */ + @JvmStatic + @AnyThread + @JvmOverloads + 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) + ?.sendEvent(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/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/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..435271d48e --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/embedded/EmbeddedContentSlotRegistry.kt @@ -0,0 +1,57 @@ +/* + * 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 + +internal class EmbeddedContentSlotRegistration( + val slotId: String +) + +internal object EmbeddedContentSlotRegistry { + private val registrations = mutableListOf>() + + @AnyThread + fun hasMarkedSlots(): Boolean { + return synchronized(registrations) { + removeGarbageCollectedRegistrations() + registrations.isNotEmpty() + } + } + + @AnyThread + fun isSlotMarked(slotId: String): Boolean { + return synchronized(registrations) { + removeGarbageCollectedRegistrations() + registrations.any { it.get()?.slotId == slotId } + } + } + + @UiThread + fun notifySlotChanged( + previousRegistration: EmbeddedContentSlotRegistration?, + newRegistration: EmbeddedContentSlotRegistration? + ) { + synchronized(registrations) { + registrations.removeAll { + val registration = it.get() + registration == null || registration === previousRegistration + } + if (newRegistration != null) { + @Suppress("UnsafeThirdPartyFunctionCall") // WeakReference construction has no documented exception. + val weakRegistration = WeakReference(newRegistration) + registrations += weakRegistration + } + } + } + + private fun removeGarbageCollectedRegistrations() { + registrations.removeAll { it.get() == null } + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/Recorder.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/Recorder.kt index 0e38f0ba20..f928532ae0 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/Recorder.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/Recorder.kt @@ -18,5 +18,7 @@ internal interface Recorder { fun resumeRecorders() + fun requestCapture() + fun stopRecorders() } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/SessionReplayRecorder.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/SessionReplayRecorder.kt index a48055d562..06a5730ad5 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 @@ -287,6 +287,14 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { } } + override fun requestCapture() { + uiHandler.post { + if (shouldRecord) { + viewOnDrawInterceptor.requestCapture() + } + } + } + override fun stopRecorders() { uiHandler.post { viewOnDrawInterceptor.stopIntercepting() diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/ViewOnDrawInterceptor.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/ViewOnDrawInterceptor.kt index 4686ed185e..0ce8f14892 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/ViewOnDrawInterceptor.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/ViewOnDrawInterceptor.kt @@ -63,6 +63,13 @@ internal class ViewOnDrawInterceptor( decorOnDrawListeners.clear() } + fun requestCapture() { + // Copy before callbacks because a capture may change the registered listener collection. + @Suppress("UnsafeThirdPartyFunctionCall") // WeakHashMap access is confined to the UI thread. + val listeners = decorOnDrawListeners.values.toSet() + listeners.forEach { it.onDraw() } + } + private fun stopInterceptingAndRemove(decorViews: List) { decorViews.forEach { decorView -> decorOnDrawListeners.remove(decorView)?.let { listener -> diff --git a/features/dd-sdk-android-session-replay/src/main/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..a9d77bce44 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,7 +6,14 @@ 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.embedded.EmbeddedContentEvent +import com.datadog.android.sessionreplay.internal.embedded.EmbeddedContentSlotRegistration +import com.datadog.android.sessionreplay.internal.embedded.EmbeddedContentSlotRegistry import fr.xgouchet.elmyr.annotation.FloatForgery import fr.xgouchet.elmyr.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension @@ -16,6 +23,12 @@ 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 @Extensions( ExtendWith(MockitoExtension::class), @@ -31,6 +44,15 @@ internal class SessionReplayInternalProxyTest { @Mock lateinit var mockInternalCallback: SessionReplayInternalCallback + @Mock + lateinit var mockView: View + + @Mock + lateinit var mockSdkCore: FeatureSdkCore + + @Mock + lateinit var mockFeatureScope: FeatureScope + @FloatForgery var fakeSampleRate: Float = 0f @@ -48,4 +70,144 @@ internal class SessionReplayInternalProxyTest { assertThat(result).isEqualTo(testedBuilder) assertThat(sessionReplayConfiguration.internalCallback).isEqualTo(mockInternalCallback) } + + @Test + fun `M set slot tag W setEmbeddedContentSlotId`() { + val registrationCaptor = argumentCaptor() + try { + // 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() + assertThat(EmbeddedContentSlotRegistry.isSlotMarked(FAKE_SLOT_ID)).isTrue() + } finally { + registrationCaptor.allValues.firstOrNull()?.let { + EmbeddedContentSlotRegistry.notifySlotChanged(it, null) + } + } + } + + @Test + fun `M unregister previous slot W setEmbeddedContentSlotId { slot is cleared }`() { + // Given + 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) + EmbeddedContentSlotRegistry.notifySlotChanged(null, fakeRegistration) + + // When + _SessionReplayInternalProxy.setEmbeddedContentSlotId(mockView, null) + + // Then + assertThat(EmbeddedContentSlotRegistry.isSlotMarked(FAKE_SLOT_ID)).isFalse() + 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 do nothing W setEmbeddedContentSlotId { slot is already assigned }`() { + // Given + whenever(mockView.getTag(R.id.datadog_session_replay_slot_id)) + .thenReturn(null, FAKE_SLOT_ID) + val registrationCaptor = argumentCaptor() + + try { + // 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() + assertThat(EmbeddedContentSlotRegistry.isSlotMarked(FAKE_SLOT_ID)).isTrue() + } finally { + registrationCaptor.allValues.firstOrNull()?.let { + EmbeddedContentSlotRegistry.notifySlotChanged(it, null) + } + } + } + + @Test + fun `M send record event W addEmbeddedContentRecords`() { + // Given + val nestedData = mutableMapOf(FAKE_RECORD_VALUE_KEY to 10L) + val record = mutableMapOf(FAKE_RECORD_DATA_KEY to nestedData) + val records = mutableListOf>(record) + whenever(mockSdkCore.getFeature(Feature.SESSION_REPLAY_FEATURE_NAME)) doReturn mockFeatureScope + + // 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(mockFeatureScope).sendEvent(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 + val resourceData = byteArrayOf(1, 2, 3) + val expectedResourceData = resourceData.copyOf() + whenever(mockSdkCore.getFeature(Feature.SESSION_REPLAY_FEATURE_NAME)) doReturn mockFeatureScope + + // When + _SessionReplayInternalProxy.addEmbeddedContentResource( + identifier = FAKE_RESOURCE_ID, + resourceData = resourceData, + mimeType = FAKE_MIME_TYPE, + sdkCore = mockSdkCore + ) + resourceData.fill(0) + + // Then + argumentCaptor { + verify(mockFeatureScope).sendEvent(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" + } } 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..f80772dfc7 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 @@ -271,6 +271,27 @@ internal class SessionReplayRecorderTest { ) } + @Test + fun `M request capture W requestCapture { recorder resumed }`() { + // Given + testedSessionReplayRecorder.resumeRecorders() + + // When + testedSessionReplayRecorder.requestCapture() + + // Then + verify(mockViewOnDrawInterceptor).requestCapture() + } + + @Test + fun `M not request capture W requestCapture { recorder stopped }`() { + // When + testedSessionReplayRecorder.requestCapture() + + // Then + verify(mockViewOnDrawInterceptor, never()).requestCapture() + } + @Test fun `M stop intercepting the active windows and decor view W stopRecorders`() { // When 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..8d63b854fc --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/embedded/EmbeddedContentSlotRegistryTest.kt @@ -0,0 +1,105 @@ +/* + * 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 { + + @Test + fun `M track slot W notifySlotChanged { slot is set }`() { + // Given + val fakeRegistration = EmbeddedContentSlotRegistration(FAKE_SLOT_ID) + + try { + // When + EmbeddedContentSlotRegistry.notifySlotChanged(null, fakeRegistration) + + // Then + assertThat(EmbeddedContentSlotRegistry.hasMarkedSlots()).isTrue() + assertThat(EmbeddedContentSlotRegistry.isSlotMarked(FAKE_SLOT_ID)).isTrue() + } finally { + EmbeddedContentSlotRegistry.notifySlotChanged(fakeRegistration, null) + } + } + + @Test + fun `M stop tracking slot W notifySlotChanged { slot is cleared }`() { + // Given + val fakeRegistration = EmbeddedContentSlotRegistration(FAKE_SLOT_ID) + EmbeddedContentSlotRegistry.notifySlotChanged(null, fakeRegistration) + + // When + EmbeddedContentSlotRegistry.notifySlotChanged(fakeRegistration, null) + + // Then + assertThat(EmbeddedContentSlotRegistry.hasMarkedSlots()).isFalse() + assertThat(EmbeddedContentSlotRegistry.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) + EmbeddedContentSlotRegistry.notifySlotChanged(null, fakeOldRegistration) + + try { + // When + EmbeddedContentSlotRegistry.notifySlotChanged( + fakeOldRegistration, + fakeNewRegistration + ) + + // Then + assertThat(EmbeddedContentSlotRegistry.isSlotMarked(FAKE_OLD_SLOT_ID)).isFalse() + assertThat(EmbeddedContentSlotRegistry.isSlotMarked(FAKE_NEW_SLOT_ID)).isTrue() + } finally { + EmbeddedContentSlotRegistry.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) + EmbeddedContentSlotRegistry.notifySlotChanged(null, fakeStaleRegistration) + EmbeddedContentSlotRegistry.notifySlotChanged(null, fakeCurrentRegistration) + + try { + // When + EmbeddedContentSlotRegistry.notifySlotChanged(fakeStaleRegistration, null) + + // Then + assertThat(EmbeddedContentSlotRegistry.isSlotMarked(FAKE_SLOT_ID)).isTrue() + } finally { + EmbeddedContentSlotRegistry.notifySlotChanged(fakeCurrentRegistration, null) + } + } + + private companion object { + const val FAKE_SLOT_ID = "slot-id" + const val FAKE_OLD_SLOT_ID = "old-slot" + const val FAKE_NEW_SLOT_ID = "new-slot" + } +} diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/ViewOnDrawInterceptorTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/ViewOnDrawInterceptorTest.kt index 22f0857bfd..455312c198 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/ViewOnDrawInterceptorTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/ViewOnDrawInterceptorTest.kt @@ -27,6 +27,7 @@ import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.clearInvocations import org.mockito.kotlin.doReturn import org.mockito.kotlin.doThrow import org.mockito.kotlin.inOrder @@ -158,6 +159,19 @@ internal class ViewOnDrawInterceptorTest { } } + @Test + fun `M invoke active listener once W requestCapture { multiple decor views }`() { + // Given + testedInterceptor.intercept(fakeDecorViews, fakeTextAndInputPrivacy, fakeImagePrivacy) + clearInvocations(mockOnDrawListener) + + // When + testedInterceptor.requestCapture() + + // Then + verify(mockOnDrawListener).onDraw() + } + @Test fun `M do nothing W intercept() { view tree observer is not alive }`() { // Given @@ -283,7 +297,7 @@ internal class ViewOnDrawInterceptorTest { // region Internal private fun Forge.aMockedDecorViewsList(): List { - return aList { + return aList(size = 2) { mock { val mockViewTreeObserver: ViewTreeObserver = mock() whenever(mockViewTreeObserver.isAlive) doReturn true From 40e3a9987c5b0ca4966ea004ef895126f8437b67 Mon Sep 17 00:00:00 2001 From: jonathanmos <48201295+jonathanmos@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:10:10 +0300 Subject: [PATCH 04/12] PANA-8412: Record embedded content wireframes --- .../semantics/RootSemanticsNodeMapperTest.kt | 8 +- .../internal/processor/MutationResolver.kt | 10 - .../internal/processor/WireframeUtils.kt | 18 +- .../recorder/SessionReplayRecorder.kt | 17 +- .../internal/recorder/SnapshotProducer.kt | 14 +- .../internal/recorder/TreeViewTraversal.kt | 17 +- .../listener/WindowsOnDrawListener.kt | 8 +- .../mapper/EmbeddedContentViewMapper.kt | 178 ++++++++++ .../SessionReplayRecorderTest.kt | 5 +- .../processor/MutationResolverTest.kt | 40 +-- .../internal/processor/WireframeUtilsTest.kt | 36 ++ .../internal/recorder/SnapshotProducerTest.kt | 28 ++ .../recorder/TreeViewTraversalTest.kt | 196 +++++++++++ .../listener/WindowsOnDrawListenerTest.kt | 19 ++ .../mapper/EmbeddedContentViewMapperTest.kt | 309 ++++++++++++++++++ 15 files changed, 850 insertions(+), 53 deletions(-) create mode 100644 features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/EmbeddedContentViewMapper.kt create mode 100644 features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/EmbeddedContentViewMapperTest.kt 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/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/MutationResolver.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/MutationResolver.kt index 674501a303..78ff0bdb24 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/MutationResolver.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/MutationResolver.kt @@ -150,16 +150,6 @@ internal class MutationResolver(private val internalLogger: InternalLogger) { ) ) } - } else if (oldWireframe is MobileSegment.Wireframe.EmbeddedContentWireframe) { - if (oldWireframe.isVisible != false) { - updates.add( - MobileSegment.WireframeUpdateMutation.EmbeddedContentWireframeUpdate( - id = oldWireframe.id, - slotId = oldWireframe.slotId, - isVisible = false - ) - ) - } } else { // Old element was removed removes.add(MobileSegment.Remove(oldWireframe.id())) diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/WireframeUtils.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/WireframeUtils.kt index 23badf5080..ae11a77aec 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/WireframeUtils.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/WireframeUtils.kt @@ -46,19 +46,25 @@ internal class WireframeUtils(private val boundsUtils: BoundsUtils = BoundsUtils wireframe: MobileSegment.Wireframe, topWireframes: List ): Boolean { + if (wireframe is MobileSegment.Wireframe.EmbeddedContentWireframe && + wireframe.isVisible == false + ) { + return false + } val wireframeBounds = boundsUtils.resolveBounds(wireframe) - topWireframes.forEach { + return topWireframes.any { val topBounds = boundsUtils.resolveBounds(it) - if (boundsUtils.isCovering(topBounds, wireframeBounds) && + boundsUtils.isCovering(topBounds, wireframeBounds) && it.hasOpaqueBackground() - ) { - return true - } } - return false } internal fun checkWireframeIsValid(wireframe: MobileSegment.Wireframe): Boolean { + if (wireframe is MobileSegment.Wireframe.EmbeddedContentWireframe && + wireframe.isVisible == false + ) { + return true + } val wireframeBounds = boundsUtils.resolveBounds(wireframe) return ( wireframeBounds.width > 0 && diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/SessionReplayRecorder.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/SessionReplayRecorder.kt index 06a5730ad5..caeb0eb6ef 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 @@ -31,6 +31,7 @@ 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 @@ -138,6 +139,14 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { viewBoundsResolver, drawableToColorMapper ) + val viewUtilsInternal = ViewUtilsInternal() + val embeddedContentViewMapper = EmbeddedContentViewMapper( + viewIdentifierResolver = viewIdentifierResolver, + colorStringFormatter = colorStringFormatter, + viewBoundsResolver = viewBoundsResolver, + drawableToColorMapper = drawableToColorMapper, + viewUtilsInternal = viewUtilsInternal + ) val bitmapCachesManager = BitmapCachesManager( bitmapPool = BitmapPool(), @@ -180,8 +189,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 +204,8 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { registry = it, internalLogger = internalLogger ) - } + }, + embeddedContentViewMapper = embeddedContentViewMapper ), recordedDataQueueHandler = recordedDataQueueHandler, sdkCore = sdkCore, 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/listener/WindowsOnDrawListener.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/listener/WindowsOnDrawListener.kt index aaab521e14..8400b5b425 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/listener/WindowsOnDrawListener.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/listener/WindowsOnDrawListener.kt @@ -72,7 +72,8 @@ internal class WindowsOnDrawListener( withinSRBenchmarkSpan(BENCHMARK_SPAN_SNAPSHOT_PRODUCER, isContainer = true) { val recordedDataQueueRefs = RecordedDataQueueRefs(recordedDataQueueHandler) recordedDataQueueRefs.recordedDataQueueItem = item - rootViews.mapNotNull { + snapshotProducer.beginSnapshot() + val snapshotNodes = rootViews.mapNotNull { snapshotProducer.produce( rootView = it, systemInformation = systemInformation, @@ -82,6 +83,11 @@ internal class WindowsOnDrawListener( activeRumViewUrl = currentViewUrl ) } + snapshotProducer.finishSnapshot()?.let { + @Suppress("UnsafeThirdPartyFunctionCall") // Kotlin listOf cannot fail for this local value. + val finishSnapshotNodes = listOf(it) + finishSnapshotNodes + snapshotNodes + } ?: snapshotNodes } } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/EmbeddedContentViewMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/EmbeddedContentViewMapper.kt new file mode 100644 index 0000000000..25b69cf190 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/EmbeddedContentViewMapper.kt @@ -0,0 +1,178 @@ +/* + * 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.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 +) : BaseWireframeMapper( + viewIdentifierResolver, + colorStringFormatter, + viewBoundsResolver, + drawableToColorMapper +) { + private val cache = EmbeddedContentViewCache() + 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 slotId = view.sessionReplaySlotId() ?: return emptyList() + isSnapshotActive = true + val wireframeId = viewIdentifierResolver.resolveChildUniqueIdentifier( + view, + EMBEDDED_CONTENT_KEY_NAME + ) ?: return emptyList() + cache.record(slotId, wireframeId) + + if (viewUtilsInternal.isNotVisible(view)) { + return wireframeList(hiddenWireframe(wireframeId, slotId)) + } + + val bounds = viewBoundsResolver.resolveViewGlobalBounds( + view, + mappingContext.systemInformation.screenDensity + ) + val shapeStyle = view.background?.let { + resolveShapeStyle(it, view.alpha, internalLogger) + } + + return wireframeList( + MobileSegment.Wireframe.EmbeddedContentWireframe( + id = wireframeId, + x = bounds.x, + y = bounds.y, + width = bounds.width, + height = bounds.height, + shapeStyle = shapeStyle, + slotId = slotId, + isVisible = true + ) + ) + } + + @Suppress("UnsafeThirdPartyFunctionCall") // Kotlin listOf cannot fail for this local value. + private fun wireframeList(wireframe: MobileSegment.Wireframe): List { + return listOf(wireframe) + } + + private fun hiddenWireframe( + wireframeId: Long, + slotId: String + ): MobileSegment.Wireframe.EmbeddedContentWireframe { + return MobileSegment.Wireframe.EmbeddedContentWireframe( + id = wireframeId, + x = 0, + y = 0, + width = 0, + height = 0, + slotId = slotId, + isVisible = false + ) + } + + private fun View.sessionReplaySlotId(): String? { + return getTag(R.id.datadog_session_replay_slot_id) as? String + } + + internal companion object { + internal const val EMBEDDED_CONTENT_KEY_NAME = "embedded_content" + } + + private class EmbeddedContentViewCache { + private data class Entry( + val wireframeId: Long, + var lastSeenSnapshot: Long + ) + + private val entries = mutableMapOf() + private var currentSnapshot: Long = 0 + + @UiThread + fun beginSnapshot() { + currentSnapshot++ + } + + @UiThread + fun isEmpty(): Boolean = entries.isEmpty() + + @UiThread + fun record(slotId: String, wireframeId: Long) { + entries[slotId] = Entry(wireframeId, currentSnapshot) + } + + @UiThread + // Iterator is locally guarded by hasNext; this mutable map supports iterator removal. + @Suppress("UnsafeThirdPartyFunctionCall") + fun hiddenWireframes(): List { + val hiddenWireframes = mutableListOf() + val iterator = entries.entries.iterator() + while (iterator.hasNext()) { + val (slotId, entry) = iterator.next() + if (!EmbeddedContentSlotRegistry.isSlotMarked(slotId)) { + iterator.remove() + } else if (entry.lastSeenSnapshot != currentSnapshot) { + hiddenWireframes += MobileSegment.Wireframe.EmbeddedContentWireframe( + id = entry.wireframeId, + x = 0, + y = 0, + width = 0, + height = 0, + slotId = slotId, + isVisible = false + ) + } + } + return hiddenWireframes + } + } +} diff --git a/features/dd-sdk-android-session-replay/src/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 f80772dfc7..ed017bee26 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/SessionReplayRecorderTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/SessionReplayRecorderTest.kt @@ -258,7 +258,7 @@ internal class SessionReplayRecorderTest { } @Test - fun `M intercept the active windows and decor view W resumeRecorders`() { + fun `M intercept active windows without invalidating native decors W resumeRecorders`() { // When testedSessionReplayRecorder.resumeRecorders() @@ -269,6 +269,9 @@ internal class SessionReplayRecorderTest { textAndInputPrivacy = fakeTextAndInputPrivacy, imagePrivacy = fakeImagePrivacy ) + fakeActiveWindowsDecorViews.forEach { + verify(it, never()).postInvalidateOnAnimation() + } } @Test diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/MutationResolverTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/MutationResolverTest.kt index 7028457314..5d8d4571fa 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/MutationResolverTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/MutationResolverTest.kt @@ -716,23 +716,18 @@ internal class MutationResolverTest { // endregion - // region EmbeddedContent "remove" mutations + // region EmbeddedContent remove mutations @Test - fun `M identify the updated wireframes W resolveMutations {EmbeddedContent removed at beginning}`(forge: Forge) { + fun `M identify removed wireframes W resolveMutations {EmbeddedContent removed at beginning}`(forge: Forge) { // Given val fakePrevSnapshot = forge.aList(size = forge.anInt(min = 3, max = 10)) { forge.getForgery(MobileSegment.Wireframe.EmbeddedContentWireframe::class.java) } - val fakeHiddenWireframes = forge.anInt(min = 1, max = fakePrevSnapshot.size - 1) - val fakeCurrentSnapshot = fakePrevSnapshot.drop(fakeHiddenWireframes) - val expectedUpdates = fakePrevSnapshot.take(fakeHiddenWireframes).map { - MobileSegment.WireframeUpdateMutation.EmbeddedContentWireframeUpdate( - id = it.id(), - slotId = it.slotId, - isVisible = false - ) - } + val fakeRemovedWireframes = forge.anInt(min = 1, max = fakePrevSnapshot.size - 1) + val fakeCurrentSnapshot = fakePrevSnapshot.drop(fakeRemovedWireframes) + val expectedRemoves = fakePrevSnapshot.take(fakeRemovedWireframes) + .map { MobileSegment.Remove(it.id()) } // When val mutations = testedMutationResolver.resolveMutations( @@ -742,25 +737,20 @@ internal class MutationResolverTest { // Then assertThat(mutations?.adds).isNullOrEmpty() - assertThat(mutations?.removes).isNullOrEmpty() - assertThat(mutations?.updates).isEqualTo(expectedUpdates) + assertThat(mutations?.removes).isEqualTo(expectedRemoves) + assertThat(mutations?.updates).isNullOrEmpty() } @Test - fun `M identify the updated wireframes W resolveMutations {EmbeddedContent removed at end}`(forge: Forge) { + fun `M identify removed wireframes W resolveMutations {EmbeddedContent removed at end}`(forge: Forge) { // Given val fakePrevSnapshot = forge.aList(size = forge.anInt(min = 3, max = 10)) { forge.getForgery(MobileSegment.Wireframe.EmbeddedContentWireframe::class.java) } - val fakeHiddenWireframes = forge.anInt(min = 1, max = fakePrevSnapshot.size - 1) - val fakeCurrentSnapshot = fakePrevSnapshot.dropLast(fakeHiddenWireframes) - val expectedUpdates = fakePrevSnapshot.takeLast(fakeHiddenWireframes).map { - MobileSegment.WireframeUpdateMutation.EmbeddedContentWireframeUpdate( - id = it.id(), - slotId = it.slotId, - isVisible = false - ) - } + val fakeRemovedWireframes = forge.anInt(min = 1, max = fakePrevSnapshot.size - 1) + val fakeCurrentSnapshot = fakePrevSnapshot.dropLast(fakeRemovedWireframes) + val expectedRemoves = fakePrevSnapshot.takeLast(fakeRemovedWireframes) + .map { MobileSegment.Remove(it.id()) } // When val mutations = testedMutationResolver.resolveMutations( @@ -770,8 +760,8 @@ internal class MutationResolverTest { // Then assertThat(mutations?.adds).isNullOrEmpty() - assertThat(mutations?.removes).isNullOrEmpty() - assertThat(mutations?.updates).isEqualTo(expectedUpdates) + assertThat(mutations?.removes).isEqualTo(expectedRemoves) + assertThat(mutations?.updates).isNullOrEmpty() } // endregion diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/WireframeUtilsTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/WireframeUtilsTest.kt index 281e22951e..6ba9b2ff46 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/WireframeUtilsTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/WireframeUtilsTest.kt @@ -581,6 +581,19 @@ internal class WireframeUtilsTest { .isFalse } + @Test + fun `M return false W checkWireframeIsCovered { hidden embedded content }`( + @Forgery fakeWireframe: MobileSegment.Wireframe.EmbeddedContentWireframe, + @Forgery topWireframes: List + ) { + // Given + val hiddenWireframe = fakeWireframe.copy(isVisible = false) + + // Then + assertThat(testedWireframeUtils.checkWireframeIsCovered(hiddenWireframe, topWireframes)) + .isFalse + } + // endregion // region checkWireframesIsValid @@ -591,6 +604,10 @@ internal class WireframeUtilsTest { forge: Forge ) { // Given + assumeTrue( + fakeWireframe !is MobileSegment.Wireframe.EmbeddedContentWireframe || + fakeWireframe.isVisible != false + ) whenever(mockBoundsUtils.resolveBounds(fakeWireframe)) .thenReturn(forge.getForgery().copy(width = 0)) @@ -604,6 +621,10 @@ internal class WireframeUtilsTest { forge: Forge ) { // Given + assumeTrue( + fakeWireframe !is MobileSegment.Wireframe.EmbeddedContentWireframe || + fakeWireframe.isVisible != false + ) whenever(mockBoundsUtils.resolveBounds(fakeWireframe)) .thenReturn(forge.getForgery().copy(height = 0)) @@ -611,6 +632,21 @@ internal class WireframeUtilsTest { assertThat(testedWireframeUtils.checkWireframeIsValid(fakeWireframe)).isFalse } + @Test + fun `M return true W checkWireframeIsValid { hidden embedded content has empty bounds }`( + @Forgery fakeWireframe: MobileSegment.Wireframe.EmbeddedContentWireframe + ) { + // Given + val hiddenWireframe = fakeWireframe.copy( + width = 0, + height = 0, + isVisible = false + ) + + // Then + assertThat(testedWireframeUtils.checkWireframeIsValid(hiddenWireframe)).isTrue + } + @Test fun `M return false W checkWireframeIsValid(){ shape wireframe with no border and background }`( forge: Forge diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/SnapshotProducerTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/SnapshotProducerTest.kt index c85287ca79..ba264b60e7 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/SnapshotProducerTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/SnapshotProducerTest.kt @@ -16,6 +16,7 @@ import com.datadog.android.sessionreplay.forge.ForgeConfigurator import com.datadog.android.sessionreplay.internal.TouchPrivacyManager import com.datadog.android.sessionreplay.internal.async.RecordedDataQueueRefs import com.datadog.android.sessionreplay.internal.recorder.SnapshotProducer.Companion.INVALID_PRIVACY_LEVEL_ERROR +import com.datadog.android.sessionreplay.internal.recorder.mapper.EmbeddedContentViewMapper import com.datadog.android.sessionreplay.model.MobileSegment import com.datadog.android.sessionreplay.recorder.MappingContext import com.datadog.android.sessionreplay.recorder.SystemInformation @@ -72,6 +73,9 @@ internal class SnapshotProducerTest { @Mock lateinit var mockTouchPrivacyManager: TouchPrivacyManager + @Mock + lateinit var mockEmbeddedContentViewMapper: EmbeddedContentViewMapper + @Forgery lateinit var fakeSystemInformation: SystemInformation @@ -95,6 +99,30 @@ internal class SnapshotProducerTest { ) } + @Test + fun `M delegate embedded lifecycle W beginSnapshot and finishSnapshot`() { + // Given + val hiddenNode = Node(wireframes = fakeViewWireframes) + testedSnapshotProducer = SnapshotProducer( + mockImageWireframeHelper, + mockTreeViewTraversal, + mockOptionSelectorDetector, + mockTouchPrivacyManager, + mockInternalLogger, + embeddedContentViewMapper = mockEmbeddedContentViewMapper + ) + whenever(mockEmbeddedContentViewMapper.finishSnapshot()).thenReturn(hiddenNode) + + // When + testedSnapshotProducer.beginSnapshot() + val result = testedSnapshotProducer.finishSnapshot() + + // Then + verify(mockEmbeddedContentViewMapper).beginSnapshot() + verify(mockEmbeddedContentViewMapper).finishSnapshot() + assertThat(result).isSameAs(hiddenNode) + } + @Test fun `M produce a null Node W produce(){ STOP_AND_DROP strategy }`() { // Given diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/TreeViewTraversalTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/TreeViewTraversalTest.kt index 800bea5130..8484d54028 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/TreeViewTraversalTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/TreeViewTraversalTest.kt @@ -20,10 +20,14 @@ import com.datadog.android.api.InternalLogger import com.datadog.android.api.feature.measureMethodCallPerf import com.datadog.android.core.metrics.MethodCallSamplingRate import com.datadog.android.sessionreplay.MapperTypeWrapper +import com.datadog.android.sessionreplay.R +import com.datadog.android.sessionreplay.TouchPrivacy import com.datadog.android.sessionreplay.forge.ForgeConfigurator import com.datadog.android.sessionreplay.internal.async.RecordedDataQueueRefs import com.datadog.android.sessionreplay.internal.recorder.TreeViewTraversal.Companion.METHOD_CALL_MAP_PREFIX +import com.datadog.android.sessionreplay.internal.recorder.callback.DefaultInteropViewCallback import com.datadog.android.sessionreplay.internal.recorder.mapper.DecorViewMapper +import com.datadog.android.sessionreplay.internal.recorder.mapper.EmbeddedContentViewMapper import com.datadog.android.sessionreplay.internal.recorder.mapper.HiddenViewMapper import com.datadog.android.sessionreplay.internal.recorder.mapper.ViewWireframeMapper import com.datadog.android.sessionreplay.model.MobileSegment @@ -46,7 +50,9 @@ import org.mockito.kotlin.any import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever import org.mockito.quality.Strictness @@ -147,6 +153,126 @@ internal class TreeViewTraversalTest { .isEqualTo(TraversalStrategy.STOP_AND_RETURN_NODE) } + @Test + fun `M use embedded mapper W traverse { tagged view has specialized mapper }`(forge: Forge) { + // Given + val mockView = forge.aMockView() + val mockCustomMapper = mock>() + val mockEmbeddedContentViewMapper = mock() + val fakeWireframes: List = forge.aList { getForgery() } + whenever(mockEmbeddedContentViewMapper.hasSlotId(mockView)) doReturn true + whenever( + mockEmbeddedContentViewMapper.map( + eq(mockView), + eq(fakeMappingContext), + any(), + eq(mockInternalLogger) + ) + ) doReturn fakeWireframes + testedTreeViewTraversal = TreeViewTraversal( + mappers = listOf(MapperTypeWrapper(TextView::class.java, mockCustomMapper)), + defaultViewMapper = mockDefaultViewMapper, + hiddenViewMapper = mockHiddenViewMapper, + decorViewMapper = mockDecorViewMapper, + viewUtilsInternal = mockViewUtilsInternal, + internalLogger = mockInternalLogger, + embeddedContentViewMapper = mockEmbeddedContentViewMapper + ) + + // When + val result = testedTreeViewTraversal.traverse( + mockView, + fakeMappingContext, + mockRecordedDataQueueRefs + ) + + // Then + assertThat(result.mappedWireframes).isEqualTo(fakeWireframes) + assertThat(result.nextActionStrategy).isEqualTo(TraversalStrategy.STOP_AND_RETURN_NODE) + verifyNoInteractions(mockCustomMapper) + } + + @Test + fun `M use embedded mapper W map { Compose interop view has slot ID }`(forge: Forge) { + // Given + val mockInteropView = forge.aMockView() + val mockEmbeddedContentViewMapper = mock() + val fakeEmbeddedWireframe: MobileSegment.Wireframe.EmbeddedContentWireframe = forge.getForgery() + whenever(mockEmbeddedContentViewMapper.hasSlotId(mockInteropView)) doReturn true + whenever( + mockEmbeddedContentViewMapper.map( + eq(mockInteropView), + eq(fakeMappingContext), + any(), + eq(mockInternalLogger) + ) + ) doReturn listOf(fakeEmbeddedWireframe) + testedTreeViewTraversal = TreeViewTraversal( + mappers = emptyList(), + defaultViewMapper = mockDefaultViewMapper, + hiddenViewMapper = mockHiddenViewMapper, + decorViewMapper = mockDecorViewMapper, + viewUtilsInternal = mockViewUtilsInternal, + internalLogger = mockInternalLogger, + embeddedContentViewMapper = mockEmbeddedContentViewMapper + ) + val interopViewCallback = DefaultInteropViewCallback( + treeViewTraversal = testedTreeViewTraversal, + recordedDataQueueRefs = mockRecordedDataQueueRefs + ) + + // When + val result = interopViewCallback.map(mockInteropView, fakeMappingContext) + + // Then + assertThat(result).containsExactly(fakeEmbeddedWireframe) + verify(mockEmbeddedContentViewMapper).map( + eq(mockInteropView), + eq(fakeMappingContext), + any(), + eq(mockInternalLogger) + ) + verifyNoInteractions(mockDefaultViewMapper, mockHiddenViewMapper, mockDecorViewMapper) + } + + @Test + fun `M use hidden mapper W traverse { tagged view has privacy hide override }`(forge: Forge) { + // Given + val mockView = forge.aMockView() + val mockEmbeddedContentViewMapper = mock() + val fakeWireframes: List = forge.aList { getForgery() } + whenever(mockView.getTag(R.id.datadog_hidden)) doReturn true + whenever( + mockHiddenViewMapper.map( + eq(mockView), + eq(fakeMappingContext), + any(), + eq(mockInternalLogger) + ) + ) doReturn fakeWireframes + testedTreeViewTraversal = TreeViewTraversal( + mappers = emptyList(), + defaultViewMapper = mockDefaultViewMapper, + hiddenViewMapper = mockHiddenViewMapper, + decorViewMapper = mockDecorViewMapper, + viewUtilsInternal = mockViewUtilsInternal, + internalLogger = mockInternalLogger, + embeddedContentViewMapper = mockEmbeddedContentViewMapper + ) + + // When + val result = testedTreeViewTraversal.traverse( + mockView, + fakeMappingContext, + mockRecordedDataQueueRefs + ) + + // Then + assertThat(result.mappedWireframes).isEqualTo(fakeWireframes) + assertThat(result.nextActionStrategy).isEqualTo(TraversalStrategy.STOP_AND_RETURN_NODE) + verify(mockEmbeddedContentViewMapper, never()).map(any(), any(), any(), any()) + } + @Test fun `M default to view mapper and stop W traverse { mapper for view type not provided }`( forge: Forge @@ -359,6 +485,76 @@ internal class TreeViewTraversalTest { .isEqualTo(TraversalStrategy.STOP_AND_DROP_NODE) } + @Test + fun `M preserve native traversal W traverse { unmarked view is not visible }`(forge: Forge) { + // Given + val mockEmbeddedContentViewMapper = mock() + val fakeRoot = forge.aMockView().apply { + whenever(mockViewUtilsInternal.isNotVisible(this)).thenReturn(true) + whenever(getTag(R.id.datadog_touch_privacy)).thenReturn(TouchPrivacy.HIDE.name) + } + testedTreeViewTraversal = TreeViewTraversal( + mappers = emptyList(), + defaultViewMapper = mockDefaultViewMapper, + hiddenViewMapper = mockHiddenViewMapper, + decorViewMapper = mockDecorViewMapper, + viewUtilsInternal = mockViewUtilsInternal, + internalLogger = mockInternalLogger, + embeddedContentViewMapper = mockEmbeddedContentViewMapper + ) + + // When + val result = testedTreeViewTraversal.traverse( + fakeRoot, + fakeMappingContext, + mockRecordedDataQueueRefs + ) + + // Then + assertThat(result.mappedWireframes).isEmpty() + assertThat(result.nextActionStrategy).isEqualTo(TraversalStrategy.STOP_AND_DROP_NODE) + verifyNoInteractions(fakeMappingContext.touchPrivacyManager) + verifyNoInteractions(mockDefaultViewMapper, mockHiddenViewMapper, mockDecorViewMapper) + } + + @Test + fun `M map embedded content W traverse { marked view is not visible }`(forge: Forge) { + // Given + val fakeRoot = forge.aMockView() + val mockEmbeddedContentViewMapper = mock() + val fakeWireframes: List = forge.aList { getForgery() } + whenever(mockViewUtilsInternal.isNotVisible(fakeRoot)).thenReturn(true) + whenever(mockEmbeddedContentViewMapper.hasSlotId(fakeRoot)).thenReturn(true) + whenever( + mockEmbeddedContentViewMapper.map( + eq(fakeRoot), + eq(fakeMappingContext), + any(), + eq(mockInternalLogger) + ) + ).thenReturn(fakeWireframes) + testedTreeViewTraversal = TreeViewTraversal( + mappers = emptyList(), + defaultViewMapper = mockDefaultViewMapper, + hiddenViewMapper = mockHiddenViewMapper, + decorViewMapper = mockDecorViewMapper, + viewUtilsInternal = mockViewUtilsInternal, + internalLogger = mockInternalLogger, + embeddedContentViewMapper = mockEmbeddedContentViewMapper + ) + + // When + val result = testedTreeViewTraversal.traverse( + fakeRoot, + fakeMappingContext, + mockRecordedDataQueueRefs + ) + + // Then + assertThat(result.mappedWireframes).isEqualTo(fakeWireframes) + assertThat(result.nextActionStrategy).isEqualTo(TraversalStrategy.STOP_AND_RETURN_NODE) + } + // endregion // region System Noise diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/listener/WindowsOnDrawListenerTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/listener/WindowsOnDrawListenerTest.kt index 048b7708c2..33cdbcf883 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/listener/WindowsOnDrawListenerTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/listener/WindowsOnDrawListenerTest.kt @@ -229,6 +229,25 @@ internal class WindowsOnDrawListenerTest { verify(mockRecordedDataQueueHandler).tryToConsumeItems() } + @Test + fun `M prepend hidden embedded node W onDraw { embedded view missing from snapshot }`() { + // Given + val hiddenEmbeddedNode = Node(wireframes = emptyList()) + whenever(mockRecordedDataQueueHandler.addSnapshotItem(any())) + .thenReturn(fakeSnapshotQueueItem) + whenever(mockSnapshotProducer.finishSnapshot()).thenReturn(hiddenEmbeddedNode) + fakeSnapshotQueueItem.pendingJobs.set(0) + + // When + testedListener.onDraw() + + // Then + verify(mockSnapshotProducer).beginSnapshot() + verify(mockSnapshotProducer).finishSnapshot() + assertThat(fakeSnapshotQueueItem.nodes) + .containsExactlyElementsOf(listOf(hiddenEmbeddedNode) + fakeWindowsSnapshots) + } + @Test fun `M do nothing W onDraw(){ windows are empty }`() { // When diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/EmbeddedContentViewMapperTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/EmbeddedContentViewMapperTest.kt new file mode 100644 index 0000000000..3362b1b24b --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/EmbeddedContentViewMapperTest.kt @@ -0,0 +1,309 @@ +/* + * 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 var slotId: String? = null + + @BeforeEach + fun `set up`() { + fakeSlotRegistration = EmbeddedContentSlotRegistration(fakeSlotId) + slotId = fakeSlotId + whenever(mockView.getTag(R.id.datadog_session_replay_slot_id)) doAnswer { slotId } + 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.notifySlotChanged(null, fakeSlotRegistration) + } + + @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(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 + 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() + } +} From 9903d3ab8d5bfed2dfbc72c2f668a9957bfc1867 Mon Sep 17 00:00:00 2001 From: jonathanmos <48201295+jonathanmos@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:11:53 +0300 Subject: [PATCH 05/12] PANA-8412: Receive embedded Session Replay data --- .../internal/SessionReplayFeature.kt | 61 +++- .../embedded/EmbeddedContentReceiver.kt | 103 +++++++ .../processor/RecordedDataProcessor.kt | 29 +- .../internal/processor/ResourceProcessor.kt | 30 ++ .../resources/ResourceDataStoreManager.kt | 26 +- .../storage/EmbeddedContentRecordWriter.kt | 11 + .../storage/SessionReplayRecordWriter.kt | 24 +- .../internal/utils/SessionReplayRumContext.kt | 7 +- .../internal/SessionReplayFeatureTest.kt | 90 ++++++ .../embedded/EmbeddedContentReceiverTest.kt | 262 ++++++++++++++++++ .../processor/RecordedDataProcessorTest.kt | 38 +-- .../processor/ResourceProcessorTest.kt | 107 +++++++ .../ResourceDataStoreManagerMigrationTest.kt | 2 +- .../resources/ResourceDataStoreManagerTest.kt | 140 +++++++++- .../storage/SessionReplayRecordWriterTest.kt | 70 ++++- .../utils/SessionReplayRumContextTest.kt | 44 +++ 16 files changed, 954 insertions(+), 90 deletions(-) create mode 100644 features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/embedded/EmbeddedContentReceiver.kt create mode 100644 features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/ResourceProcessor.kt create mode 100644 features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/storage/EmbeddedContentRecordWriter.kt create mode 100644 features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/embedded/EmbeddedContentReceiverTest.kt create mode 100644 features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/ResourceProcessorTest.kt 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..2fcbc502e0 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 @@ -25,13 +25,17 @@ 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.net.BatchesToSegmentsMapper import com.datadog.android.sessionreplay.internal.net.SegmentRequestFactory +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.NoOpRecordWriter import com.datadog.android.sessionreplay.internal.storage.RecordWriter import com.datadog.android.sessionreplay.internal.storage.SessionReplayRecordWriter @@ -121,7 +125,17 @@ internal class SessionReplayFeature( internal var sessionReplayRecorder: Recorder = NoOpRecorder() internal var dataWriter: RecordWriter = NoOpRecordWriter() internal val initialized = AtomicBoolean(false) + private val rumContextProvider = SessionReplayRumContextProvider() + private var resourceProcessor: ResourceProcessor? = null + private var embeddedContentRecordWriter: EmbeddedContentRecordWriter? = null + private val embeddedContentReceiver = EmbeddedContentReceiver( + rumContextProvider = rumContextProvider, + recordWriter = { embeddedContentRecordWriter }, + resourceProcessor = { resourceProcessor }, + isRecording = { isRecording.get() }, + internalLogger = sdkCore.internalLogger + ) // region Feature @@ -144,7 +158,13 @@ internal class SessionReplayFeature( resourceHashesDeserializer = ResourceHashesEntryDeserializer(internalLogger = sdkCore.internalLogger) ) - dataWriter = createDataWriter() + val sessionReplayRecordWriter = createDataWriter() + dataWriter = sessionReplayRecordWriter + embeddedContentRecordWriter = sessionReplayRecordWriter + resourceProcessor = ResourceProcessor( + resourceDataStoreManager = resourceDataStoreManager, + resourcesWriter = resourcesFeature.dataWriter + ) sdkCore.setContextUpdateReceiver(rumContextProvider) sessionReplayRecorder = recorderProvider.provideSessionReplayRecorder( @@ -182,6 +202,8 @@ internal class SessionReplayFeature( sessionReplayRecorder.unregisterCallbacks() sessionReplayRecorder.stopProcessingRecords() dataWriter = NoOpRecordWriter() + embeddedContentRecordWriter = null + resourceProcessor = null sessionReplayRecorder = NoOpRecorder() initialized.set(false) } @@ -191,20 +213,29 @@ 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 - } - - if (!checkIfInitialized()) { - return + when (event) { + is EmbeddedContentEvent -> { + if (checkIfInitialized()) { + sdkCore.getFeature(Feature.SESSION_REPLAY_FEATURE_NAME)?.withContext { + if (initialized.get()) { + embeddedContentReceiver.receive(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) } + ) + } } - - handleRumSession(event) } // endregion @@ -364,7 +395,7 @@ internal class SessionReplayFeature( } } - private fun createDataWriter(): RecordWriter { + private fun createDataWriter(): SessionReplayRecordWriter { val recordCallback = SessionReplayRecordCallback(sdkCore) return SessionReplayRecordWriter(sdkCore, recordCallback) } 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..30e2749ca7 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/embedded/EmbeddedContentReceiver.kt @@ -0,0 +1,103 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.embedded + +import com.datadog.android.api.InternalLogger +import com.datadog.android.core.internal.utils.JsonSerializer +import com.datadog.android.sessionreplay.internal.processor.ResourceProcessor +import com.datadog.android.sessionreplay.internal.storage.EmbeddedContentRecordWriter +import com.datadog.android.sessionreplay.internal.utils.RumContextProvider +import com.google.gson.JsonArray +import com.google.gson.JsonObject + +internal class EmbeddedContentReceiver( + private val rumContextProvider: RumContextProvider, + private val recordWriter: () -> EmbeddedContentRecordWriter?, + private val resourceProcessor: () -> ResourceProcessor?, + private val isRecording: () -> Boolean, + private val internalLogger: InternalLogger +) { + fun receive(event: EmbeddedContentEvent) { + if (!isRecording()) { + return + } + when (event) { + is EmbeddedContentEvent.RecordBatch -> { + if (event.records.isNotEmpty()) { + process(event) + } + } + + is EmbeddedContentEvent.Resource -> { + if (rumContextProvider.getRumContext().hasValidApplicationAndSession()) { + process(event) + } + } + } + } + + private fun process(event: EmbeddedContentEvent) { + when (event) { + is EmbeddedContentEvent.RecordBatch -> { + val rumContext = rumContextProvider.getRumContext() + if (rumContext.isNotValid()) { + return + } + val records = JsonArray() + .also { array -> + event.records.forEach { record -> + val jsonRecord = JsonSerializer.toJsonElement(record) + if (jsonRecord is JsonObject) { + jsonRecord.addProperty(SLOT_ID_KEY, event.slotId) + array.add(jsonRecord) + } else { + logInvalidRecord() + } + } + } + + if (records.size() == 0) { + return + } + + val enrichedRecord = JsonObject().apply { + addProperty(APPLICATION_ID_KEY, rumContext.applicationId) + addProperty(SESSION_ID_KEY, rumContext.sessionId) + addProperty(VIEW_ID_KEY, event.viewId) + add(RECORDS_KEY, records) + } + recordWriter()?.writeRaw( + enrichedRecord.toString().toByteArray(Charsets.UTF_8), + event.viewId, + records.size() + ) + } + + is EmbeddedContentEvent.Resource -> { + resourceProcessor()?.process(event.identifier, event.data, event.mimeType) + } + } + } + + private fun logInvalidRecord() { + internalLogger.log( + InternalLogger.Level.WARN, + InternalLogger.Target.MAINTAINER, + { INVALID_EMBEDDED_RECORD_MESSAGE } + ) + } + + companion object { + internal const val APPLICATION_ID_KEY = "application_id" + internal const val SESSION_ID_KEY = "session_id" + internal const val VIEW_ID_KEY = "view_id" + internal const val RECORDS_KEY = "records" + internal const val SLOT_ID_KEY = "slotId" + internal const val INVALID_EMBEDDED_RECORD_MESSAGE = + "Session Replay received an invalid embedded content record." + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/RecordedDataProcessor.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/RecordedDataProcessor.kt index 807f1ce860..f567293ab1 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/RecordedDataProcessor.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/RecordedDataProcessor.kt @@ -24,12 +24,16 @@ import java.util.concurrent.TimeUnit @Suppress("TooManyFunctions") internal class RecordedDataProcessor( - private val resourceDataStoreManager: ResourceDataStoreManager, - private val resourcesWriter: ResourcesWriter, + resourceDataStoreManager: ResourceDataStoreManager, + resourcesWriter: ResourcesWriter, private val writer: RecordWriter, private val mutationResolver: MutationResolver, private val timeProvider: TimeProvider, - private val nodeFlattener: NodeFlattener = NodeFlattener() + private val nodeFlattener: NodeFlattener = NodeFlattener(), + private val resourceProcessor: ResourceProcessor = ResourceProcessor( + resourceDataStoreManager, + resourcesWriter + ) ) : Processor { private var prevSnapshot: List = emptyList() private var lastSnapshotTimestamp = 0L @@ -40,24 +44,7 @@ internal class RecordedDataProcessor( override fun processResources( item: ResourceRecordedDataQueueItem ) { - val resourceHash = item.identifier - val isKnownResource = resourceDataStoreManager.isPreviouslySentResource(resourceHash) - - if (!isKnownResource) { - // the cacheResourceHash method overwrites the datastore entry and we don't want that if we haven't finished - // initializing - if (resourceDataStoreManager.isReady()) { - resourceDataStoreManager.cacheResourceHash(resourceHash) - } - - val enrichedResource = EnrichedResource( - resource = item.resourceData, - filename = resourceHash, - mimeType = item.mimeType - ) - - resourcesWriter.write(enrichedResource) - } + resourceProcessor.process(item.identifier, item.resourceData, item.mimeType) } @WorkerThread diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/ResourceProcessor.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/ResourceProcessor.kt new file mode 100644 index 0000000000..4604275166 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/ResourceProcessor.kt @@ -0,0 +1,30 @@ +/* + * 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 + +internal class ResourceProcessor( + private val resourceDataStoreManager: ResourceDataStoreManager, + private val resourcesWriter: ResourcesWriter +) { + + 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/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..44bc39b72e --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/storage/EmbeddedContentRecordWriter.kt @@ -0,0 +1,11 @@ +/* + * 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 + +internal fun interface EmbeddedContentRecordWriter { + fun writeRaw(record: ByteArray, viewId: String, recordsCount: Int) +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/storage/SessionReplayRecordWriter.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/storage/SessionReplayRecordWriter.kt index 83c22e6734..4c66298a9a 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/storage/SessionReplayRecordWriter.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/storage/SessionReplayRecordWriter.kt @@ -15,8 +15,9 @@ import com.datadog.android.sessionreplay.internal.processor.EnrichedRecord internal class SessionReplayRecordWriter( private val sdkCore: FeatureSdkCore, - private val recordCallback: RecordCallback -) : RecordWriter { + private val recordCallback: RecordCallback, + private val onEmbeddedRecordWritten: (String, Int) -> Unit = { _, _ -> } +) : RecordWriter, EmbeddedContentRecordWriter { override fun write(record: EnrichedRecord) { sdkCore.getFeature(Feature.SESSION_REPLAY_FEATURE_NAME) ?.withWriteContext { _, writeScope -> @@ -37,6 +38,25 @@ internal class SessionReplayRecordWriter( } } + override fun writeRaw(record: ByteArray, viewId: String, recordsCount: Int) { + sdkCore.getFeature(Feature.SESSION_REPLAY_FEATURE_NAME) + ?.withWriteContext { _, writeScope -> + writeScope { + val rawBatchEvent = RawBatchEvent(data = record) + synchronized(this@SessionReplayRecordWriter) { + val success = it.write( + event = rawBatchEvent, + batchMetadata = null, + eventType = EventType.DEFAULT + ) + if (success) { + onEmbeddedRecordWritten(viewId, recordsCount) + } + } + } + } + } + private fun updateViewSent(record: EnrichedRecord) { /** * We have to see whether it's ok that this method is being called from the background. diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/SessionReplayRumContext.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/SessionReplayRumContext.kt index a22a724be3..507ec7e0df 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/SessionReplayRumContext.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/SessionReplayRumContext.kt @@ -27,10 +27,13 @@ internal data class SessionReplayRumContext( internal fun isNotValid(): Boolean = !isValid() internal fun isValid(): Boolean = - applicationId != NULL_UUID && - sessionId != NULL_UUID && + hasValidApplicationAndSession() && viewId != NULL_UUID + internal fun hasValidApplicationAndSession(): Boolean = + applicationId != NULL_UUID && + sessionId != NULL_UUID + companion object { private val NULL_UUID = UUID(0, 0).toString() } diff --git a/features/dd-sdk-android-session-replay/src/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..1f8bc62262 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,19 @@ 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.embedded.EmbeddedContentEvent import com.datadog.android.sessionreplay.internal.net.SegmentRequestFactory import com.datadog.android.sessionreplay.internal.recorder.NoOpRecorder import com.datadog.android.sessionreplay.internal.recorder.Recorder @@ -42,6 +49,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 @@ -161,6 +169,84 @@ 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.withContext(eq(emptySet()), any())) doAnswer { + val callback = it.getArgument<(DatadogContext) -> Unit>(it.arguments.lastIndex) + callback(mockDatadogContext) + } + 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( + "application_id" to UUID.randomUUID().toString(), + "session_id" to UUID.randomUUID().toString(), + "view_id" to UUID.randomUUID().toString() + ) + ) + // When + testedFeature.onReceive( + EmbeddedContentEvent.RecordBatch( + records = listOf(mapOf(FAKE_RECORD_TYPE_KEY to 10L)), + slotId = FAKE_EMBEDDED_SLOT_ID, + viewId = FAKE_EMBEDDED_VIEW_ID + ) + ) + + // Then + argumentCaptor { + verify(mockEventBatchWriter).write( + event = capture(), + batchMetadata = eq(null), + eventType = eq(EventType.DEFAULT) + ) + assertThat(firstValue.data.toString(Charsets.UTF_8)) + .contains("\"view_id\":\"$FAKE_EMBEDDED_VIEW_ID\"") + .contains("\"slotId\":\"$FAKE_EMBEDDED_SLOT_ID\"") + } + } + + @Test + fun `M schedule embedded records W onReceive { feature initialized }`() { + // Given + val mockFeatureScope = mock() + whenever(mockSdkCore.getFeature(Feature.SESSION_REPLAY_FEATURE_NAME)) + .thenReturn(mockFeatureScope) + testedFeature.onInitialize(appContext.mockInstance) + val event = EmbeddedContentEvent.RecordBatch( + records = listOf(mapOf(FAKE_RECORD_TYPE_KEY to 10L)), + slotId = FAKE_EMBEDDED_SLOT_ID, + viewId = FAKE_EMBEDDED_VIEW_ID + ) + + // When + testedFeature.onReceive(event) + + // Then + verify(mockFeatureScope).withContext(eq(emptySet()), any()) + verify(mockFeatureScope, never()).withWriteContext(any(), any()) + } + @Test fun `M update feature context for telemetry W initialize()`() { // Given @@ -1344,6 +1430,10 @@ internal class SessionReplayFeatureTest { ) companion object { + private const val FAKE_EMBEDDED_SLOT_ID = "slot-id" + private const val FAKE_RECORD_TYPE_KEY = "type" + private const val FAKE_EMBEDDED_VIEW_ID = "embedded-view-id" + val appContext = ApplicationContextTestConfiguration(Application::class.java) @TestConfigurationsProvider diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/embedded/EmbeddedContentReceiverTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/embedded/EmbeddedContentReceiverTest.kt new file mode 100644 index 0000000000..6363647f6d --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/embedded/EmbeddedContentReceiverTest.kt @@ -0,0 +1,262 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.embedded + +import com.datadog.android.api.InternalLogger +import com.datadog.android.sessionreplay.forge.ForgeConfigurator +import com.datadog.android.sessionreplay.internal.processor.ResourceProcessor +import com.datadog.android.sessionreplay.internal.storage.EmbeddedContentRecordWriter +import com.datadog.android.sessionreplay.internal.utils.RumContextProvider +import com.datadog.android.sessionreplay.internal.utils.SessionReplayRumContext +import com.google.gson.JsonParser +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.Extensions +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(ForgeConfigurator::class) +internal class EmbeddedContentReceiverTest { + + private lateinit var testedReceiver: EmbeddedContentReceiver + + @Mock + lateinit var mockRumContextProvider: RumContextProvider + + @Mock + lateinit var mockRecordWriter: EmbeddedContentRecordWriter + + @Mock + lateinit var mockResourceProcessor: ResourceProcessor + + @Mock + lateinit var mockInternalLogger: InternalLogger + + private var isRecording: Boolean = true + + private val fakeRumContext = SessionReplayRumContext( + applicationId = FAKE_NATIVE_APPLICATION_ID, + sessionId = FAKE_NATIVE_SESSION_ID, + viewId = FAKE_NATIVE_VIEW_ID + ) + + @BeforeEach + fun `set up`() { + whenever(mockRumContextProvider.getRumContext()).thenReturn(fakeRumContext) + testedReceiver = EmbeddedContentReceiver( + rumContextProvider = mockRumContextProvider, + recordWriter = { mockRecordWriter }, + resourceProcessor = { mockResourceProcessor }, + isRecording = { isRecording }, + internalLogger = mockInternalLogger + ) + } + + @Test + fun `M enrich and write records W receive { record batch }`() { + // Given + val event = EmbeddedContentEvent.RecordBatch( + records = listOf( + mapOf( + "timestamp" to 123L, + "type" to 10L, + FAKE_FUTURE_FIELD_NAME to FAKE_FUTURE_FIELD_VALUE + ), + mapOf("type" to 11L, EmbeddedContentReceiver.SLOT_ID_KEY to "stale-slot") + ), + slotId = FAKE_NATIVE_SLOT_ID, + viewId = FAKE_EMBEDDED_VIEW_ID + ) + + // When + testedReceiver.receive(event) + + // Then + argumentCaptor { + verify(mockRecordWriter).writeRaw(capture(), eq(FAKE_EMBEDDED_VIEW_ID), eq(2)) + val json = JsonParser.parseString(firstValue.toString(Charsets.UTF_8)).asJsonObject + assertThat(json[EmbeddedContentReceiver.APPLICATION_ID_KEY].asString) + .isEqualTo(FAKE_NATIVE_APPLICATION_ID) + assertThat(json[EmbeddedContentReceiver.SESSION_ID_KEY].asString) + .isEqualTo(FAKE_NATIVE_SESSION_ID) + assertThat(json[EmbeddedContentReceiver.VIEW_ID_KEY].asString) + .isEqualTo(FAKE_EMBEDDED_VIEW_ID) + val records = json[EmbeddedContentReceiver.RECORDS_KEY].asJsonArray + assertThat(records).hasSize(2) + assertThat(records[0].asJsonObject[EmbeddedContentReceiver.SLOT_ID_KEY].asString) + .isEqualTo(FAKE_NATIVE_SLOT_ID) + assertThat( + records[0].asJsonObject[FAKE_FUTURE_FIELD_NAME].asString + ).isEqualTo(FAKE_FUTURE_FIELD_VALUE) + assertThat(records[1].asJsonObject[EmbeddedContentReceiver.SLOT_ID_KEY].asString) + .isEqualTo(FAKE_NATIVE_SLOT_ID) + } + verifyNoInteractions(mockResourceProcessor) + } + + @Test + fun `M write records W receive { slot has no native container }`() { + // When + testedReceiver.receive( + EmbeddedContentEvent.RecordBatch( + records = listOf(mapOf("type" to 10L)), + slotId = "unmaterialized-slot", + viewId = FAKE_EMBEDDED_VIEW_ID + ) + ) + + // Then + verify(mockRecordWriter).writeRaw(any(), eq(FAKE_EMBEDDED_VIEW_ID), eq(1)) + } + + @Test + fun `M write resource W receive { resource }`() { + // Given + val resourceData = byteArrayOf(1, 2, 3) + + // When + testedReceiver.receive( + EmbeddedContentEvent.Resource( + identifier = FAKE_RESOURCE_ID, + data = resourceData, + mimeType = FAKE_MIME_TYPE + ) + ) + + // Then + verify(mockResourceProcessor).process(FAKE_RESOURCE_ID, resourceData, FAKE_MIME_TYPE) + verifyNoInteractions(mockRecordWriter) + } + + @Test + fun `M drop event W receive { recording inactive }`() { + // Given + isRecording = false + + // When + testedReceiver.receive( + EmbeddedContentEvent.RecordBatch( + records = listOf(mapOf("type" to 10L)), + slotId = FAKE_SLOT_ID, + viewId = FAKE_VIEW_ID + ) + ) + + // Then + verifyNoInteractions(mockRumContextProvider, mockRecordWriter, mockResourceProcessor) + } + + @Test + fun `M drop records W receive { invalid RUM context }`() { + // Given + whenever(mockRumContextProvider.getRumContext()).thenReturn(SessionReplayRumContext()) + + // When + testedReceiver.receive( + EmbeddedContentEvent.RecordBatch( + records = listOf(mapOf("type" to 10L)), + slotId = FAKE_SLOT_ID, + viewId = FAKE_VIEW_ID + ) + ) + + // Then + verifyNoInteractions(mockRecordWriter, mockResourceProcessor) + } + + @Test + fun `M drop records W receive { empty record batch }`() { + // When + testedReceiver.receive( + EmbeddedContentEvent.RecordBatch( + records = emptyList(), + slotId = FAKE_SLOT_ID, + viewId = FAKE_VIEW_ID + ) + ) + + // Then + verifyNoInteractions(mockRumContextProvider, mockRecordWriter, mockResourceProcessor) + } + + @Test + fun `M drop resource W receive { invalid RUM application and session context }`() { + // Given + whenever(mockRumContextProvider.getRumContext()).thenReturn(SessionReplayRumContext()) + val resourceData = byteArrayOf(1, 2, 3) + + // When + testedReceiver.receive( + EmbeddedContentEvent.Resource( + identifier = FAKE_RESOURCE_ID, + data = resourceData, + mimeType = FAKE_MIME_TYPE + ) + ) + + // Then + verifyNoInteractions(mockRecordWriter, mockResourceProcessor) + } + + @Test + fun `M write resource W receive { RUM application and session context is valid without view }`() { + // Given + whenever(mockRumContextProvider.getRumContext()).thenReturn( + SessionReplayRumContext( + applicationId = FAKE_APPLICATION_ID, + sessionId = FAKE_SESSION_ID + ) + ) + val resourceData = byteArrayOf(1, 2, 3) + + // When + testedReceiver.receive( + EmbeddedContentEvent.Resource( + identifier = FAKE_RESOURCE_ID, + data = resourceData, + mimeType = FAKE_MIME_TYPE + ) + ) + + // Then + verify(mockResourceProcessor).process(FAKE_RESOURCE_ID, resourceData, FAKE_MIME_TYPE) + verifyNoInteractions(mockRecordWriter) + } + + private companion object { + const val FAKE_APPLICATION_ID = "application-id" + const val FAKE_SESSION_ID = "session-id" + const val FAKE_VIEW_ID = "view-id" + const val FAKE_SLOT_ID = "slot-id" + const val FAKE_NATIVE_APPLICATION_ID = "native-application-id" + const val FAKE_NATIVE_SESSION_ID = "native-session-id" + const val FAKE_NATIVE_VIEW_ID = "native-view-id" + const val FAKE_EMBEDDED_VIEW_ID = "embedded-view-id" + const val FAKE_NATIVE_SLOT_ID = "native-slot" + const val FAKE_RESOURCE_ID = "resource-id" + const val FAKE_MIME_TYPE = "image/png" + const val FAKE_FUTURE_FIELD_NAME = "futureField" + const val FAKE_FUTURE_FIELD_VALUE = "preserved" + } +} diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/RecordedDataProcessorTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/RecordedDataProcessorTest.kt index b0e0e93e33..d3fd8a31e7 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/RecordedDataProcessorTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/RecordedDataProcessorTest.kt @@ -40,7 +40,6 @@ import org.mockito.junit.jupiter.MockitoSettings import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.atLeastOnce -import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions @@ -1258,6 +1257,7 @@ internal class RecordedDataProcessorTest { // Given val fakeByteArray = forge.anAlphaNumericalString().toByteArray() val fakeResourceItem = createResourceItem(fakeByteArray, usedContext = initialRecordedQueuedItemContext) + whenever(mockResourceDataStoreManager.markResourceAsSentIfNew(fakeIdentifier)).thenReturn(true) // When testedProcessor.processResources(fakeResourceItem) @@ -1276,38 +1276,6 @@ internal class RecordedDataProcessorTest { assertThat(itemFilename).isEqualTo(fakeIdentifier) } - @Test - fun `M not store resource in datastore W processResources { resource not previously seen by manager not ready }`( - forge: Forge - ) { - // Given - val fakeByteArray = forge.anAlphaNumericalString().toByteArray() - val fakeResourceItem = createResourceItem(fakeByteArray, usedContext = initialRecordedQueuedItemContext) - whenever(mockResourceDataStoreManager.isReady()).thenReturn(false) - - // When - testedProcessor.processResources(fakeResourceItem) - - // Then - verify(mockResourceDataStoreManager, never()).cacheResourceHash(fakeIdentifier) - } - - @Test - fun `M store resource in datastore W processResources { resource not previously seen and manager ready }`( - forge: Forge - ) { - // Given - val fakeByteArray = forge.anAlphaNumericalString().toByteArray() - val fakeResourceItem = createResourceItem(fakeByteArray, usedContext = initialRecordedQueuedItemContext) - whenever(mockResourceDataStoreManager.isReady()).thenReturn(true) - - // When - testedProcessor.processResources(fakeResourceItem) - - // Then - verify(mockResourceDataStoreManager, times(1)).cacheResourceHash(fakeIdentifier) - } - @Test fun `M not write resource data W processResources { resource was previously seen }`( @StringForgery fakeString: String @@ -1315,8 +1283,8 @@ internal class RecordedDataProcessorTest { // Given val fakeByteArray = fakeString.toByteArray() val fakeResourceItem = createResourceItem(fakeByteArray, usedContext = initialRecordedQueuedItemContext) - whenever(mockResourceDataStoreManager.isPreviouslySentResource(fakeIdentifier)) - .thenReturn(true) + whenever(mockResourceDataStoreManager.markResourceAsSentIfNew(fakeIdentifier)) + .thenReturn(false) // When testedProcessor.processResources(fakeResourceItem) diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/ResourceProcessorTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/ResourceProcessorTest.kt new file mode 100644 index 0000000000..7fb847c0ce --- /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: ResourceProcessor + + @Mock + lateinit var mockResourceDataStoreManager: ResourceDataStoreManager + + @Mock + lateinit var mockResourcesWriter: ResourcesWriter + + @BeforeEach + fun `set up`() { + testedProcessor = ResourceProcessor( + 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/resources/ResourceDataStoreManagerMigrationTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/resources/ResourceDataStoreManagerMigrationTest.kt index 926432c874..3e1dc7fe43 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/resources/ResourceDataStoreManagerMigrationTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/resources/ResourceDataStoreManagerMigrationTest.kt @@ -78,7 +78,7 @@ internal class ResourceDataStoreManagerMigrationTest { assertThat(dataStore.removeCount).isEqualTo(1) // When - testedManager.cacheResourceHash(NEW_RESOURCE_HASH) + testedManager.markResourceAsSentIfNew(NEW_RESOURCE_HASH) // Then val migratedEntry = checkNotNull(dataStore.entry) diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/resources/ResourceDataStoreManagerTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/resources/ResourceDataStoreManagerTest.kt index 75578ddd35..4809eb1a89 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/resources/ResourceDataStoreManagerTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/resources/ResourceDataStoreManagerTest.kt @@ -40,9 +40,14 @@ import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doAnswer import org.mockito.kotlin.eq import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.mockito.quality.Strictness +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit @Extensions( ExtendWith(MockitoExtension::class), @@ -113,7 +118,7 @@ internal class ResourceDataStoreManagerTest { resourceHashesSerializer = mockResourceHashesEntrySerializer, resourceHashesDeserializer = mockResourceHashesEntryDeserializer ) - testedDataStoreManager.cacheResourceHash(fakeHash) + testedDataStoreManager.markResourceAsSentIfNew(fakeHash) // When val wasSent = testedDataStoreManager.isPreviouslySentResource(fakeHash) @@ -123,8 +128,9 @@ internal class ResourceDataStoreManagerTest { } @Test - fun `M write to datastore W cacheResourceHash`() { + fun `M write to datastore W markResourceAsSentIfNew`() { // Given + setFetchDataSuccess(null) testedDataStoreManager = ResourceDataStoreManager( featureSdkCore = mockFeatureSdkCore, resourceHashesSerializer = mockResourceHashesEntrySerializer, @@ -132,7 +138,7 @@ internal class ResourceDataStoreManagerTest { ) // When - testedDataStoreManager.cacheResourceHash(fakeHash) + testedDataStoreManager.markResourceAsSentIfNew(fakeHash) // Then verify(mockFeatureScope.dataStore).setValue( @@ -145,7 +151,123 @@ internal class ResourceDataStoreManagerTest { } @Test - fun `M do not use expired date W cacheResourceHash { datastore expired }`( + fun `M mark resource once W markResourceAsSentIfNew { concurrent calls }`() { + // Given + setFetchDataSuccess(null) + testedDataStoreManager = ResourceDataStoreManager( + featureSdkCore = mockFeatureSdkCore, + resourceHashesSerializer = mockResourceHashesEntrySerializer, + resourceHashesDeserializer = mockResourceHashesEntryDeserializer + ) + val concurrentCallCount = 20 + val executor = Executors.newFixedThreadPool(concurrentCallCount) + val startLatch = CountDownLatch(1) + val completionLatch = CountDownLatch(concurrentCallCount) + val results = ConcurrentLinkedQueue() + + try { + repeat(concurrentCallCount) { + executor.execute { + startLatch.await() + results += testedDataStoreManager.markResourceAsSentIfNew(fakeHash) + completionLatch.countDown() + } + } + + // When + startLatch.countDown() + + // Then + assertThat(completionLatch.await(5, TimeUnit.SECONDS)).isTrue() + assertThat(results.count { it }).isEqualTo(1) + } finally { + executor.shutdownNow() + } + } + + @Test + fun `M persist pending resource W init { resource marked before fetch completes }`() { + // Given + testedDataStoreManager = ResourceDataStoreManager( + featureSdkCore = mockFeatureSdkCore, + resourceHashesSerializer = mockResourceHashesEntrySerializer, + resourceHashesDeserializer = mockResourceHashesEntryDeserializer + ) + val fetchCallback = argumentCaptor>().also { + verify(mockDataStoreHandler).value( + key = eq(DATASTORE_HASHES_ENTRY_NAME), + version = anyOrNull(), + callback = it.capture(), + deserializer = any() + ) + }.firstValue + assertThat(testedDataStoreManager.markResourceAsSentIfNew(fakeHash)).isTrue() + verify(mockDataStoreHandler, never()).setValue( + key = any(), + data = any(), + version = any(), + callback = anyOrNull(), + serializer = any() + ) + + // When + fetchCallback.onSuccess(null) + + // Then + verify(mockDataStoreHandler).setValue( + key = eq(DATASTORE_HASHES_ENTRY_NAME), + data = any(), + version = eq(DATASTORE_VERSION), + callback = anyOrNull(), + serializer = eq(mockResourceHashesEntrySerializer) + ) + } + + @Test + fun `M persist stored and pending hashes W init { resource marked before valid fetch completes }`( + forge: Forge + ) { + // Given + val storedHash = "stored-$fakeHash" + val storedContent = generateDataStoreContent( + forge = forge, + isExpired = false, + currentTime = fakeCurrentTimestampMs, + resourceHashes = listOf(storedHash) + ) + testedDataStoreManager = ResourceDataStoreManager( + featureSdkCore = mockFeatureSdkCore, + resourceHashesSerializer = mockResourceHashesEntrySerializer, + resourceHashesDeserializer = mockResourceHashesEntryDeserializer + ) + val fetchCallback = argumentCaptor>().also { + verify(mockDataStoreHandler).value( + key = eq(DATASTORE_HASHES_ENTRY_NAME), + version = anyOrNull(), + callback = it.capture(), + deserializer = any() + ) + }.firstValue + assertThat(testedDataStoreManager.markResourceAsSentIfNew(fakeHash)).isTrue() + + // When + fetchCallback.onSuccess(storedContent) + + // Then + val entryCaptor = argumentCaptor() + verify(mockDataStoreHandler).setValue( + key = eq(DATASTORE_HASHES_ENTRY_NAME), + data = entryCaptor.capture(), + version = eq(DATASTORE_VERSION), + callback = anyOrNull(), + serializer = eq(mockResourceHashesEntrySerializer) + ) + assertThat(entryCaptor.firstValue.resourceHashes) + .containsExactlyInAnyOrder(storedHash, fakeHash) + } + + @Test + fun `M do not use expired date W markResourceAsSentIfNew { datastore expired }`( forge: Forge ) { // Given @@ -159,7 +281,7 @@ internal class ResourceDataStoreManagerTest { resourceHashesDeserializer = mockResourceHashesEntryDeserializer ) - testedDataStoreManager.cacheResourceHash(fakeHash) + testedDataStoreManager.markResourceAsSentIfNew(fakeHash) // Then val resourceHashesEntryCaptor = argumentCaptor() @@ -177,7 +299,7 @@ internal class ResourceDataStoreManagerTest { } @Test - fun `M refresh stored date W cacheResourceHash { valid entry was loaded }`( + fun `M refresh stored date W markResourceAsSentIfNew { valid entry was loaded }`( forge: Forge ) { // Given @@ -196,7 +318,7 @@ internal class ResourceDataStoreManagerTest { ) // When - testedDataStoreManager.cacheResourceHash(fakeHash) + testedDataStoreManager.markResourceAsSentIfNew(fakeHash) // Then val resourceHashesEntryCaptor = argumentCaptor() @@ -431,9 +553,9 @@ internal class ResourceDataStoreManagerTest { isExpired: Boolean, currentTime: Long, version: Int = DATASTORE_VERSION, - storedTimestamp: Long? = null + storedTimestamp: Long? = null, + resourceHashes: List = forge.aList { aString() }.distinct() ): DataStoreContent { - val resourceHashes = forge.aList { aString() }.distinct() val entryTime = storedTimestamp ?: if (isExpired) { currentTime - DATASTORE_EXPIRATION_MS - 1 } else { diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/storage/SessionReplayRecordWriterTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/storage/SessionReplayRecordWriterTest.kt index 6fef625248..bb9b0ea1c4 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/storage/SessionReplayRecordWriterTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/storage/SessionReplayRecordWriterTest.kt @@ -19,8 +19,11 @@ import com.datadog.android.sessionreplay.internal.RecordCallback import com.datadog.android.sessionreplay.internal.processor.EnrichedRecord import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.annotation.Forgery +import fr.xgouchet.elmyr.annotation.IntForgery +import fr.xgouchet.elmyr.annotation.StringForgery import fr.xgouchet.elmyr.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @@ -46,6 +49,7 @@ import java.util.UUID @ForgeConfiguration(ForgeConfigurator::class) internal class SessionReplayRecordWriterTest { lateinit var testedWriter: SessionReplayRecordWriter + private val embeddedRecordWrites = mutableListOf>() @Mock lateinit var mockSdkCore: FeatureSdkCore @@ -73,7 +77,10 @@ internal class SessionReplayRecordWriterTest { whenever(mockSdkCore.getFeature(Feature.SESSION_REPLAY_FEATURE_NAME)) .thenReturn(mockSessionReplayFeature) - testedWriter = SessionReplayRecordWriter(mockSdkCore, mockRecordCallback) + embeddedRecordWrites.clear() + testedWriter = SessionReplayRecordWriter(mockSdkCore, mockRecordCallback) { viewId, recordsCount -> + embeddedRecordWrites += viewId to recordsCount + } } @Test @@ -102,6 +109,7 @@ internal class SessionReplayRecordWriterTest { verify(mockRecordCallback).onRecordForViewSent(fakeRecord) verifyNoMoreInteractions(mockRecordCallback) + assertThat(embeddedRecordWrites).isEmpty() } @Test @@ -119,6 +127,35 @@ internal class SessionReplayRecordWriterTest { verifyNoMoreInteractions(mockRecordCallback) } + @Test + fun `M write serialized record W writeRaw`( + @StringForgery fakeViewId: String, + @IntForgery(min = 1) fakeRecordsCount: Int + ) { + // Given + val fakeRecord = byteArrayOf(1, 2, 3) + whenever(mockEventWriteScope.invoke(any())) doAnswer { + val callback = it.getArgument<(EventBatchWriter) -> Unit>(0) + callback.invoke(mockEventBatchWriter) + } + whenever(mockSessionReplayFeature.withWriteContext(eq(emptySet()), any())) doAnswer { + val callback = it.getArgument<(DatadogContext, EventWriteScope) -> Unit>(it.arguments.lastIndex) + callback.invoke(fakeDatadogContext, mockEventWriteScope) + } + + // When + testedWriter.writeRaw(fakeRecord, fakeViewId, fakeRecordsCount) + + // Then + verify(mockEventBatchWriter).write( + event = RawBatchEvent(data = fakeRecord), + batchMetadata = null, + eventType = EventType.DEFAULT + ) + assertThat(embeddedRecordWrites).containsExactly(fakeViewId to fakeRecordsCount) + verifyNoMoreInteractions(mockRecordCallback) + } + @Test fun `M not call record callback W write { eventBatchWriter write failed }`(forge: Forge) { // Given @@ -149,6 +186,37 @@ internal class SessionReplayRecordWriterTest { verifyNoMoreInteractions(mockRecordCallback) } + @Test + fun `M not count embedded records W writeRaw { eventBatchWriter write failed }`( + @StringForgery fakeViewId: String, + @IntForgery(min = 1) fakeRecordsCount: Int + ) { + // Given + val fakeRecord = byteArrayOf(1, 2, 3) + whenever(mockEventBatchWriter.write(anyOrNull(), anyOrNull(), any())) + .thenReturn(false) + whenever(mockEventWriteScope.invoke(any())) doAnswer { + val callback = it.getArgument<(EventBatchWriter) -> Unit>(0) + callback.invoke(mockEventBatchWriter) + } + whenever(mockSessionReplayFeature.withWriteContext(any(), any())) doAnswer { + val callback = it.getArgument<(DatadogContext, EventWriteScope) -> Unit>(it.arguments.lastIndex) + callback.invoke(fakeDatadogContext, mockEventWriteScope) + } + + // When + testedWriter.writeRaw(fakeRecord, fakeViewId, fakeRecordsCount) + + // Then + verify(mockEventBatchWriter).write( + event = RawBatchEvent(data = fakeRecord), + batchMetadata = null, + eventType = EventType.DEFAULT + ) + assertThat(embeddedRecordWrites).isEmpty() + verifyNoMoreInteractions(mockRecordCallback) + } + private fun Forge.forgeEnrichedRecord(): EnrichedRecord { // We don't want to create a forgery for this as this lives in the session-replay module // and we will need to copy all the records forgeries. Instead we just forge this record diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/utils/SessionReplayRumContextTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/utils/SessionReplayRumContextTest.kt index 738bba7b49..bcb4de2f47 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/utils/SessionReplayRumContextTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/utils/SessionReplayRumContextTest.kt @@ -26,6 +26,45 @@ import org.mockito.quality.Strictness @ForgeConfiguration(ForgeConfigurator::class) internal class SessionReplayRumContextTest { + @Test + fun `M return true W hasValidApplicationAndSession() { application and session are set }`() { + // Given + val rumContext = SessionReplayRumContext( + applicationId = FAKE_APPLICATION_ID, + sessionId = FAKE_SESSION_ID + ) + + // When + val result = rumContext.hasValidApplicationAndSession() + + // Then + assertThat(result).isTrue() + } + + @Test + fun `M return false W hasValidApplicationAndSession() { application is missing }`() { + // Given + val rumContext = SessionReplayRumContext(sessionId = FAKE_SESSION_ID) + + // When + val result = rumContext.hasValidApplicationAndSession() + + // Then + assertThat(result).isFalse() + } + + @Test + fun `M return false W hasValidApplicationAndSession() { session is missing }`() { + // Given + val rumContext = SessionReplayRumContext(applicationId = FAKE_APPLICATION_ID) + + // When + val result = rumContext.hasValidApplicationAndSession() + + // Then + assertThat(result).isFalse() + } + @Test fun `M return true W isNotValid() { RumContext is not valid }`() { // Given @@ -41,4 +80,9 @@ internal class SessionReplayRumContextTest { ) { assertThat(fakeContext.isNotValid()).isFalse() } + + private companion object { + const val FAKE_APPLICATION_ID = "application-id" + const val FAKE_SESSION_ID = "session-id" + } } From 211dfb0ac62d750522121ddb790c7371550762ef Mon Sep 17 00:00:00 2001 From: jonathanmos <48201295+jonathanmos@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:12:45 +0300 Subject: [PATCH 06/12] PANA-8412: Track embedded records across RUM views --- .../internal/SessionReplayFeature.kt | 22 +++- .../internal/SessionReplayRecordCallback.kt | 12 ++ .../SessionReplayRumContextProvider.kt | 23 +++- .../internal/SessionReplayFeatureTest.kt | 97 +++++++++++++- .../SessionReplayRecordCallbackTest.kt | 40 ++++++ .../SessionReplayRumContextProviderTest.kt | 124 ++++++++++++++---- 6 files changed, 282 insertions(+), 36 deletions(-) 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 2fcbc502e0..e35a2a988f 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 @@ -27,6 +27,7 @@ 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.EmbeddedContentSlotRegistry import com.datadog.android.sessionreplay.internal.net.BatchesToSegmentsMapper import com.datadog.android.sessionreplay.internal.net.SegmentRequestFactory import com.datadog.android.sessionreplay.internal.processor.ResourceProcessor @@ -126,7 +127,9 @@ internal class SessionReplayFeature( internal var dataWriter: RecordWriter = NoOpRecordWriter() internal val initialized = AtomicBoolean(false) - private val rumContextProvider = SessionReplayRumContextProvider() + private val rumContextProvider = SessionReplayRumContextProvider { + onRumViewChanged() + } private var resourceProcessor: ResourceProcessor? = null private var embeddedContentRecordWriter: EmbeddedContentRecordWriter? = null private val embeddedContentReceiver = EmbeddedContentReceiver( @@ -397,7 +400,13 @@ internal class SessionReplayFeature( private fun createDataWriter(): SessionReplayRecordWriter { val recordCallback = SessionReplayRecordCallback(sdkCore) - return SessionReplayRecordWriter(sdkCore, recordCallback) + return SessionReplayRecordWriter( + sdkCore, + recordCallback, + { viewId, recordsCount -> + recordCallback.onEmbeddedRecordsForViewSent(viewId, recordsCount) + } + ) } /** @@ -412,6 +421,15 @@ internal class SessionReplayFeature( } } + private fun onRumViewChanged() { + if (!isRecording.get()) { + return + } + if (EmbeddedContentSlotRegistry.hasMarkedSlots()) { + sessionReplayRecorder.requestCapture() + } + } + // endregion // region resourcesFeature diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayRecordCallback.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayRecordCallback.kt index 3b83fde495..e4f268c2bf 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 @@ -30,6 +30,18 @@ internal class SessionReplayRecordCallback( } } + @Suppress("UNCHECKED_CAST") + fun onEmbeddedRecordsForViewSent(viewId: String, recordsCount: Int) { + if (recordsCount > 0) { + 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 + } + } + } + private fun updateRecordsCount( viewMetadata: MutableMap, recordsCount: Int diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayRumContextProvider.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayRumContextProvider.kt index 10d4033d70..c9f67bfdef 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayRumContextProvider.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayRumContextProvider.kt @@ -12,7 +12,9 @@ import com.datadog.android.sessionreplay.internal.utils.RumContextProvider import com.datadog.android.sessionreplay.internal.utils.SessionReplayRumContext import java.util.UUID -internal class SessionReplayRumContextProvider : RumContextProvider, FeatureContextUpdateReceiver { +internal class SessionReplayRumContextProvider( + private val onRumViewChanged: () -> Unit = {} +) : RumContextProvider, FeatureContextUpdateReceiver { @Volatile private var rumContext = emptyMap() @@ -20,11 +22,11 @@ internal class SessionReplayRumContextProvider : RumContextProvider, FeatureCont override fun getRumContext(): SessionReplayRumContext { return rumContext.let { SessionReplayRumContext( - applicationId = it["application_id"] as? String ?: NULL_UUID, - sessionId = it["session_id"] as? String ?: NULL_UUID, - viewId = it["view_id"] as? String ?: NULL_UUID, + applicationId = it[RUM_APPLICATION_ID_CONTEXT_KEY] as? String ?: NULL_UUID, + sessionId = it[RUM_SESSION_ID_CONTEXT_KEY] as? String ?: NULL_UUID, + viewId = it[RUM_VIEW_ID_CONTEXT_KEY] as? String ?: NULL_UUID, // TODO RUM-3785 Share this property somehow, defined in RumFeature.VIEW_TIMESTAMP_OFFSET_IN_MS_KEY - viewTimeOffsetMs = it["view_timestamp_offset"] as? Long ?: 0L, + viewTimeOffsetMs = it[RUM_VIEW_TIME_OFFSET_CONTEXT_KEY] as? Long ?: 0L, viewUrl = it[RUM_VIEW_URL_CONTEXT_KEY] as? String ) } @@ -32,6 +34,11 @@ internal class SessionReplayRumContextProvider : RumContextProvider, FeatureCont override fun onContextUpdate(featureName: String, context: Map) { if (featureName == Feature.RUM_FEATURE_NAME) { + val previousViewId = rumContext[RUM_VIEW_ID_CONTEXT_KEY] as? String + val newViewId = context[RUM_VIEW_ID_CONTEXT_KEY] as? String + if (newViewId != null && newViewId != NULL_UUID && newViewId != previousViewId) { + onRumViewChanged() + } rumContext = context } } @@ -39,7 +46,11 @@ internal class SessionReplayRumContextProvider : RumContextProvider, FeatureCont companion object { val NULL_UUID = UUID(0, 0).toString() - // Mirrors RumContext.VIEW_URL in the RUM module. + // Mirrors feature-context keys from RumContext in the RUM module. + internal const val RUM_APPLICATION_ID_CONTEXT_KEY = "application_id" + internal const val RUM_SESSION_ID_CONTEXT_KEY = "session_id" + internal const val RUM_VIEW_ID_CONTEXT_KEY = "view_id" + internal const val RUM_VIEW_TIME_OFFSET_CONTEXT_KEY = "view_timestamp_offset" internal const val RUM_VIEW_URL_CONTEXT_KEY = "view_url" } } diff --git a/features/dd-sdk-android-session-replay/src/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 1f8bc62262..fbef93d282 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 @@ -20,7 +20,13 @@ 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.embedded.EmbeddedContentSlotRegistry import com.datadog.android.sessionreplay.internal.net.SegmentRequestFactory import com.datadog.android.sessionreplay.internal.recorder.NoOpRecorder import com.datadog.android.sessionreplay.internal.recorder.Recorder @@ -32,6 +38,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 @@ -140,6 +147,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) + EmbeddedContentSlotRegistry.notifySlotChanged(null, fakeRegistration) + testedFeature.onInitialize(appContext.mockInstance) + val contextReceiver = argumentCaptor().also { + verify(mockSdkCore).setContextUpdateReceiver(it.capture()) + }.firstValue + contextReceiver.onContextUpdate( + Feature.RUM_FEATURE_NAME, + mapOf(RUM_VIEW_ID_CONTEXT_KEY to UUID.randomUUID().toString()) + ) + testedFeature.startRecording() + + // When + contextReceiver.onContextUpdate( + Feature.RUM_FEATURE_NAME, + mapOf(RUM_VIEW_ID_CONTEXT_KEY to UUID.randomUUID().toString()) + ) + + // Then + verify(mockRecorder).requestCapture() + + // Cleanup + 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) + EmbeddedContentSlotRegistry.notifySlotChanged(null, fakeRegistration) + testedFeature.onInitialize(appContext.mockInstance) + testedFeature.startRecording() + val contextReceiver = argumentCaptor().also { + verify(mockSdkCore).setContextUpdateReceiver(it.capture()) + }.firstValue + + // When + contextReceiver.onContextUpdate( + Feature.RUM_FEATURE_NAME, + mapOf(RUM_VIEW_ID_CONTEXT_KEY to UUID.randomUUID().toString()) + ) + + // Then + verify(mockRecorder).requestCapture() + + // Cleanup + 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) + EmbeddedContentSlotRegistry.notifySlotChanged(null, fakeRegistration) + testedFeature.onInitialize(appContext.mockInstance) + val contextReceiver = argumentCaptor().also { + verify(mockSdkCore).setContextUpdateReceiver(it.capture()) + }.firstValue + + // When + contextReceiver.onContextUpdate( + Feature.RUM_FEATURE_NAME, + mapOf(RUM_VIEW_ID_CONTEXT_KEY to UUID.randomUUID().toString()) + ) + + // Then + verify(mockRecorder, never()).requestCapture() + + // Cleanup + EmbeddedContentSlotRegistry.notifySlotChanged(fakeRegistration, null) + } + @Test fun `M initialize session replay recorder W initialize()`() { // Given @@ -199,9 +281,9 @@ internal class SessionReplayFeatureTest { contextReceiver.onContextUpdate( Feature.RUM_FEATURE_NAME, mapOf( - "application_id" to UUID.randomUUID().toString(), - "session_id" to UUID.randomUUID().toString(), - "view_id" to UUID.randomUUID().toString() + RUM_APPLICATION_ID_CONTEXT_KEY to UUID.randomUUID().toString(), + RUM_SESSION_ID_CONTEXT_KEY to UUID.randomUUID().toString(), + RUM_VIEW_ID_CONTEXT_KEY to UUID.randomUUID().toString() ) ) // When @@ -220,9 +302,12 @@ internal class SessionReplayFeatureTest { batchMetadata = eq(null), eventType = eq(EventType.DEFAULT) ) - assertThat(firstValue.data.toString(Charsets.UTF_8)) - .contains("\"view_id\":\"$FAKE_EMBEDDED_VIEW_ID\"") - .contains("\"slotId\":\"$FAKE_EMBEDDED_SLOT_ID\"") + val json = JsonParser.parseString(firstValue.data.toString(Charsets.UTF_8)).asJsonObject + assertThat(json[EmbeddedContentReceiver.VIEW_ID_KEY].asString) + .isEqualTo(FAKE_EMBEDDED_VIEW_ID) + val records = json[EmbeddedContentReceiver.RECORDS_KEY].asJsonArray + assertThat(records[0].asJsonObject[EmbeddedContentReceiver.SLOT_ID_KEY].asString) + .isEqualTo(FAKE_EMBEDDED_SLOT_ID) } } diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayRecordCallbackTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayRecordCallbackTest.kt index 0118daa9a4..93b99317e2 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayRecordCallbackTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayRecordCallbackTest.kt @@ -157,6 +157,46 @@ internal class SessionReplayRecordCallbackTest { } } + @Suppress("UNCHECKED_CAST") + @Test + fun `M share records count W native and embedded records sent`() { + // Given + val embeddedRecordsCount = 3 + + // When + testedRecordCallback.onRecordForViewSent(fakeEnrichedRecord) + testedRecordCallback.onEmbeddedRecordsForViewSent( + fakeEnrichedRecord.viewId, + embeddedRecordsCount + ) + + // Then + argumentCaptor<(MutableMap) -> Unit> { + verify(mockDatadogCore, times(2)).updateFeatureContext( + eq(Feature.SESSION_REPLAY_FEATURE_NAME), + eq(false), + capture() + ) + + val featureContext = mutableMapOf() + allValues.forEach { it.invoke(featureContext) } + val viewMetadata = featureContext[fakeEnrichedRecord.viewId] as? Map + assertThat(viewMetadata?.get(SessionReplayRecordCallback.HAS_REPLAY_KEY)) + .isEqualTo(true) + assertThat(viewMetadata?.get(SessionReplayRecordCallback.VIEW_RECORDS_COUNT_KEY)) + .isEqualTo(fakeEnrichedRecord.records.size.toLong() + embeddedRecordsCount) + } + } + + @Test + fun `M do nothing W onEmbeddedRecordsForViewSent { empty batch }`() { + // When + testedRecordCallback.onEmbeddedRecordsForViewSent(fakeEnrichedRecord.viewId, 0) + + // Then + verifyNoInteractions(mockDatadogCore) + } + private fun Forge.forgeFakeValidEnrichedRecord(): EnrichedRecord { return getForgery() .copy(records = aList(size = anInt(min = 1, max = 10)) { getForgery() }) diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayRumContextProviderTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayRumContextProviderTest.kt index 3797e8dd0c..4e2195db98 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayRumContextProviderTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayRumContextProviderTest.kt @@ -9,6 +9,11 @@ package com.datadog.android.sessionreplay.internal import com.datadog.android.api.feature.Feature import com.datadog.android.sessionreplay.forge.ForgeConfigurator import com.datadog.android.sessionreplay.internal.SessionReplayRumContextProvider.Companion.NULL_UUID +import com.datadog.android.sessionreplay.internal.SessionReplayRumContextProvider.Companion.RUM_APPLICATION_ID_CONTEXT_KEY +import com.datadog.android.sessionreplay.internal.SessionReplayRumContextProvider.Companion.RUM_SESSION_ID_CONTEXT_KEY +import com.datadog.android.sessionreplay.internal.SessionReplayRumContextProvider.Companion.RUM_VIEW_ID_CONTEXT_KEY +import com.datadog.android.sessionreplay.internal.SessionReplayRumContextProvider.Companion.RUM_VIEW_TIME_OFFSET_CONTEXT_KEY +import com.datadog.android.sessionreplay.internal.SessionReplayRumContextProvider.Companion.RUM_VIEW_URL_CONTEXT_KEY import com.datadog.android.sessionreplay.internal.utils.SessionReplayRumContext import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.annotation.Forgery @@ -31,6 +36,81 @@ internal class SessionReplayRumContextProviderTest { private val testedSessionReplayContextProvider = SessionReplayRumContextProvider() + @Test + fun `M notify first view W onContextUpdate { new valid RUM view }`() { + // Given + var notificationCount = 0 + val testedProvider = SessionReplayRumContextProvider { notificationCount++ } + val viewId = UUID.randomUUID().toString() + + // When + testedProvider.onContextUpdate( + Feature.RUM_FEATURE_NAME, + mapOf(RUM_VIEW_ID_CONTEXT_KEY to viewId) + ) + + // Then + assertThat(notificationCount).isEqualTo(1) + } + + @Test + fun `M notify view transition W onContextUpdate { valid RUM view changes }`() { + // Given + var notificationCount = 0 + val testedProvider = SessionReplayRumContextProvider { notificationCount++ } + testedProvider.onContextUpdate( + Feature.RUM_FEATURE_NAME, + mapOf(RUM_VIEW_ID_CONTEXT_KEY to UUID.randomUUID().toString()) + ) + + // When + testedProvider.onContextUpdate( + Feature.RUM_FEATURE_NAME, + mapOf(RUM_VIEW_ID_CONTEXT_KEY to UUID.randomUUID().toString()) + ) + + // Then + assertThat(notificationCount).isEqualTo(2) + } + + @Test + fun `M not notify view change W onContextUpdate { same RUM view }`() { + // Given + var notificationCount = 0 + val testedProvider = SessionReplayRumContextProvider { notificationCount++ } + val viewId = UUID.randomUUID().toString() + testedProvider.onContextUpdate( + Feature.RUM_FEATURE_NAME, + mapOf(RUM_VIEW_ID_CONTEXT_KEY to viewId) + ) + + // When + testedProvider.onContextUpdate( + Feature.RUM_FEATURE_NAME, + mapOf(RUM_VIEW_ID_CONTEXT_KEY to viewId, RUM_VIEW_TIME_OFFSET_CONTEXT_KEY to 10L) + ) + + // Then + assertThat(notificationCount).isEqualTo(1) + } + + @Test + fun `M not notify view change W onContextUpdate { invalid view }`() { + // Given + var notificationCount = 0 + val testedProvider = SessionReplayRumContextProvider { notificationCount++ } + + // When + testedProvider.onContextUpdate( + Feature.RUM_FEATURE_NAME, + mapOf(RUM_VIEW_ID_CONTEXT_KEY to NULL_UUID) + ) + testedProvider.onContextUpdate(Feature.RUM_FEATURE_NAME, emptyMap()) + + // Then + assertThat(notificationCount).isZero() + } + @Test fun `M provide a valid Rum context W getRumContext()`( @Forgery fakeApplicationId: UUID, @@ -43,11 +123,11 @@ internal class SessionReplayRumContextProviderTest { testedSessionReplayContextProvider.onContextUpdate( Feature.RUM_FEATURE_NAME, mapOf( - "application_id" to fakeApplicationId.toString(), - "session_id" to fakeSessionId.toString(), - "view_id" to fakeViewId.toString(), - "view_timestamp_offset" to fakeViewTimeOffsetMs, - "view_url" to fakeViewUrl + RUM_APPLICATION_ID_CONTEXT_KEY to fakeApplicationId.toString(), + RUM_SESSION_ID_CONTEXT_KEY to fakeSessionId.toString(), + RUM_VIEW_ID_CONTEXT_KEY to fakeViewId.toString(), + RUM_VIEW_TIME_OFFSET_CONTEXT_KEY to fakeViewTimeOffsetMs, + RUM_VIEW_URL_CONTEXT_KEY to fakeViewUrl ) ) @@ -74,10 +154,10 @@ internal class SessionReplayRumContextProviderTest { testedSessionReplayContextProvider.onContextUpdate( Feature.RUM_FEATURE_NAME, mapOf( - "application_id" to fakeApplicationId.toString(), - "session_id" to fakeSessionId.toString(), - "view_id" to fakeViewId.toString(), - "view_timestamp_offset" to fakeViewTimeOffsetMs + RUM_APPLICATION_ID_CONTEXT_KEY to fakeApplicationId.toString(), + RUM_SESSION_ID_CONTEXT_KEY to fakeSessionId.toString(), + RUM_VIEW_ID_CONTEXT_KEY to fakeViewId.toString(), + RUM_VIEW_TIME_OFFSET_CONTEXT_KEY to fakeViewTimeOffsetMs ) ) @@ -101,11 +181,11 @@ internal class SessionReplayRumContextProviderTest { testedSessionReplayContextProvider.onContextUpdate( Feature.RUM_FEATURE_NAME, mapOf( - "application_id" to fakeApplicationId.toString(), - "session_id" to fakeSessionId.toString(), - "view_id" to fakeViewId.toString(), - "view_timestamp_offset" to fakeViewTimeOffsetMs, - "view_url" to forge.anInt() + RUM_APPLICATION_ID_CONTEXT_KEY to fakeApplicationId.toString(), + RUM_SESSION_ID_CONTEXT_KEY to fakeSessionId.toString(), + RUM_VIEW_ID_CONTEXT_KEY to fakeViewId.toString(), + RUM_VIEW_TIME_OFFSET_CONTEXT_KEY to fakeViewTimeOffsetMs, + RUM_VIEW_URL_CONTEXT_KEY to forge.anInt() ) ) @@ -128,10 +208,10 @@ internal class SessionReplayRumContextProviderTest { testedSessionReplayContextProvider.onContextUpdate( Feature.RUM_FEATURE_NAME, mapOf( - "application_id" to fakeApplicationId.toString(), - "session_id" to fakeSessionId.toString(), - "view_id" to fakeViewId.toString(), - "view_timestamp_offset" to fakeViewTimeOffsetMs + RUM_APPLICATION_ID_CONTEXT_KEY to fakeApplicationId.toString(), + RUM_SESSION_ID_CONTEXT_KEY to fakeSessionId.toString(), + RUM_VIEW_ID_CONTEXT_KEY to fakeViewId.toString(), + RUM_VIEW_TIME_OFFSET_CONTEXT_KEY to fakeViewTimeOffsetMs ) ) }.apply { @@ -160,10 +240,10 @@ internal class SessionReplayRumContextProviderTest { testedSessionReplayContextProvider.onContextUpdate( Feature.RUM_FEATURE_NAME, mapOf( - "application_id" to fakeApplicationId.toString(), - "session_id" to fakeSessionId.toString(), - "view_id" to fakeViewId.toString(), - "view_timestamp_offset" to fakeViewTimeOffsetMs + RUM_APPLICATION_ID_CONTEXT_KEY to fakeApplicationId.toString(), + RUM_SESSION_ID_CONTEXT_KEY to fakeSessionId.toString(), + RUM_VIEW_ID_CONTEXT_KEY to fakeViewId.toString(), + RUM_VIEW_TIME_OFFSET_CONTEXT_KEY to fakeViewTimeOffsetMs ) ) From 98149ff7cc940c8108cbc985d953e265e61de94d Mon Sep 17 00:00:00 2001 From: jonathanmos <48201295+jonathanmos@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:29:54 +0300 Subject: [PATCH 07/12] PANA-8412: PR fixes --- .../api/dd-sdk-android-session-replay.api | 7 -- .../build.gradle.kts | 3 - ...ddedContentSlotRegistryInstrumentedTest.kt | 52 -------------- .../_SessionReplayInternalProxy.kt | 9 +-- .../internal/SessionReplayFeature.kt | 29 ++++---- .../internal/SessionReplayRecordCallback.kt | 37 +++++----- .../embedded/EmbeddedContentReceiver.kt | 8 +-- .../processor/RecordedDataProcessor.kt | 2 +- .../internal/processor/ResourceProcessor.kt | 12 +++- .../storage/EmbeddedContentRecordWriter.kt | 3 + .../SessionReplayInternalProxyTest.kt | 14 ++-- .../forge/FullSnapshotRecordForgeryFactory.kt | 4 +- ...IncrementalSnapshotRecordForgeryFactory.kt | 4 +- .../internal/SessionReplayFeatureTest.kt | 25 ------- .../EmbeddedContentResourceEventTest.kt | 58 ++++++++++++++++ .../processor/ResourceProcessorTest.kt | 4 +- .../sessionreplay/EmbeddedContentSlotTest.kt | 69 +++++++++++++++++++ 17 files changed, 194 insertions(+), 146 deletions(-) delete mode 100644 features/dd-sdk-android-session-replay/src/androidTest/kotlin/com/datadog/android/sessionreplay/internal/embedded/EmbeddedContentSlotRegistryInstrumentedTest.kt create mode 100644 features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/embedded/EmbeddedContentResourceEventTest.kt create mode 100644 instrumented/integration/src/androidTest/kotlin/com/datadog/android/sdk/integration/sessionreplay/EmbeddedContentSlotTest.kt 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 1ea49fa445..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 @@ -149,19 +149,12 @@ 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 static final fun addEmbeddedContentRecords (Ljava/util/List;Ljava/lang/String;Ljava/lang/String;)V - public static final fun addEmbeddedContentRecords (Ljava/util/List;Ljava/lang/String;Ljava/lang/String;Lcom/datadog/android/api/SdkCore;)V - public static final fun addEmbeddedContentResource (Ljava/lang/String;[BLjava/lang/String;)V - public static final fun addEmbeddedContentResource (Ljava/lang/String;[BLjava/lang/String;Lcom/datadog/android/api/SdkCore;)V - public static final fun setEmbeddedContentSlotId (Landroid/view/View;Ljava/lang/String;)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;)V 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;)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 diff --git a/features/dd-sdk-android-session-replay/build.gradle.kts b/features/dd-sdk-android-session-replay/build.gradle.kts index 24d2eace6d..84d44ff0e6 100644 --- a/features/dd-sdk-android-session-replay/build.gradle.kts +++ b/features/dd-sdk-android-session-replay/build.gradle.kts @@ -48,7 +48,6 @@ plugins { android { defaultConfig { consumerProguardFiles("consumer-rules.pro") - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } namespace = "com.datadog.android.sessionreplay" @@ -74,8 +73,6 @@ dependencies { } testImplementation(testFixtures(project(":dd-sdk-android-core"))) unmock(libs.robolectric) - - androidTestImplementation(libs.bundles.integrationTests) } unMock { diff --git a/features/dd-sdk-android-session-replay/src/androidTest/kotlin/com/datadog/android/sessionreplay/internal/embedded/EmbeddedContentSlotRegistryInstrumentedTest.kt b/features/dd-sdk-android-session-replay/src/androidTest/kotlin/com/datadog/android/sessionreplay/internal/embedded/EmbeddedContentSlotRegistryInstrumentedTest.kt deleted file mode 100644 index 10b22d246e..0000000000 --- a/features/dd-sdk-android-session-replay/src/androidTest/kotlin/com/datadog/android/sessionreplay/internal/embedded/EmbeddedContentSlotRegistryInstrumentedTest.kt +++ /dev/null @@ -1,52 +0,0 @@ -/* - * 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 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.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -internal class EmbeddedContentSlotRegistryInstrumentedTest { - - @Test - fun markedViewRetainsSlotWhenRemovedAndRestored() { - val context = InstrumentationRegistry.getInstrumentation().targetContext - val root = FrameLayout(context) - val embeddedView = View(context) - val fakeSlotId = "flutter/slot" - - InstrumentationRegistry.getInstrumentation().runOnMainSync { - root.addView(embeddedView) - _SessionReplayInternalProxy.setEmbeddedContentSlotId(embeddedView, fakeSlotId) - - assertTrue(EmbeddedContentSlotRegistry.hasMarkedSlots()) - - root.removeView(embeddedView) - assertEquals( - fakeSlotId, - embeddedView.getTag(R.id.datadog_session_replay_slot_id) - ) - - root.addView(embeddedView) - assertTrue(EmbeddedContentSlotRegistry.hasMarkedSlots()) - assertTrue(EmbeddedContentSlotRegistry.isSlotMarked(fakeSlotId)) - - _SessionReplayInternalProxy.setEmbeddedContentSlotId(embeddedView, null) - assertFalse(EmbeddedContentSlotRegistry.hasMarkedSlots()) - assertFalse(EmbeddedContentSlotRegistry.isSlotMarked(fakeSlotId)) - } - } -} 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 4d607e6b2f..c8afc867ad 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 @@ -14,6 +14,7 @@ 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 import com.datadog.android.sessionreplay.internal.embedded.EmbeddedContentSlotRegistry @@ -55,7 +56,6 @@ class _SessionReplayInternalProxy(private val builder: SessionReplayConfiguratio * embedded-content wireframe from the next capture. Reassigning the current slot identifier * has no effect. */ - @JvmStatic @UiThread fun setEmbeddedContentSlotId(view: View, slotId: String?) { val previousSlotId = @@ -82,9 +82,7 @@ class _SessionReplayInternalProxy(private val builder: SessionReplayConfiguratio /** * Queues Session Replay records produced by an embedded renderer. */ - @JvmStatic @AnyThread - @JvmOverloads fun addEmbeddedContentRecords( records: List>, slotId: String, @@ -100,9 +98,7 @@ class _SessionReplayInternalProxy(private val builder: SessionReplayConfiguratio /** * Queues a Session Replay resource produced by an embedded renderer. */ - @JvmStatic @AnyThread - @JvmOverloads fun addEmbeddedContentResource( identifier: String, resourceData: ByteArray, @@ -118,7 +114,8 @@ class _SessionReplayInternalProxy(private val builder: SessionReplayConfiguratio private fun sendEmbeddedContentEvent(sdkCore: SdkCore, event: EmbeddedContentEvent) { (sdkCore as? FeatureSdkCore) ?.getFeature(Feature.SESSION_REPLAY_FEATURE_NAME) - ?.sendEvent(event) + ?.unwrap() + ?.receiveEmbeddedContentEvent(event) } private fun snapshotRecords(records: List>): List> { 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 e35a2a988f..bcbffdf6c2 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 @@ -30,6 +30,8 @@ import com.datadog.android.sessionreplay.internal.embedded.EmbeddedContentReceiv 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 @@ -37,6 +39,7 @@ import com.datadog.android.sessionreplay.internal.resources.ResourceDataStoreMan 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 @@ -130,8 +133,8 @@ internal class SessionReplayFeature( private val rumContextProvider = SessionReplayRumContextProvider { onRumViewChanged() } - private var resourceProcessor: ResourceProcessor? = null - private var embeddedContentRecordWriter: EmbeddedContentRecordWriter? = null + private var resourceProcessor: ResourceProcessor = NoOpResourceProcessor() + private var embeddedContentRecordWriter: EmbeddedContentRecordWriter = NoOpEmbeddedContentRecordWriter() private val embeddedContentReceiver = EmbeddedContentReceiver( rumContextProvider = rumContextProvider, recordWriter = { embeddedContentRecordWriter }, @@ -164,7 +167,7 @@ internal class SessionReplayFeature( val sessionReplayRecordWriter = createDataWriter() dataWriter = sessionReplayRecordWriter embeddedContentRecordWriter = sessionReplayRecordWriter - resourceProcessor = ResourceProcessor( + resourceProcessor = DefaultResourceProcessor( resourceDataStoreManager = resourceDataStoreManager, resourcesWriter = resourcesFeature.dataWriter ) @@ -205,8 +208,8 @@ internal class SessionReplayFeature( sessionReplayRecorder.unregisterCallbacks() sessionReplayRecorder.stopProcessingRecords() dataWriter = NoOpRecordWriter() - embeddedContentRecordWriter = null - resourceProcessor = null + embeddedContentRecordWriter = NoOpEmbeddedContentRecordWriter() + resourceProcessor = NoOpResourceProcessor() sessionReplayRecorder = NoOpRecorder() initialized.set(false) } @@ -215,17 +218,15 @@ internal class SessionReplayFeature( // region EventReceiver + internal fun receiveEmbeddedContentEvent(event: EmbeddedContentEvent) { + if (checkIfInitialized()) { + embeddedContentReceiver.receive(event) + } + } + override fun onReceive(event: Any) { when (event) { - is EmbeddedContentEvent -> { - if (checkIfInitialized()) { - sdkCore.getFeature(Feature.SESSION_REPLAY_FEATURE_NAME)?.withContext { - if (initialized.get()) { - embeddedContentReceiver.receive(event) - } - } - } - } + is EmbeddedContentEvent -> receiveEmbeddedContentEvent(event) is Map<*, *> -> { if (checkIfInitialized()) { handleRumSession(event) 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 e4f268c2bf..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,31 +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) } - @Suppress("UNCHECKED_CAST") fun onEmbeddedRecordsForViewSent(viewId: String, recordsCount: Int) { - if (recordsCount > 0) { - 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 - } + 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/embedded/EmbeddedContentReceiver.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/embedded/EmbeddedContentReceiver.kt index 30e2749ca7..be7c0fedaf 100644 --- 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 @@ -16,8 +16,8 @@ import com.google.gson.JsonObject internal class EmbeddedContentReceiver( private val rumContextProvider: RumContextProvider, - private val recordWriter: () -> EmbeddedContentRecordWriter?, - private val resourceProcessor: () -> ResourceProcessor?, + private val recordWriter: () -> EmbeddedContentRecordWriter, + private val resourceProcessor: () -> ResourceProcessor, private val isRecording: () -> Boolean, private val internalLogger: InternalLogger ) { @@ -70,7 +70,7 @@ internal class EmbeddedContentReceiver( addProperty(VIEW_ID_KEY, event.viewId) add(RECORDS_KEY, records) } - recordWriter()?.writeRaw( + recordWriter().writeRaw( enrichedRecord.toString().toByteArray(Charsets.UTF_8), event.viewId, records.size() @@ -78,7 +78,7 @@ internal class EmbeddedContentReceiver( } is EmbeddedContentEvent.Resource -> { - resourceProcessor()?.process(event.identifier, event.data, event.mimeType) + resourceProcessor().process(event.identifier, event.data, event.mimeType) } } } 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 f567293ab1..63b719e79a 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/RecordedDataProcessor.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/RecordedDataProcessor.kt @@ -30,7 +30,7 @@ internal class RecordedDataProcessor( private val mutationResolver: MutationResolver, private val timeProvider: TimeProvider, private val nodeFlattener: NodeFlattener = NodeFlattener(), - private val resourceProcessor: ResourceProcessor = ResourceProcessor( + private val resourceProcessor: ResourceProcessor = DefaultResourceProcessor( resourceDataStoreManager, resourcesWriter ) 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 index 4604275166..b9c11c3b08 100644 --- 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 @@ -8,13 +8,19 @@ 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 -internal class ResourceProcessor( +@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 { - fun process(identifier: String, data: ByteArray, mimeType: String?) { + override fun process(identifier: String, data: ByteArray, mimeType: String?) { if (!resourceDataStoreManager.markResourceAsSentIfNew(identifier)) { return } 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 index 44bc39b72e..0604f2f775 100644 --- 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 @@ -6,6 +6,9 @@ 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/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 a9d77bce44..c61d1157a9 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 @@ -11,6 +11,7 @@ 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 com.datadog.android.sessionreplay.internal.embedded.EmbeddedContentSlotRegistry @@ -53,6 +54,9 @@ internal class SessionReplayInternalProxyTest { @Mock lateinit var mockFeatureScope: FeatureScope + @Mock + lateinit var mockSessionReplayFeature: SessionReplayFeature + @FloatForgery var fakeSampleRate: Float = 0f @@ -150,6 +154,7 @@ internal class SessionReplayInternalProxyTest { val record = mutableMapOf(FAKE_RECORD_DATA_KEY to nestedData) val records = mutableListOf>(record) whenever(mockSdkCore.getFeature(Feature.SESSION_REPLAY_FEATURE_NAME)) doReturn mockFeatureScope + whenever(mockFeatureScope.unwrap()) doReturn mockSessionReplayFeature // When _SessionReplayInternalProxy.addEmbeddedContentRecords( @@ -163,8 +168,8 @@ internal class SessionReplayInternalProxyTest { records.clear() // Then - argumentCaptor { - verify(mockFeatureScope).sendEvent(capture()) + 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)) @@ -180,6 +185,7 @@ internal class SessionReplayInternalProxyTest { val resourceData = byteArrayOf(1, 2, 3) val expectedResourceData = resourceData.copyOf() whenever(mockSdkCore.getFeature(Feature.SESSION_REPLAY_FEATURE_NAME)) doReturn mockFeatureScope + whenever(mockFeatureScope.unwrap()) doReturn mockSessionReplayFeature // When _SessionReplayInternalProxy.addEmbeddedContentResource( @@ -191,8 +197,8 @@ internal class SessionReplayInternalProxyTest { resourceData.fill(0) // Then - argumentCaptor { - verify(mockFeatureScope).sendEvent(capture()) + argumentCaptor { + verify(mockSessionReplayFeature).receiveEmbeddedContentEvent(capture()) val event = firstValue as EmbeddedContentEvent.Resource assertThat(event.identifier).isEqualTo(FAKE_RESOURCE_ID) assertThat(event.data).isEqualTo(expectedResourceData) 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 fbef93d282..29f91153c1 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 @@ -265,10 +265,6 @@ internal class SessionReplayFeatureTest { val callback = it.getArgument<(EventBatchWriter) -> Unit>(0) callback(mockEventBatchWriter) } - whenever(mockFeatureScope.withContext(eq(emptySet()), any())) doAnswer { - val callback = it.getArgument<(DatadogContext) -> Unit>(it.arguments.lastIndex) - callback(mockDatadogContext) - } whenever(mockFeatureScope.withWriteContext(eq(emptySet()), any())) doAnswer { val callback = it.getArgument<(DatadogContext, EventWriteScope) -> Unit>(it.arguments.lastIndex) callback(mockDatadogContext, mockEventWriteScope) @@ -311,27 +307,6 @@ internal class SessionReplayFeatureTest { } } - @Test - fun `M schedule embedded records W onReceive { feature initialized }`() { - // Given - val mockFeatureScope = mock() - whenever(mockSdkCore.getFeature(Feature.SESSION_REPLAY_FEATURE_NAME)) - .thenReturn(mockFeatureScope) - testedFeature.onInitialize(appContext.mockInstance) - val event = EmbeddedContentEvent.RecordBatch( - records = listOf(mapOf(FAKE_RECORD_TYPE_KEY to 10L)), - slotId = FAKE_EMBEDDED_SLOT_ID, - viewId = FAKE_EMBEDDED_VIEW_ID - ) - - // When - testedFeature.onReceive(event) - - // Then - verify(mockFeatureScope).withContext(eq(emptySet()), any()) - verify(mockFeatureScope, never()).withWriteContext(any(), any()) - } - @Test fun `M update feature context for telemetry W initialize()`() { // Given 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/processor/ResourceProcessorTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/ResourceProcessorTest.kt index 7fb847c0ce..6821d7b36d 100644 --- 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 @@ -33,7 +33,7 @@ import org.mockito.quality.Strictness @ForgeConfiguration(ForgeConfigurator::class) internal class ResourceProcessorTest { - private lateinit var testedProcessor: ResourceProcessor + private lateinit var testedProcessor: DefaultResourceProcessor @Mock lateinit var mockResourceDataStoreManager: ResourceDataStoreManager @@ -43,7 +43,7 @@ internal class ResourceProcessorTest { @BeforeEach fun `set up`() { - testedProcessor = ResourceProcessor( + testedProcessor = DefaultResourceProcessor( resourceDataStoreManager = mockResourceDataStoreManager, resourcesWriter = mockResourcesWriter ) 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" + } +} From 8b6c2f34161ba3fed8dbb4d8fe54376851bb2dfd Mon Sep 17 00:00:00 2001 From: jonathanmos <48201295+jonathanmos@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:51:53 +0300 Subject: [PATCH 08/12] PANA-8412: Scope embedded slot registry to feature --- .../android/sessionreplay/SessionReplay.kt | 6 + .../_SessionReplayInternalProxy.kt | 4 +- .../internal/DefaultRecorderProvider.kt | 5 +- .../internal/RecorderProvider.kt | 4 +- .../internal/SessionReplayFeature.kt | 16 ++- .../embedded/EmbeddedContentSlotRegistry.kt | 52 ++++++-- .../recorder/SessionReplayRecorder.kt | 5 +- .../mapper/EmbeddedContentViewMapper.kt | 22 +++- .../SessionReplayInternalProxyTest.kt | 123 +++++++++++------- .../internal/SessionReplayFeatureTest.kt | 33 +++-- .../EmbeddedContentSlotRegistryTest.kt | 64 ++++++--- .../mapper/EmbeddedContentViewMapperTest.kt | 22 ++-- 12 files changed, 243 insertions(+), 113 deletions(-) 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 c8afc867ad..16ad094ee2 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/_SessionReplayInternalProxy.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/_SessionReplayInternalProxy.kt @@ -17,7 +17,6 @@ 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 -import com.datadog.android.sessionreplay.internal.embedded.EmbeddedContentSlotRegistry /** * This class exposes internal methods that are used by other Datadog modules and cross platform @@ -69,9 +68,10 @@ class _SessionReplayInternalProxy(private val builder: SessionReplayConfiguratio 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) - EmbeddedContentSlotRegistry.notifySlotChanged( + SessionReplay.registeredFeature()?.notifyEmbeddedContentSlotChanged( previousRegistration = previousRegistration, newRegistration = newRegistration ) 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 bcbffdf6c2..3b03ca72a6 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeature.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeature.kt @@ -8,6 +8,7 @@ package com.datadog.android.sessionreplay.internal import android.app.Application import android.content.Context +import androidx.annotation.UiThread import com.datadog.android.api.InternalLogger import com.datadog.android.api.SdkCore import com.datadog.android.api.feature.Feature @@ -27,6 +28,7 @@ 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 @@ -66,6 +68,7 @@ internal class SessionReplayFeature( ) : StorageBackedFeature, FeatureEventReceiver { private val currentRumSessionId = AtomicReference() + internal val embeddedContentSlotRegistry = EmbeddedContentSlotRegistry() @Suppress("LongParameterList") internal constructor( @@ -178,7 +181,8 @@ internal class SessionReplayFeature( resourceWriter = resourcesFeature.dataWriter, recordWriter = dataWriter, rumContextProvider = rumContextProvider, - application = appContext + application = appContext, + embeddedContentSlotRegistry = embeddedContentSlotRegistry ) sessionReplayRecorder.registerCallbacks() initialized.set(true) @@ -224,6 +228,14 @@ internal class SessionReplayFeature( } } + @UiThread + internal fun notifyEmbeddedContentSlotChanged( + previousRegistration: EmbeddedContentSlotRegistration?, + newRegistration: EmbeddedContentSlotRegistration? + ) { + embeddedContentSlotRegistry.notifySlotChanged(previousRegistration, newRegistration) + } + override fun onReceive(event: Any) { when (event) { is EmbeddedContentEvent -> receiveEmbeddedContentEvent(event) @@ -426,7 +438,7 @@ internal class SessionReplayFeature( if (!isRecording.get()) { return } - if (EmbeddedContentSlotRegistry.hasMarkedSlots()) { + if (embeddedContentSlotRegistry.hasMarkedSlots()) { sessionReplayRecorder.requestCapture() } } 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 index 435271d48e..00e24c9ae6 100644 --- 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 @@ -9,18 +9,27 @@ 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) -internal object EmbeddedContentSlotRegistry { + fun deactivate() { + active.set(false) + } + + fun isActive(): Boolean = active.get() +} + +internal class EmbeddedContentSlotRegistry { private val registrations = mutableListOf>() @AnyThread fun hasMarkedSlots(): Boolean { return synchronized(registrations) { - removeGarbageCollectedRegistrations() + removeInactiveRegistrations() registrations.isNotEmpty() } } @@ -28,7 +37,7 @@ internal object EmbeddedContentSlotRegistry { @AnyThread fun isSlotMarked(slotId: String): Boolean { return synchronized(registrations) { - removeGarbageCollectedRegistrations() + removeInactiveRegistrations() registrations.any { it.get()?.slotId == slotId } } } @@ -38,20 +47,39 @@ internal object EmbeddedContentSlotRegistry { previousRegistration: EmbeddedContentSlotRegistration?, newRegistration: EmbeddedContentSlotRegistration? ) { + previousRegistration?.deactivate() synchronized(registrations) { registrations.removeAll { val registration = it.get() - registration == null || registration === previousRegistration - } - if (newRegistration != null) { - @Suppress("UnsafeThirdPartyFunctionCall") // WeakReference construction has no documented exception. - val weakRegistration = WeakReference(newRegistration) - registrations += weakRegistration + registration == null || + !registration.isActive() || + registration === previousRegistration } + trackRegistration(newRegistration) + } + } + + @UiThread + fun track(registration: EmbeddedContentSlotRegistration) { + synchronized(registrations) { + removeInactiveRegistrations() + trackRegistration(registration) } } - private fun removeGarbageCollectedRegistrations() { - registrations.removeAll { it.get() == null } + private fun trackRegistration(registration: EmbeddedContentSlotRegistration?) { + val isAlreadyTracked = registrations.any { it.get() === registration } + if (registration != null && registration.isActive() && !isAlreadyTracked) { + @Suppress("UnsafeThirdPartyFunctionCall") // WeakReference construction has no documented exception. + val weakRegistration = WeakReference(registration) + registrations += weakRegistration + } + } + + private fun removeInactiveRegistrations() { + registrations.removeAll { + val registration = it.get() + registration == null || !registration.isActive() + } } } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/SessionReplayRecorder.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/SessionReplayRecorder.kt index caeb0eb6ef..473fc61d72 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/SessionReplayRecorder.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/SessionReplayRecorder.kt @@ -25,6 +25,7 @@ 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 @@ -94,6 +95,7 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { resourceDataStoreManager: ResourceDataStoreManager, dynamicOptimizationEnabled: Boolean, internalCallback: SessionReplayInternalCallback, + embeddedContentSlotRegistry: EmbeddedContentSlotRegistry, heatmapIdentifierRegistry: HeatmapIdentifierRegistry? = null ) { val internalLogger = sdkCore.internalLogger @@ -145,7 +147,8 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { colorStringFormatter = colorStringFormatter, viewBoundsResolver = viewBoundsResolver, drawableToColorMapper = drawableToColorMapper, - viewUtilsInternal = viewUtilsInternal + viewUtilsInternal = viewUtilsInternal, + embeddedContentSlotRegistry = embeddedContentSlotRegistry ) val bitmapCachesManager = BitmapCachesManager( 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 index 25b69cf190..23bcc99098 100644 --- 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 @@ -10,6 +10,7 @@ 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 @@ -27,14 +28,15 @@ internal class EmbeddedContentViewMapper( colorStringFormatter: ColorStringFormatter, viewBoundsResolver: ViewBoundsResolver, drawableToColorMapper: DrawableToColorMapper, - private val viewUtilsInternal: ViewUtilsInternal + private val viewUtilsInternal: ViewUtilsInternal, + private val embeddedContentSlotRegistry: EmbeddedContentSlotRegistry ) : BaseWireframeMapper( viewIdentifierResolver, colorStringFormatter, viewBoundsResolver, drawableToColorMapper ) { - private val cache = EmbeddedContentViewCache() + private val cache = EmbeddedContentViewCache(embeddedContentSlotRegistry) private var isSnapshotActive = false @UiThread @@ -42,7 +44,7 @@ internal class EmbeddedContentViewMapper( @UiThread fun beginSnapshot() { - isSnapshotActive = !cache.isEmpty() || EmbeddedContentSlotRegistry.hasMarkedSlots() + isSnapshotActive = !cache.isEmpty() || embeddedContentSlotRegistry.hasMarkedSlots() if (isSnapshotActive) { cache.beginSnapshot() } @@ -66,7 +68,9 @@ internal class EmbeddedContentViewMapper( asyncJobStatusCallback: AsyncJobStatusCallback, internalLogger: InternalLogger ): List { - val slotId = view.sessionReplaySlotId() ?: return emptyList() + val registration = view.sessionReplaySlotRegistration() ?: return emptyList() + val slotId = registration.slotId + embeddedContentSlotRegistry.track(registration) isSnapshotActive = true val wireframeId = viewIdentifierResolver.resolveChildUniqueIdentifier( view, @@ -124,11 +128,17 @@ internal class EmbeddedContentViewMapper( 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 class EmbeddedContentViewCache( + private val embeddedContentSlotRegistry: EmbeddedContentSlotRegistry + ) { private data class Entry( val wireframeId: Long, var lastSeenSnapshot: Long @@ -158,7 +168,7 @@ internal class EmbeddedContentViewMapper( val iterator = entries.entries.iterator() while (iterator.hasNext()) { val (slotId, entry) = iterator.next() - if (!EmbeddedContentSlotRegistry.isSlotMarked(slotId)) { + if (!embeddedContentSlotRegistry.isSlotMarked(slotId)) { iterator.remove() } else if (entry.lastSeenSnapshot != currentSnapshot) { hiddenWireframes += MobileSegment.Wireframe.EmbeddedContentWireframe( 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 c61d1157a9..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 @@ -14,11 +14,12 @@ 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 com.datadog.android.sessionreplay.internal.embedded.EmbeddedContentSlotRegistry 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 @@ -30,6 +31,7 @@ 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), @@ -60,6 +62,16 @@ internal class SessionReplayInternalProxyTest { @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 @@ -77,84 +89,101 @@ internal class SessionReplayInternalProxyTest { @Test fun `M set slot tag W setEmbeddedContentSlotId`() { + stubRegisteredFeature() val registrationCaptor = argumentCaptor() - try { - // 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() - assertThat(EmbeddedContentSlotRegistry.isSlotMarked(FAKE_SLOT_ID)).isTrue() - } finally { - registrationCaptor.allValues.firstOrNull()?.let { - EmbeddedContentSlotRegistry.notifySlotChanged(it, null) - } - } + + // 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) - EmbeddedContentSlotRegistry.notifySlotChanged(null, fakeRegistration) // When _SessionReplayInternalProxy.setEmbeddedContentSlotId(mockView, null) // Then - assertThat(EmbeddedContentSlotRegistry.isSlotMarked(FAKE_SLOT_ID)).isFalse() + 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() - try { - // When - _SessionReplayInternalProxy.setEmbeddedContentSlotId(mockView, FAKE_SLOT_ID) - _SessionReplayInternalProxy.setEmbeddedContentSlotId(mockView, FAKE_SLOT_ID) + // 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() - assertThat(EmbeddedContentSlotRegistry.isSlotMarked(FAKE_SLOT_ID)).isTrue() - } finally { - registrationCaptor.allValues.firstOrNull()?.let { - EmbeddedContentSlotRegistry.notifySlotChanged(it, null) - } - } + // 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) - whenever(mockSdkCore.getFeature(Feature.SESSION_REPLAY_FEATURE_NAME)) doReturn mockFeatureScope - whenever(mockFeatureScope.unwrap()) doReturn mockSessionReplayFeature // When _SessionReplayInternalProxy.addEmbeddedContentRecords( @@ -182,11 +211,9 @@ internal class SessionReplayInternalProxyTest { @Test fun `M send resource event W addEmbeddedContentResource`() { // Given + stubRegisteredFeature() val resourceData = byteArrayOf(1, 2, 3) val expectedResourceData = resourceData.copyOf() - whenever(mockSdkCore.getFeature(Feature.SESSION_REPLAY_FEATURE_NAME)) doReturn mockFeatureScope - whenever(mockFeatureScope.unwrap()) doReturn mockSessionReplayFeature - // When _SessionReplayInternalProxy.addEmbeddedContentResource( identifier = FAKE_RESOURCE_ID, @@ -216,4 +243,10 @@ internal class SessionReplayInternalProxyTest { 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/internal/SessionReplayFeatureTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeatureTest.kt index 29f91153c1..2a9da3721d 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeatureTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeatureTest.kt @@ -26,7 +26,6 @@ import com.datadog.android.sessionreplay.internal.SessionReplayRumContextProvide 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.SegmentRequestFactory import com.datadog.android.sessionreplay.internal.recorder.NoOpRecorder import com.datadog.android.sessionreplay.internal.recorder.Recorder @@ -125,7 +124,7 @@ internal class SessionReplayFeatureTest { startRecordingImmediately = true, touchPrivacy = fakeConfiguration.touchPrivacy, configuredSampleRate = fakeSampleRate - ) { _, _, _, _, _ -> mockRecorder } + ) { _, _, _, _, _, _ -> mockRecorder } } @Test @@ -151,7 +150,7 @@ internal class SessionReplayFeatureTest { fun `M request embedded capture W RUM view changes { recording active }`() { // Given val fakeRegistration = EmbeddedContentSlotRegistration(FAKE_EMBEDDED_SLOT_ID) - EmbeddedContentSlotRegistry.notifySlotChanged(null, fakeRegistration) + testedFeature.embeddedContentSlotRegistry.notifySlotChanged(null, fakeRegistration) testedFeature.onInitialize(appContext.mockInstance) val contextReceiver = argumentCaptor().also { verify(mockSdkCore).setContextUpdateReceiver(it.capture()) @@ -172,14 +171,14 @@ internal class SessionReplayFeatureTest { verify(mockRecorder).requestCapture() // Cleanup - EmbeddedContentSlotRegistry.notifySlotChanged(fakeRegistration, null) + 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) - EmbeddedContentSlotRegistry.notifySlotChanged(null, fakeRegistration) + testedFeature.embeddedContentSlotRegistry.notifySlotChanged(null, fakeRegistration) testedFeature.onInitialize(appContext.mockInstance) testedFeature.startRecording() val contextReceiver = argumentCaptor().also { @@ -196,14 +195,14 @@ internal class SessionReplayFeatureTest { verify(mockRecorder).requestCapture() // Cleanup - EmbeddedContentSlotRegistry.notifySlotChanged(fakeRegistration, null) + 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) - EmbeddedContentSlotRegistry.notifySlotChanged(null, fakeRegistration) + testedFeature.embeddedContentSlotRegistry.notifySlotChanged(null, fakeRegistration) testedFeature.onInitialize(appContext.mockInstance) val contextReceiver = argumentCaptor().also { verify(mockSdkCore).setContextUpdateReceiver(it.capture()) @@ -219,7 +218,7 @@ internal class SessionReplayFeatureTest { verify(mockRecorder, never()).requestCapture() // Cleanup - EmbeddedContentSlotRegistry.notifySlotChanged(fakeRegistration, null) + testedFeature.embeddedContentSlotRegistry.notifySlotChanged(fakeRegistration, null) } @Test @@ -365,7 +364,7 @@ internal class SessionReplayFeatureTest { startRecordingImmediately = true, touchPrivacy = fakeConfiguration.touchPrivacy, configuredSampleRate = fakeSampleRate - ) { _, _, _, _, _ -> mockRecorder } + ) { _, _, _, _, _, _ -> mockRecorder } // When testedFeature.onInitialize(appContext.mockInstance) @@ -890,7 +889,7 @@ internal class SessionReplayFeatureTest { touchPrivacy = fakeConfiguration.touchPrivacy, startRecordingImmediately = true, configuredSampleRate = fakeSampleRate - ) { _, _, _, _, _ -> mockRecorder } + ) { _, _, _, _, _, _ -> mockRecorder } testedFeature.onInitialize(appContext.mockInstance) val rumSessionUpdateBusMessage1 = mapOf( SessionReplayFeature.SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY to @@ -941,7 +940,7 @@ internal class SessionReplayFeatureTest { touchPrivacy = fakeConfiguration.touchPrivacy, startRecordingImmediately = false, configuredSampleRate = fakeSampleRate - ) { _, _, _, _, _ -> mockRecorder } + ) { _, _, _, _, _, _ -> mockRecorder } testedFeature.onInitialize(appContext.mockInstance) val rumSessionUpdateBusMessage1 = mapOf( SessionReplayFeature.SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY to @@ -1223,7 +1222,7 @@ internal class SessionReplayFeatureTest { touchPrivacy = fakeConfiguration.touchPrivacy, startRecordingImmediately = scenario.startRecordingImmediately, configuredSampleRate = fakeSampleRate - ) { _, _, _, _, _ -> mockRecorder } + ) { _, _, _, _, _, _ -> mockRecorder } testedFeature.onInitialize(fakeContext) testedFeature.onReceive(event) @@ -1262,7 +1261,7 @@ internal class SessionReplayFeatureTest { touchPrivacy = fakeConfiguration.touchPrivacy, startRecordingImmediately = true, configuredSampleRate = fakeSampleRate - ) { _, _, _, _, _ -> mockRecorder } + ) { _, _, _, _, _, _ -> mockRecorder } testedFeature.onInitialize(fakeContext) testedFeature.onReceive(event) testedFeature.onReceive(event) @@ -1294,7 +1293,7 @@ internal class SessionReplayFeatureTest { touchPrivacy = fakeConfiguration.touchPrivacy, startRecordingImmediately = false, configuredSampleRate = fakeSampleRate - ) { _, _, _, _, _ -> mockRecorder } + ) { _, _, _, _, _, _ -> mockRecorder } testedFeature.onInitialize(fakeContext) testedFeature.manuallyStartRecording() testedFeature.onReceive(event) @@ -1328,7 +1327,7 @@ internal class SessionReplayFeatureTest { touchPrivacy = fakeConfiguration.touchPrivacy, startRecordingImmediately = true, configuredSampleRate = fakeSampleRate - ) { _, _, _, _, _ -> mockRecorder } + ) { _, _, _, _, _, _ -> mockRecorder } testedFeature.onInitialize(fakeContext) testedFeature.onReceive(event) testedFeature.manuallyStopRecording() @@ -1365,7 +1364,7 @@ internal class SessionReplayFeatureTest { touchPrivacy = fakeConfiguration.touchPrivacy, startRecordingImmediately = true, configuredSampleRate = fakeSampleRate - ) { _, _, _, _, _ -> mockRecorder } + ) { _, _, _, _, _, _ -> mockRecorder } testedFeature.onInitialize(fakeContext) testedFeature.onReceive(event1) @@ -1451,7 +1450,7 @@ internal class SessionReplayFeatureTest { startRecordingImmediately = true, touchPrivacy = fakeConfiguration.touchPrivacy, configuredSampleRate = 100f - ) { _, _, _, _, _ -> mockRecorder } + ) { _, _, _, _, _, _ -> mockRecorder } testedFeature.onInitialize(appContext.mockInstance) testedFeature.onReceive( 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 index 8d63b854fc..eba709057a 100644 --- 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 @@ -25,6 +25,34 @@ import org.mockito.quality.Strictness @ForgeConfiguration(ForgeConfigurator::class) internal class EmbeddedContentSlotRegistryTest { + private val testedRegistry = EmbeddedContentSlotRegistry() + + @Test + fun `M keep registry state isolated W separate instances`() { + // Given + val otherRegistry = EmbeddedContentSlotRegistry() + val fakeRegistration = EmbeddedContentSlotRegistration(FAKE_SLOT_ID) + + // When + testedRegistry.notifySlotChanged(null, fakeRegistration) + + // Then + assertThat(testedRegistry.hasMarkedSlots()).isTrue() + assertThat(otherRegistry.hasMarkedSlots()).isFalse() + } + + @Test + fun `M track active registration W track`() { + // Given + val fakeRegistration = EmbeddedContentSlotRegistration(FAKE_SLOT_ID) + + // When + testedRegistry.track(fakeRegistration) + + // Then + assertThat(testedRegistry.isSlotMarked(FAKE_SLOT_ID)).isTrue() + } + @Test fun `M track slot W notifySlotChanged { slot is set }`() { // Given @@ -32,13 +60,13 @@ internal class EmbeddedContentSlotRegistryTest { try { // When - EmbeddedContentSlotRegistry.notifySlotChanged(null, fakeRegistration) + testedRegistry.notifySlotChanged(null, fakeRegistration) // Then - assertThat(EmbeddedContentSlotRegistry.hasMarkedSlots()).isTrue() - assertThat(EmbeddedContentSlotRegistry.isSlotMarked(FAKE_SLOT_ID)).isTrue() + assertThat(testedRegistry.hasMarkedSlots()).isTrue() + assertThat(testedRegistry.isSlotMarked(FAKE_SLOT_ID)).isTrue() } finally { - EmbeddedContentSlotRegistry.notifySlotChanged(fakeRegistration, null) + testedRegistry.notifySlotChanged(fakeRegistration, null) } } @@ -46,14 +74,14 @@ internal class EmbeddedContentSlotRegistryTest { fun `M stop tracking slot W notifySlotChanged { slot is cleared }`() { // Given val fakeRegistration = EmbeddedContentSlotRegistration(FAKE_SLOT_ID) - EmbeddedContentSlotRegistry.notifySlotChanged(null, fakeRegistration) + testedRegistry.notifySlotChanged(null, fakeRegistration) // When - EmbeddedContentSlotRegistry.notifySlotChanged(fakeRegistration, null) + testedRegistry.notifySlotChanged(fakeRegistration, null) // Then - assertThat(EmbeddedContentSlotRegistry.hasMarkedSlots()).isFalse() - assertThat(EmbeddedContentSlotRegistry.isSlotMarked(FAKE_SLOT_ID)).isFalse() + assertThat(testedRegistry.hasMarkedSlots()).isFalse() + assertThat(testedRegistry.isSlotMarked(FAKE_SLOT_ID)).isFalse() } @Test @@ -61,20 +89,20 @@ internal class EmbeddedContentSlotRegistryTest { // Given val fakeOldRegistration = EmbeddedContentSlotRegistration(FAKE_OLD_SLOT_ID) val fakeNewRegistration = EmbeddedContentSlotRegistration(FAKE_NEW_SLOT_ID) - EmbeddedContentSlotRegistry.notifySlotChanged(null, fakeOldRegistration) + testedRegistry.notifySlotChanged(null, fakeOldRegistration) try { // When - EmbeddedContentSlotRegistry.notifySlotChanged( + testedRegistry.notifySlotChanged( fakeOldRegistration, fakeNewRegistration ) // Then - assertThat(EmbeddedContentSlotRegistry.isSlotMarked(FAKE_OLD_SLOT_ID)).isFalse() - assertThat(EmbeddedContentSlotRegistry.isSlotMarked(FAKE_NEW_SLOT_ID)).isTrue() + assertThat(testedRegistry.isSlotMarked(FAKE_OLD_SLOT_ID)).isFalse() + assertThat(testedRegistry.isSlotMarked(FAKE_NEW_SLOT_ID)).isTrue() } finally { - EmbeddedContentSlotRegistry.notifySlotChanged(fakeNewRegistration, null) + testedRegistry.notifySlotChanged(fakeNewRegistration, null) } } @@ -83,17 +111,17 @@ internal class EmbeddedContentSlotRegistryTest { // Given val fakeStaleRegistration = EmbeddedContentSlotRegistration(FAKE_SLOT_ID) val fakeCurrentRegistration = EmbeddedContentSlotRegistration(FAKE_SLOT_ID) - EmbeddedContentSlotRegistry.notifySlotChanged(null, fakeStaleRegistration) - EmbeddedContentSlotRegistry.notifySlotChanged(null, fakeCurrentRegistration) + testedRegistry.notifySlotChanged(null, fakeStaleRegistration) + testedRegistry.notifySlotChanged(null, fakeCurrentRegistration) try { // When - EmbeddedContentSlotRegistry.notifySlotChanged(fakeStaleRegistration, null) + testedRegistry.notifySlotChanged(fakeStaleRegistration, null) // Then - assertThat(EmbeddedContentSlotRegistry.isSlotMarked(FAKE_SLOT_ID)).isTrue() + assertThat(testedRegistry.isSlotMarked(FAKE_SLOT_ID)).isTrue() } finally { - EmbeddedContentSlotRegistry.notifySlotChanged(fakeCurrentRegistration, null) + testedRegistry.notifySlotChanged(fakeCurrentRegistration, null) } } 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 index 3362b1b24b..766f55f953 100644 --- 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 @@ -63,14 +63,17 @@ internal class EmbeddedContentViewMapperTest : LegacyBaseWireframeMapperTest() { 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( @@ -90,14 +93,14 @@ internal class EmbeddedContentViewMapperTest : LegacyBaseWireframeMapperTest() { mockColorStringFormatter, mockViewBoundsResolver, mockDrawableToColorMapper, - mockViewUtilsInternal + mockViewUtilsInternal, + embeddedContentSlotRegistry ) - EmbeddedContentSlotRegistry.notifySlotChanged(null, fakeSlotRegistration) } @AfterEach fun `tear down`() { - EmbeddedContentSlotRegistry.notifySlotChanged(fakeSlotRegistration, null) + embeddedContentSlotRegistry.notifySlotChanged(fakeSlotRegistration, null) } @Test @@ -111,6 +114,7 @@ internal class EmbeddedContentViewMapperTest : LegacyBaseWireframeMapperTest() { ).single() // Then + assertThat(embeddedContentSlotRegistry.isSlotMarked(fakeSlotId)).isTrue() assertThat(wireframe).isEqualTo( MobileSegment.Wireframe.EmbeddedContentWireframe( id = fakeWireframeId, @@ -194,7 +198,7 @@ internal class EmbeddedContentViewMapperTest : LegacyBaseWireframeMapperTest() { ) // When - EmbeddedContentSlotRegistry.notifySlotChanged(fakeSlotRegistration, null) + embeddedContentSlotRegistry.notifySlotChanged(fakeSlotRegistration, null) slotId = null testedMapper.beginSnapshot() @@ -213,8 +217,10 @@ internal class EmbeddedContentViewMapperTest : LegacyBaseWireframeMapperTest() { 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 - EmbeddedContentSlotRegistry.notifySlotChanged(null, fakeFirstRegistration) - EmbeddedContentSlotRegistry.notifySlotChanged(null, fakeSecondRegistration) + 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 @@ -280,8 +286,8 @@ internal class EmbeddedContentViewMapperTest : LegacyBaseWireframeMapperTest() { ) ) } finally { - EmbeddedContentSlotRegistry.notifySlotChanged(fakeFirstRegistration, null) - EmbeddedContentSlotRegistry.notifySlotChanged(fakeSecondRegistration, null) + embeddedContentSlotRegistry.notifySlotChanged(fakeFirstRegistration, null) + embeddedContentSlotRegistry.notifySlotChanged(fakeSecondRegistration, null) } } From 6f464b902aa2ee5d91ff686ae952b163231347cb Mon Sep 17 00:00:00 2001 From: Jonathan Moskovich <48201295+jonathanmos@users.noreply.github.com> Date: Mon, 10 Aug 2026 14:33:11 +0300 Subject: [PATCH 09/12] PANA-8412: Process embedded content asynchronously --- .../internal/SessionReplayFeature.kt | 11 +++ .../SessionReplayRumContextProvider.kt | 2 +- .../embedded/EmbeddedContentReceiver.kt | 89 +++++++++++-------- .../embedded/EmbeddedContentSlotRegistry.kt | 16 ++-- .../mapper/EmbeddedContentViewMapper.kt | 3 +- .../internal/SessionReplayFeatureTest.kt | 15 ++++ .../SessionReplayRumContextProviderTest.kt | 20 +++++ .../embedded/EmbeddedContentReceiverTest.kt | 71 +++++++++++++++ .../EmbeddedContentSlotRegistryTest.kt | 16 ++++ 9 files changed, 195 insertions(+), 48 deletions(-) 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 3b03ca72a6..3cf7f4db94 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 @@ -48,6 +48,7 @@ import com.datadog.android.sessionreplay.internal.storage.SessionReplayRecordWri 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 @@ -138,11 +139,13 @@ internal class SessionReplayFeature( } 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 }, internalLogger = sdkCore.internalLogger ) @@ -157,6 +160,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) @@ -211,6 +217,9 @@ internal class SessionReplayFeature( sdkCore.removeContextUpdateReceiver(rumContextProvider) sessionReplayRecorder.unregisterCallbacks() sessionReplayRecorder.stopProcessingRecords() + if (::embeddedContentExecutor.isInitialized) { + embeddedContentExecutor.shutdown() + } dataWriter = NoOpRecordWriter() embeddedContentRecordWriter = NoOpEmbeddedContentRecordWriter() resourceProcessor = NoOpResourceProcessor() @@ -461,6 +470,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/SessionReplayRumContextProvider.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayRumContextProvider.kt index c9f67bfdef..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 @@ -36,10 +36,10 @@ internal class SessionReplayRumContextProvider( 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() } - rumContext = context } } 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 index be7c0fedaf..3f396f447f 100644 --- 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 @@ -8,17 +8,21 @@ 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.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 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 internalLogger: InternalLogger ) { fun receive(event: EmbeddedContentEvent) { @@ -28,59 +32,67 @@ internal class EmbeddedContentReceiver( when (event) { is EmbeddedContentEvent.RecordBatch -> { if (event.records.isNotEmpty()) { - process(event) + val rumContext = rumContextProvider.getRumContext() + if (rumContext.isValid()) { + val scheduledWriter = recordWriter() + executor().executeSafe(PROCESS_EVENT_TASK_NAME, internalLogger) { + processRecordBatch(event, rumContext, scheduledWriter) + } + } } } is EmbeddedContentEvent.Resource -> { if (rumContextProvider.getRumContext().hasValidApplicationAndSession()) { - process(event) + val scheduledProcessor = resourceProcessor() + executor().executeSafe(PROCESS_EVENT_TASK_NAME, internalLogger) { + processResource(event, scheduledProcessor) + } } } } } - private fun process(event: EmbeddedContentEvent) { - when (event) { - is EmbeddedContentEvent.RecordBatch -> { - val rumContext = rumContextProvider.getRumContext() - if (rumContext.isNotValid()) { - return - } - val records = JsonArray() - .also { array -> - event.records.forEach { record -> - val jsonRecord = JsonSerializer.toJsonElement(record) - if (jsonRecord is JsonObject) { - jsonRecord.addProperty(SLOT_ID_KEY, event.slotId) - array.add(jsonRecord) - } else { - logInvalidRecord() - } - } + private fun processRecordBatch( + event: EmbeddedContentEvent.RecordBatch, + rumContext: SessionReplayRumContext, + writer: EmbeddedContentRecordWriter + ) { + val records = JsonArray() + .also { array -> + event.records.forEach { record -> + val jsonRecord = JsonSerializer.toJsonElement(record) + if (jsonRecord is JsonObject) { + jsonRecord.addProperty(SLOT_ID_KEY, event.slotId) + array.add(jsonRecord) + } else { + logInvalidRecord() } - - if (records.size() == 0) { - return - } - - val enrichedRecord = JsonObject().apply { - addProperty(APPLICATION_ID_KEY, rumContext.applicationId) - addProperty(SESSION_ID_KEY, rumContext.sessionId) - addProperty(VIEW_ID_KEY, event.viewId) - add(RECORDS_KEY, records) } - recordWriter().writeRaw( - enrichedRecord.toString().toByteArray(Charsets.UTF_8), - event.viewId, - records.size() - ) } - is EmbeddedContentEvent.Resource -> { - resourceProcessor().process(event.identifier, event.data, event.mimeType) - } + 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() { @@ -99,5 +111,6 @@ internal class EmbeddedContentReceiver( internal const val SLOT_ID_KEY = "slotId" 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 index 00e24c9ae6..163cf3f2da 100644 --- 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 @@ -27,18 +27,18 @@ internal class EmbeddedContentSlotRegistry { private val registrations = mutableListOf>() @AnyThread - fun hasMarkedSlots(): Boolean { - return synchronized(registrations) { - removeInactiveRegistrations() - registrations.isNotEmpty() - } - } + fun hasMarkedSlots(): Boolean = activeSlotIds().isNotEmpty() + + @AnyThread + fun isSlotMarked(slotId: String): Boolean = slotId in activeSlotIds() @AnyThread - fun isSlotMarked(slotId: String): Boolean { + fun activeSlotIds(): Set { return synchronized(registrations) { removeInactiveRegistrations() - registrations.any { it.get()?.slotId == slotId } + // The transform only reads a weak reference and immutable slot ID. + @Suppress("UnsafeThirdPartyFunctionCall") + registrations.mapNotNullTo(mutableSetOf()) { it.get()?.slotId } } } 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 index 23bcc99098..9be91278ce 100644 --- 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 @@ -165,10 +165,11 @@ internal class EmbeddedContentViewMapper( @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 (!embeddedContentSlotRegistry.isSlotMarked(slotId)) { + if (slotId !in activeSlotIds) { iterator.remove() } else if (entry.lastSeenSnapshot != currentSnapshot) { hiddenWireframes += MobileSegment.Wireframe.EmbeddedContentWireframe( 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 2a9da3721d..87f950c056 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 @@ -114,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, @@ -425,6 +428,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 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 4e2195db98..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 @@ -53,6 +53,26 @@ internal class SessionReplayRumContextProviderTest { 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 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 index 6363647f6d..4b4631d05c 100644 --- 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 @@ -26,10 +26,12 @@ import org.mockito.junit.jupiter.MockitoSettings import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.eq +import org.mockito.kotlin.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), @@ -69,6 +71,7 @@ internal class EmbeddedContentReceiverTest { recordWriter = { mockRecordWriter }, resourceProcessor = { mockResourceProcessor }, isRecording = { isRecording }, + executor = { Executor(Runnable::run) }, internalLogger = mockInternalLogger ) } @@ -130,6 +133,42 @@ internal class EmbeddedContentReceiverTest { verify(mockRecordWriter).writeRaw(any(), eq(FAKE_EMBEDDED_VIEW_ID), eq(1)) } + @Test + fun `M process records asynchronously W receive { record batch }`() { + // Given + lateinit var queuedTask: Runnable + val replacementRecordWriter = mock() + var currentRecordWriter = mockRecordWriter + testedReceiver = EmbeddedContentReceiver( + rumContextProvider = mockRumContextProvider, + recordWriter = { currentRecordWriter }, + resourceProcessor = { mockResourceProcessor }, + isRecording = { isRecording }, + executor = { Executor { queuedTask = it } }, + internalLogger = mockInternalLogger + ) + + // 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 @@ -149,6 +188,38 @@ internal class EmbeddedContentReceiverTest { 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 = EmbeddedContentReceiver( + rumContextProvider = mockRumContextProvider, + recordWriter = { mockRecordWriter }, + resourceProcessor = { currentResourceProcessor }, + isRecording = { isRecording }, + executor = { Executor { queuedTask = it } }, + internalLogger = mockInternalLogger + ) + + // 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 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 index eba709057a..ee9730e618 100644 --- 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 @@ -53,6 +53,22 @@ internal class EmbeddedContentSlotRegistryTest { 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 From f1770534f9491fabc55f3a7d93c6104c4650dd54 Mon Sep 17 00:00:00 2001 From: Jonathan Moskovich <48201295+jonathanmos@users.noreply.github.com> Date: Mon, 10 Aug 2026 14:53:51 +0300 Subject: [PATCH 10/12] PANA-8412: Address embedded content review feedback --- detekt_custom_safe_calls_android.yml | 1 + .../android/sessionreplay/_SessionReplayInternalProxy.kt | 1 - .../internal/embedded/EmbeddedContentReceiver.kt | 7 +++++-- .../recorder/mapper/EmbeddedContentViewMapper.kt | 9 ++------- .../sessionreplay/internal/SessionReplayFeatureTest.kt | 2 +- .../internal/SessionReplayRecordCallbackTest.kt | 8 ++++---- .../internal/embedded/EmbeddedContentReceiverTest.kt | 6 +++--- 7 files changed, 16 insertions(+), 18 deletions(-) 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/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 16ad094ee2..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 @@ -75,7 +75,6 @@ class _SessionReplayInternalProxy(private val builder: SessionReplayConfiguratio previousRegistration = previousRegistration, newRegistration = newRegistration ) - @Suppress("UnsafeThirdPartyFunctionCall") // Android documents no exception for scheduling invalidation. view.postInvalidateOnAnimation() } 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 index 3f396f447f..06860f0efb 100644 --- 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 @@ -63,7 +63,7 @@ internal class EmbeddedContentReceiver( event.records.forEach { record -> val jsonRecord = JsonSerializer.toJsonElement(record) if (jsonRecord is JsonObject) { - jsonRecord.addProperty(SLOT_ID_KEY, event.slotId) + jsonRecord.addProperty(RECORD_SLOT_ID_KEY, event.slotId) array.add(jsonRecord) } else { logInvalidRecord() @@ -104,11 +104,14 @@ internal class EmbeddedContentReceiver( } 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" - internal const val SLOT_ID_KEY = "slotId" + + // Embedded records use the Session Replay record schema naming convention. + internal const val RECORD_SLOT_ID_KEY = "slotId" 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/recorder/mapper/EmbeddedContentViewMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/EmbeddedContentViewMapper.kt index 9be91278ce..d99cac6c2d 100644 --- 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 @@ -79,7 +79,7 @@ internal class EmbeddedContentViewMapper( cache.record(slotId, wireframeId) if (viewUtilsInternal.isNotVisible(view)) { - return wireframeList(hiddenWireframe(wireframeId, slotId)) + return listOf(hiddenWireframe(wireframeId, slotId)) } val bounds = viewBoundsResolver.resolveViewGlobalBounds( @@ -90,7 +90,7 @@ internal class EmbeddedContentViewMapper( resolveShapeStyle(it, view.alpha, internalLogger) } - return wireframeList( + return listOf( MobileSegment.Wireframe.EmbeddedContentWireframe( id = wireframeId, x = bounds.x, @@ -104,11 +104,6 @@ internal class EmbeddedContentViewMapper( ) } - @Suppress("UnsafeThirdPartyFunctionCall") // Kotlin listOf cannot fail for this local value. - private fun wireframeList(wireframe: MobileSegment.Wireframe): List { - return listOf(wireframe) - } - private fun hiddenWireframe( wireframeId: Long, slotId: String 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 87f950c056..20a47c0481 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 @@ -304,7 +304,7 @@ internal class SessionReplayFeatureTest { assertThat(json[EmbeddedContentReceiver.VIEW_ID_KEY].asString) .isEqualTo(FAKE_EMBEDDED_VIEW_ID) val records = json[EmbeddedContentReceiver.RECORDS_KEY].asJsonArray - assertThat(records[0].asJsonObject[EmbeddedContentReceiver.SLOT_ID_KEY].asString) + assertThat(records[0].asJsonObject[EmbeddedContentReceiver.RECORD_SLOT_ID_KEY].asString) .isEqualTo(FAKE_EMBEDDED_SLOT_ID) } } 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 93b99317e2..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 @@ -159,15 +159,15 @@ internal class SessionReplayRecordCallbackTest { @Suppress("UNCHECKED_CAST") @Test - fun `M share records count W native and embedded records sent`() { + fun `M share records count W native and embedded records sent`(forge: Forge) { // Given - val embeddedRecordsCount = 3 + val fakeEmbeddedRecordsCount = forge.anInt(min = 1, max = 10) // When testedRecordCallback.onRecordForViewSent(fakeEnrichedRecord) testedRecordCallback.onEmbeddedRecordsForViewSent( fakeEnrichedRecord.viewId, - embeddedRecordsCount + fakeEmbeddedRecordsCount ) // Then @@ -184,7 +184,7 @@ internal class SessionReplayRecordCallbackTest { assertThat(viewMetadata?.get(SessionReplayRecordCallback.HAS_REPLAY_KEY)) .isEqualTo(true) assertThat(viewMetadata?.get(SessionReplayRecordCallback.VIEW_RECORDS_COUNT_KEY)) - .isEqualTo(fakeEnrichedRecord.records.size.toLong() + embeddedRecordsCount) + .isEqualTo(fakeEnrichedRecord.records.size.toLong() + fakeEmbeddedRecordsCount) } } 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 index 4b4631d05c..1647555b57 100644 --- 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 @@ -86,7 +86,7 @@ internal class EmbeddedContentReceiverTest { "type" to 10L, FAKE_FUTURE_FIELD_NAME to FAKE_FUTURE_FIELD_VALUE ), - mapOf("type" to 11L, EmbeddedContentReceiver.SLOT_ID_KEY to "stale-slot") + mapOf("type" to 11L, EmbeddedContentReceiver.RECORD_SLOT_ID_KEY to "stale-slot") ), slotId = FAKE_NATIVE_SLOT_ID, viewId = FAKE_EMBEDDED_VIEW_ID @@ -107,12 +107,12 @@ internal class EmbeddedContentReceiverTest { .isEqualTo(FAKE_EMBEDDED_VIEW_ID) val records = json[EmbeddedContentReceiver.RECORDS_KEY].asJsonArray assertThat(records).hasSize(2) - assertThat(records[0].asJsonObject[EmbeddedContentReceiver.SLOT_ID_KEY].asString) + 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.SLOT_ID_KEY].asString) + assertThat(records[1].asJsonObject[EmbeddedContentReceiver.RECORD_SLOT_ID_KEY].asString) .isEqualTo(FAKE_NATIVE_SLOT_ID) } verifyNoInteractions(mockResourceProcessor) From 5b41f07e0fd239bb1fbffc060120029a2a2d47ee Mon Sep 17 00:00:00 2001 From: Juan Naranjo Date: Wed, 19 Aug 2026 17:13:54 +0200 Subject: [PATCH 11/12] PANA-8412: Order Flutter records after their placeholder wireframe --- detekt_custom_safe_calls_third_party.yml | 12 + .../internal/SessionReplayFeature.kt | 2 + .../embedded/EmbeddedContentReceiver.kt | 120 +++++- .../embedded/EmbeddedContentSlotRegistry.kt | 55 ++- .../embedded/EmbeddedRecordTimeline.kt | 83 ++++ .../processor/RecordedDataProcessor.kt | 37 +- .../recorder/DefaultOnDrawListenerProducer.kt | 3 +- .../recorder/OnDemandCaptureListener.kt | 23 ++ .../recorder/OnDrawListenerProducer.kt | 3 +- .../recorder/SessionReplayRecorder.kt | 88 ++++- .../recorder/ViewOnDrawInterceptor.kt | 37 +- .../listener/WindowsOnDrawListener.kt | 102 ++--- .../internal/storage/NoOpRecordWriter.kt | 2 +- .../internal/storage/RecordWriter.kt | 4 +- .../storage/SessionReplayRecordWriter.kt | 6 +- .../SessionReplayRecorderTest.kt | 65 ++++ .../internal/SessionReplayFeatureTest.kt | 8 + .../embedded/EmbeddedContentReceiverTest.kt | 366 ++++++++++++++++-- .../EmbeddedContentSlotRegistryTest.kt | 157 ++++++++ .../processor/RecordedDataProcessorTest.kt | 166 ++++++-- .../recorder/ViewOnDrawInterceptorTest.kt | 55 ++- .../listener/WindowsOnDrawListenerTest.kt | 44 +++ .../storage/SessionReplayRecordWriterTest.kt | 44 +++ 23 files changed, 1332 insertions(+), 150 deletions(-) create mode 100644 features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/embedded/EmbeddedRecordTimeline.kt create mode 100644 features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/OnDemandCaptureListener.kt diff --git a/detekt_custom_safe_calls_third_party.yml b/detekt_custom_safe_calls_third_party.yml index e1fa0b408e..583ecb2568 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()" @@ -519,10 +523,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 +577,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 +594,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 +614,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 +632,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)" @@ -700,6 +710,7 @@ datadog: - "kotlin.collections.MutableSet.contains(kotlin.String)" - "kotlin.collections.MutableSet.contains(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 +724,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/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 3cf7f4db94..55e09f1f23 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 @@ -146,6 +146,8 @@ internal class SessionReplayFeature( resourceProcessor = { resourceProcessor }, isRecording = { isRecording.get() }, executor = { embeddedContentExecutor }, + requestCapture = { sessionReplayRecorder.requestCapture() }, + embeddedContentSlotRegistry = embeddedContentSlotRegistry, internalLogger = sdkCore.internalLogger ) 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 index 06860f0efb..cbcfcda7f9 100644 --- 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 @@ -9,6 +9,7 @@ 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 @@ -23,24 +24,33 @@ internal class EmbeddedContentReceiver( private val resourceProcessor: () -> ResourceProcessor, private val isRecording: () -> Boolean, private val executor: () -> Executor, + private val requestCapture: () -> 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) + } + } + fun receive(event: EmbeddedContentEvent) { if (!isRecording()) { return } when (event) { - is EmbeddedContentEvent.RecordBatch -> { - if (event.records.isNotEmpty()) { - val rumContext = rumContextProvider.getRumContext() - if (rumContext.isValid()) { - val scheduledWriter = recordWriter() - executor().executeSafe(PROCESS_EVENT_TASK_NAME, internalLogger) { - processRecordBatch(event, rumContext, scheduledWriter) - } - } - } - } + is EmbeddedContentEvent.RecordBatch -> handleRecordBatch(event) is EmbeddedContentEvent.Resource -> { if (rumContextProvider.getRumContext().hasValidApplicationAndSession()) { @@ -53,10 +63,93 @@ internal class EmbeddedContentReceiver( } } + 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() + } + } + + /** 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 + } + + 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 + writer: EmbeddedContentRecordWriter, + shift: Long ) { val records = JsonArray() .also { array -> @@ -64,6 +157,7 @@ internal class EmbeddedContentReceiver( 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() @@ -112,6 +206,8 @@ internal class EmbeddedContentReceiver( // 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 index 163cf3f2da..189d2f59b4 100644 --- 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 @@ -26,6 +26,55 @@ internal class EmbeddedContentSlotRegistration( 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>() + + 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 + } + } + } + if (newlyPlaced.isEmpty()) { + return + } + val listeners = synchronized(placeholders) { + placeholderListeners.toList() + } + newlyPlaced.forEach { slotId -> listeners.forEach { it(slotId) } } + } + + @AnyThread + fun addPlaceholderListener(listener: (String) -> Unit) { + synchronized(placeholders) { + placeholderListeners.add(listener) + } + } + @AnyThread fun hasMarkedSlots(): Boolean = activeSlotIds().isNotEmpty() @@ -36,8 +85,6 @@ internal class EmbeddedContentSlotRegistry { fun activeSlotIds(): Set { return synchronized(registrations) { removeInactiveRegistrations() - // The transform only reads a weak reference and immutable slot ID. - @Suppress("UnsafeThirdPartyFunctionCall") registrations.mapNotNullTo(mutableSetOf()) { it.get()?.slotId } } } @@ -70,9 +117,7 @@ internal class EmbeddedContentSlotRegistry { private fun trackRegistration(registration: EmbeddedContentSlotRegistration?) { val isAlreadyTracked = registrations.any { it.get() === registration } if (registration != null && registration.isActive() && !isAlreadyTracked) { - @Suppress("UnsafeThirdPartyFunctionCall") // WeakReference construction has no documented exception. - val weakRegistration = WeakReference(registration) - registrations += weakRegistration + registrations += WeakReference(registration) } } 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/RecordedDataProcessor.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/RecordedDataProcessor.kt index 63b719e79a..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 @@ -29,6 +30,7 @@ internal class RecordedDataProcessor( private val writer: RecordWriter, private val mutationResolver: MutationResolver, private val timeProvider: TimeProvider, + private val embeddedContentSlotRegistry: EmbeddedContentSlotRegistry, private val nodeFlattener: NodeFlattener = NodeFlattener(), private val resourceProcessor: ResourceProcessor = DefaultResourceProcessor( resourceDataStoreManager, @@ -145,11 +147,42 @@ internal class RecordedDataProcessor( } 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/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/SessionReplayRecorder.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/SessionReplayRecorder.kt index 473fc61d72..e93951785b 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 @@ -59,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 { @@ -77,6 +78,9 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { private val windowFromDecorView: (View) -> Window? private var shouldRecord = false + /** Whether a capture has been asked for and not yet taken — see [requestCapture]. */ + private val captureRequested = AtomicBoolean(false) + @Suppress("LongParameterList") constructor( appContext: Application, @@ -110,7 +114,8 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { resourcesWriter, recordWriter, MutationResolver(internalLogger), - timeProvider + timeProvider, + embeddedContentSlotRegistry ) this.appContext = appContext @@ -294,18 +299,56 @@ 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 it produces 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 a capture actually happens. + */ override fun requestCapture() { + captureRequested.set(true) uiHandler.post { - if (shouldRecord) { - viewOnDrawInterceptor.requestCapture() + @Suppress("ThreadSafety") // handler posts to the main looper + performRequestedCapture(attempt = 0) + } + } + + @MainThread + private fun performRequestedCapture(attempt: Int) { + if (!shouldRecord || !captureRequested.get()) { + return + } + when (viewOnDrawInterceptor.requestCapture()) { + ViewOnDrawInterceptor.CaptureRequestResult.CAPTURED -> { + captureRequested.set(false) + return + } + + 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() && attempt < MAX_CAPTURE_ATTEMPTS) { + uiHandler.postDelayed( + { + @Suppress("ThreadSafety") // handler posts to the main looper + performRequestedCapture(attempt + 1) + }, + CAPTURE_RETRY_DELAY_IN_MS + ) } } @@ -320,9 +363,25 @@ 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 is cleared only once a + * snapshot has in fact been taken, otherwise it stays pending for whichever window comes next. + */ + @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() == ViewOnDrawInterceptor.CaptureRequestResult.CAPTURED + ) { + captureRequested.set(false) } } @@ -343,4 +402,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/ViewOnDrawInterceptor.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/ViewOnDrawInterceptor.kt index 0ce8f14892..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,11 +64,39 @@ internal class ViewOnDrawInterceptor( decorOnDrawListeners.clear() } - fun requestCapture() { + /** + * 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. - @Suppress("UnsafeThirdPartyFunctionCall") // WeakHashMap access is confined to the UI thread. val listeners = decorOnDrawListeners.values.toSet() - listeners.forEach { it.onDraw() } + 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) { 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 8400b5b425..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,60 +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 - 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 - } - } + 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/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 4c66298a9a..6a78a16f71 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 @@ -18,7 +18,7 @@ internal class SessionReplayRecordWriter( private val recordCallback: RecordCallback, private val onEmbeddedRecordWritten: (String, Int) -> Unit = { _, _ -> } ) : RecordWriter, EmbeddedContentRecordWriter { - override fun write(record: EnrichedRecord) { + override fun write(record: EnrichedRecord, onSuccess: () -> Unit) { sdkCore.getFeature(Feature.SESSION_REPLAY_FEATURE_NAME) ?.withWriteContext { _, writeScope -> writeScope { @@ -32,6 +32,10 @@ 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. + @Suppress("UnsafeThirdPartyFunctionCall") // Callback is internal. + onSuccess() } } } 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 ed017bee26..fde25bcf4c 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 @@ -33,8 +33,10 @@ 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.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 @@ -92,6 +94,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, @@ -286,6 +294,63 @@ internal class SessionReplayRecorderTest { verify(mockViewOnDrawInterceptor).requestCapture() } + @Test + fun `M intercept the current windows W requestCapture { nothing intercepted yet }`() { + // Given + testedSessionReplayRecorder.resumeRecorders() + clearInvocations(mockViewOnDrawInterceptor) + whenever(mockViewOnDrawInterceptor.requestCapture()).thenReturn( + ViewOnDrawInterceptor.CaptureRequestResult.NOT_INTERCEPTING, + ViewOnDrawInterceptor.CaptureRequestResult.CAPTURED + ) + + // When + testedSessionReplayRecorder.requestCapture() + + // Then + verify(mockViewOnDrawInterceptor).intercept( + decorViews = fakeActiveWindowsDecorViews, + textAndInputPrivacy = fakeTextAndInputPrivacy, + imagePrivacy = fakeImagePrivacy + ) + verify(mockViewOnDrawInterceptor, times(2)).requestCapture() + } + + @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() + + // Then + verify(mockViewOnDrawInterceptor, times(SessionReplayRecorder.MAX_CAPTURE_ATTEMPTS + 1)) + .requestCapture() + } + + @Test + fun `M stop retrying W requestCapture { snapshot taken on the retry }`() { + // Given + testedSessionReplayRecorder.resumeRecorders() + clearInvocations(mockViewOnDrawInterceptor) + whenever(mockViewOnDrawInterceptor.requestCapture()).thenReturn( + ViewOnDrawInterceptor.CaptureRequestResult.NOT_CAPTURED, + ViewOnDrawInterceptor.CaptureRequestResult.CAPTURED + ) + + // When + testedSessionReplayRecorder.requestCapture() + + // Then + verify(mockViewOnDrawInterceptor, times(2)).requestCapture() + } + @Test fun `M not request capture W requestCapture { recorder stopped }`() { // When 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 20a47c0481..19400be3db 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 @@ -284,6 +284,13 @@ internal class SessionReplayFeatureTest { 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( @@ -1506,6 +1513,7 @@ 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) 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 index 1647555b57..ae07269369 100644 --- 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 @@ -25,6 +25,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.atLeastOnce import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.verify @@ -57,6 +58,10 @@ internal class EmbeddedContentReceiverTest { private var isRecording: Boolean = true + private var captureRequests: Int = 0 + + private val fakeSlotRegistry = EmbeddedContentSlotRegistry() + private val fakeRumContext = SessionReplayRumContext( applicationId = FAKE_NATIVE_APPLICATION_ID, sessionId = FAKE_NATIVE_SESSION_ID, @@ -66,19 +71,13 @@ internal class EmbeddedContentReceiverTest { @BeforeEach fun `set up`() { whenever(mockRumContextProvider.getRumContext()).thenReturn(fakeRumContext) - testedReceiver = EmbeddedContentReceiver( - rumContextProvider = mockRumContextProvider, - recordWriter = { mockRecordWriter }, - resourceProcessor = { mockResourceProcessor }, - isRecording = { isRecording }, - executor = { Executor(Runnable::run) }, - internalLogger = mockInternalLogger - ) + 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( @@ -118,34 +117,16 @@ internal class EmbeddedContentReceiverTest { verifyNoInteractions(mockResourceProcessor) } - @Test - fun `M write records W receive { slot has no native container }`() { - // When - testedReceiver.receive( - EmbeddedContentEvent.RecordBatch( - records = listOf(mapOf("type" to 10L)), - slotId = "unmaterialized-slot", - viewId = FAKE_EMBEDDED_VIEW_ID - ) - ) - - // Then - verify(mockRecordWriter).writeRaw(any(), eq(FAKE_EMBEDDED_VIEW_ID), eq(1)) - } - @Test fun `M 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 = EmbeddedContentReceiver( - rumContextProvider = mockRumContextProvider, + testedReceiver = receiver( recordWriter = { currentRecordWriter }, - resourceProcessor = { mockResourceProcessor }, - isRecording = { isRecording }, - executor = { Executor { queuedTask = it } }, - internalLogger = mockInternalLogger + executor = { Executor { queuedTask = it } } ) // When @@ -195,13 +176,9 @@ internal class EmbeddedContentReceiverTest { val replacementResourceProcessor = mock() var currentResourceProcessor = mockResourceProcessor val resourceData = byteArrayOf(1, 2, 3) - testedReceiver = EmbeddedContentReceiver( - rumContextProvider = mockRumContextProvider, - recordWriter = { mockRecordWriter }, + testedReceiver = receiver( resourceProcessor = { currentResourceProcessor }, - isRecording = { isRecording }, - executor = { Executor { queuedTask = it } }, - internalLogger = mockInternalLogger + executor = { Executor { queuedTask = it } } ) // When @@ -315,6 +292,322 @@ internal class EmbeddedContentReceiverTest { 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 + 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 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 = { captureRequests++ }, + 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" @@ -324,6 +617,9 @@ internal class EmbeddedContentReceiverTest { 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" 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 index ee9730e618..b10feb4d2b 100644 --- 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 @@ -141,9 +141,166 @@ internal class EmbeddedContentSlotRegistryTest { } } + // 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 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/RecordedDataProcessorTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/RecordedDataProcessorTest.kt index d3fd8a31e7..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 @@ -92,6 +93,8 @@ internal class RecordedDataProcessorTest { @Mock lateinit var mockTimeProvider: TimeProvider + private val fakeSlotRegistry = EmbeddedContentSlotRegistry() + private val invalidRumContext = SessionReplayRumContext() private lateinit var invalidRecordedQueuedItemContext: RecordedQueuedItemContext @@ -151,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 ) } @@ -174,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) @@ -203,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 @@ -227,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\"") } @@ -276,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) @@ -343,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 @@ -394,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 @@ -414,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) @@ -451,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) @@ -478,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 @@ -506,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 @@ -552,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) @@ -599,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) @@ -645,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) @@ -703,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 @@ -754,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) @@ -786,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) @@ -820,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) @@ -854,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) @@ -891,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) @@ -927,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) @@ -970,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) @@ -1012,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) @@ -1071,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 @@ -1126,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 @@ -1181,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 @@ -1236,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 @@ -1295,8 +1304,113 @@ internal class RecordedDataProcessorTest { // endregion + // region embedded content placeholders + + @Test + fun `M report placeholder to the registry W process { snapshot with visible placeholder }`( + @StringForgery fakeSlotId: String, + forge: Forge + ) { + // Given + currentRecordedQueuedItemContext = mockRumContextDataHandler.createRumContextData() + ?: fail("RumContextData is null") + givenFlattenedWireframes(forge.anEmbeddedContentWireframe(fakeSlotId, isVisible = true)) + + // When + testedProcessor.processScreenSnapshots(createSnapshotItem(fakeSnapshot1)) + + // Then + assertThat(fakeSlotRegistry.placeholder(fakeSlotId)) + .isEqualTo( + EmbeddedContentSlotRegistry.Placeholder(fakeRumContext.viewId, fakeTimestamp) + ) + } + + @Test + fun `M report no placeholder to the registry W process { snapshot with hidden placeholder }`( + @StringForgery fakeSlotId: String, + forge: Forge + ) { + // Given + currentRecordedQueuedItemContext = mockRumContextDataHandler.createRumContextData() + ?: fail("RumContextData is null") + givenFlattenedWireframes(forge.anEmbeddedContentWireframe(fakeSlotId, isVisible = false)) + + // When + testedProcessor.processScreenSnapshots(createSnapshotItem(fakeSnapshot1)) + + // Then + 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/recorder/ViewOnDrawInterceptorTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/ViewOnDrawInterceptorTest.kt index 455312c198..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 @@ -32,7 +32,9 @@ 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 @@ -53,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 @@ -78,6 +80,7 @@ internal class ViewOnDrawInterceptorTest { touchPrivacyManager = mockTouchPrivacyManager ) ) doReturn mockOnDrawListener + whenever(mockOnDrawListener.captureNow()) doReturn true testedInterceptor = ViewOnDrawInterceptor( internalLogger = mockInternalLogger, @@ -126,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 @@ -160,16 +163,58 @@ internal class ViewOnDrawInterceptorTest { } @Test - fun `M invoke active listener once W requestCapture { multiple decor views }`() { + fun `M capture on the active listener once W requestCapture { multiple decor views }`() { // Given testedInterceptor.intercept(fakeDecorViews, fakeTextAndInputPrivacy, fakeImagePrivacy) clearInvocations(mockOnDrawListener) // When - testedInterceptor.requestCapture() + val result = testedInterceptor.requestCapture() // Then - verify(mockOnDrawListener).onDraw() + // 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 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 33cdbcf883..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 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 bb9b0ea1c4..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 @@ -186,6 +186,50 @@ 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, From 1c93ebf01e5c7231bc7cbeb5c976db52583068c4 Mon Sep 17 00:00:00 2001 From: Juan Naranjo Date: Mon, 24 Aug 2026 14:01:38 +0200 Subject: [PATCH 12/12] PANA-8412: feedback addressed --- detekt_custom_safe_calls_third_party.yml | 3 + .../internal/SessionReplayFeature.kt | 7 +- .../embedded/EmbeddedContentReceiver.kt | 28 +++++- .../embedded/EmbeddedContentSlotRegistry.kt | 25 ++++-- .../internal/recorder/Recorder.kt | 2 +- .../recorder/SessionReplayRecorder.kt | 78 ++++++++++++---- .../storage/SessionReplayRecordWriter.kt | 1 - .../SessionReplayRecorderTest.kt | 89 +++++++++++++++---- .../internal/SessionReplayFeatureTest.kt | 6 +- .../embedded/EmbeddedContentReceiverTest.kt | 41 ++++++++- .../EmbeddedContentSlotRegistryTest.kt | 33 +++++++ 11 files changed, 260 insertions(+), 53 deletions(-) diff --git a/detekt_custom_safe_calls_third_party.yml b/detekt_custom_safe_calls_third_party.yml index 583ecb2568..d5d5029c8e 100644 --- a/detekt_custom_safe_calls_third_party.yml +++ b/detekt_custom_safe_calls_third_party.yml @@ -506,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)" @@ -709,6 +710,8 @@ 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)" 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 55e09f1f23..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 @@ -146,7 +146,7 @@ internal class SessionReplayFeature( resourceProcessor = { resourceProcessor }, isRecording = { isRecording.get() }, executor = { embeddedContentExecutor }, - requestCapture = { sessionReplayRecorder.requestCapture() }, + requestCapture = { slotId -> sessionReplayRecorder.requestCapture(setOf(slotId)) }, embeddedContentSlotRegistry = embeddedContentSlotRegistry, internalLogger = sdkCore.internalLogger ) @@ -449,8 +449,9 @@ internal class SessionReplayFeature( if (!isRecording.get()) { return } - if (embeddedContentSlotRegistry.hasMarkedSlots()) { - sessionReplayRecorder.requestCapture() + val activeSlotIds = embeddedContentSlotRegistry.activeSlotIds() + if (activeSlotIds.isNotEmpty()) { + sessionReplayRecorder.requestCapture(activeSlotIds) } } 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 index cbcfcda7f9..f332290c5e 100644 --- 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 @@ -18,13 +18,14 @@ 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: () -> Unit, + private val requestCapture: (String) -> Unit, private val embeddedContentSlotRegistry: EmbeddedContentSlotRegistry, private val internalLogger: InternalLogger ) { @@ -43,6 +44,9 @@ internal class EmbeddedContentReceiver( embeddedContentSlotRegistry.addPlaceholderListener { slotId -> releasePendingBatches(slotId) } + embeddedContentSlotRegistry.addSnapshotListener { drawnSlotIds -> + flushAbandonedSlots(drawnSlotIds) + } } fun receive(event: EmbeddedContentEvent) { @@ -99,7 +103,7 @@ internal class EmbeddedContentReceiver( // 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() + requestCapture(slotId) } } @@ -121,6 +125,26 @@ internal class EmbeddedContentReceiver( 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) 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 index 189d2f59b4..c82a100258 100644 --- 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 @@ -23,12 +23,14 @@ internal class EmbeddedContentSlotRegistration( 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) @@ -59,13 +61,13 @@ internal class EmbeddedContentSlotRegistry { } } } - if (newlyPlaced.isEmpty()) { - return + val (placeholderNotified, snapshotNotified) = synchronized(placeholders) { + placeholderListeners.toList() to snapshotListeners.toList() } - val listeners = synchronized(placeholders) { - placeholderListeners.toList() - } - newlyPlaced.forEach { slotId -> listeners.forEach { it(slotId) } } + 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 @@ -75,6 +77,17 @@ internal class EmbeddedContentSlotRegistry { } } + /** + * 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() 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 f928532ae0..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,7 +18,7 @@ internal interface Recorder { fun resumeRecorders() - fun requestCapture() + 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 e93951785b..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 @@ -78,9 +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 taken — see [requestCapture]. */ + /** 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, @@ -246,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 @@ -263,6 +273,7 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { resourceResolver: ResourceResolver, uiHandler: Handler, internalLogger: InternalLogger, + embeddedContentSlotRegistry: EmbeddedContentSlotRegistry = EmbeddedContentSlotRegistry(), windowFromDecorView: (View) -> Window? = { WindowReflectionUtils.getWindowFromDecorView(it, internalLogger) } @@ -280,6 +291,7 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { this.uiHandler = uiHandler this.windowFromDecorView = windowFromDecorView this.internalLogger = internalLogger + embeddedContentSlotRegistry.addPlaceholderListener(::onPlaceholderWritten) } override fun stopProcessingRecords() { @@ -306,12 +318,15 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { /** * A capture request is a standing obligation rather than a single attempt: the placeholder - * wireframe it produces 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 a capture actually happens. + * 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() { + override fun requestCapture(slotIds: Set) { + synchronized(slotsAwaitingPlaceholder) { + slotsAwaitingPlaceholder.addAll(slotIds) + } captureRequested.set(true) uiHandler.post { @Suppress("ThreadSafety") // handler posts to the main looper @@ -319,16 +334,28 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { } } + /** 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()) { - ViewOnDrawInterceptor.CaptureRequestResult.CAPTURED -> { - captureRequested.set(false) - return - } + // 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 @@ -341,17 +368,34 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { // whatever draw or batch happens to come along next. ViewOnDrawInterceptor.CaptureRequestResult.NOT_CAPTURED -> Unit } - if (captureRequested.get() && attempt < MAX_CAPTURE_ATTEMPTS) { + if (!captureRequested.get()) { + return + } + if (attempt < MAX_CAPTURE_ATTEMPTS) { uiHandler.postDelayed( { @Suppress("ThreadSafety") // handler posts to the main looper performRequestedCapture(attempt + 1) }, - CAPTURE_RETRY_DELAY_IN_MS + // 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() @@ -370,18 +414,16 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { /** * 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 is cleared only once a - * snapshot has in fact been taken, otherwise it stays pending for whichever window comes next. + * 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() == ViewOnDrawInterceptor.CaptureRequestResult.CAPTURED - ) { - captureRequested.set(false) + if (captureRequested.get()) { + viewOnDrawInterceptor.requestCapture() } } 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 6a78a16f71..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 @@ -34,7 +34,6 @@ internal class SessionReplayRecordWriter( updateViewSent(record) // Runs while this monitor is held, so whatever the callback does must // not write back through this writer synchronously. - @Suppress("UnsafeThirdPartyFunctionCall") // Callback is internal. onSuccess() } } 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 fde25bcf4c..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,6 +35,7 @@ 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 @@ -77,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 @@ -112,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 @@ -288,10 +308,10 @@ internal class SessionReplayRecorderTest { testedSessionReplayRecorder.resumeRecorders() // When - testedSessionReplayRecorder.requestCapture() + testedSessionReplayRecorder.requestCapture(setOf(fakeSlotId)) // Then - verify(mockViewOnDrawInterceptor).requestCapture() + verify(mockViewOnDrawInterceptor, atLeastOnce()).requestCapture() } @Test @@ -299,13 +319,13 @@ internal class SessionReplayRecorderTest { // Given testedSessionReplayRecorder.resumeRecorders() clearInvocations(mockViewOnDrawInterceptor) - whenever(mockViewOnDrawInterceptor.requestCapture()).thenReturn( - ViewOnDrawInterceptor.CaptureRequestResult.NOT_INTERCEPTING, - ViewOnDrawInterceptor.CaptureRequestResult.CAPTURED - ) + whenever(mockViewOnDrawInterceptor.requestCapture()).then { + writePlaceholdersFor(fakeSlotId) + ViewOnDrawInterceptor.CaptureRequestResult.NOT_INTERCEPTING + } // When - testedSessionReplayRecorder.requestCapture() + testedSessionReplayRecorder.requestCapture(setOf(fakeSlotId)) // Then verify(mockViewOnDrawInterceptor).intercept( @@ -313,7 +333,6 @@ internal class SessionReplayRecorderTest { textAndInputPrivacy = fakeTextAndInputPrivacy, imagePrivacy = fakeImagePrivacy ) - verify(mockViewOnDrawInterceptor, times(2)).requestCapture() } @Test @@ -327,7 +346,7 @@ internal class SessionReplayRecorderTest { .thenReturn(ViewOnDrawInterceptor.CaptureRequestResult.NOT_CAPTURED) // When - testedSessionReplayRecorder.requestCapture() + testedSessionReplayRecorder.requestCapture(setOf(fakeSlotId)) // Then verify(mockViewOnDrawInterceptor, times(SessionReplayRecorder.MAX_CAPTURE_ATTEMPTS + 1)) @@ -335,26 +354,60 @@ internal class SessionReplayRecorderTest { } @Test - fun `M stop retrying W requestCapture { snapshot taken on the retry }`() { + 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) - 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 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() + testedSessionReplayRecorder.requestCapture(setOf(fakeSlotId, fakeOtherSlotId)) // Then - verify(mockViewOnDrawInterceptor, times(2)).requestCapture() + verify(mockViewOnDrawInterceptor, times(SessionReplayRecorder.MAX_CAPTURE_ATTEMPTS + 1)) + .requestCapture() } @Test fun `M not request capture W requestCapture { recorder stopped }`() { // When - testedSessionReplayRecorder.requestCapture() + testedSessionReplayRecorder.requestCapture(setOf(fakeSlotId)) // Then verify(mockViewOnDrawInterceptor, never()).requestCapture() @@ -502,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/internal/SessionReplayFeatureTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeatureTest.kt index 19400be3db..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 @@ -171,7 +171,7 @@ internal class SessionReplayFeatureTest { ) // Then - verify(mockRecorder).requestCapture() + verify(mockRecorder).requestCapture(setOf(FAKE_EMBEDDED_SLOT_ID)) // Cleanup testedFeature.embeddedContentSlotRegistry.notifySlotChanged(fakeRegistration, null) @@ -195,7 +195,7 @@ internal class SessionReplayFeatureTest { ) // Then - verify(mockRecorder).requestCapture() + verify(mockRecorder).requestCapture(setOf(FAKE_EMBEDDED_SLOT_ID)) // Cleanup testedFeature.embeddedContentSlotRegistry.notifySlotChanged(fakeRegistration, null) @@ -218,7 +218,7 @@ internal class SessionReplayFeatureTest { ) // Then - verify(mockRecorder, never()).requestCapture() + verify(mockRecorder, never()).requestCapture(any()) // Cleanup testedFeature.embeddedContentSlotRegistry.notifySlotChanged(fakeRegistration, null) 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 index ae07269369..6361a77f07 100644 --- 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 @@ -58,7 +58,9 @@ internal class EmbeddedContentReceiverTest { private var isRecording: Boolean = true - private var captureRequests: Int = 0 + private val capturedSlotIds = mutableListOf() + + private val captureRequests: Int get() = capturedSlotIds.size private val fakeSlotRegistry = EmbeddedContentSlotRegistry() @@ -331,6 +333,9 @@ internal class EmbeddedContentReceiverTest { @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 @@ -558,6 +563,38 @@ internal class EmbeddedContentReceiverTest { // 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( @@ -572,7 +609,7 @@ internal class EmbeddedContentReceiverTest { resourceProcessor = resourceProcessor, isRecording = { isRecording }, executor = executor, - requestCapture = { captureRequests++ }, + requestCapture = { slotId -> capturedSlotIds += slotId }, embeddedContentSlotRegistry = registry, internalLogger = mockInternalLogger ) 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 index b10feb4d2b..79577055bf 100644 --- 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 @@ -279,6 +279,39 @@ internal class EmbeddedContentSlotRegistryTest { 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