diff --git a/detekt_custom_safe_calls_third_party.yml b/detekt_custom_safe_calls_third_party.yml index a48a4e71c3..c432ce2639 100644 --- a/detekt_custom_safe_calls_third_party.yml +++ b/detekt_custom_safe_calls_third_party.yml @@ -821,6 +821,7 @@ datadog: - "kotlin.Char.titlecase(java.util.Locale)" - "kotlin.CharArray.constructor(kotlin.Int, kotlin.Function1)" - "kotlin.Double.coerceAtMost(kotlin.Double)" + - "kotlin.Double.isFinite()" - "kotlin.Double.isNaN()" - "kotlin.Double.pow(kotlin.Int)" - "kotlin.Double.pow(kotlin.Double)" diff --git a/features/dd-sdk-android-session-replay/api/apiSurface b/features/dd-sdk-android-session-replay/api/apiSurface index f420baab16..517d3c6f3c 100644 --- a/features/dd-sdk-android-session-replay/api/apiSurface +++ b/features/dd-sdk-android-session-replay/api/apiSurface @@ -1,3 +1,4 @@ +annotation com.datadog.android.sessionreplay.ExperimentalSessionReplayApi interface com.datadog.android.sessionreplay.ExtensionSupport fun name(): String fun getCustomViewMappers(): List> @@ -48,6 +49,7 @@ data class com.datadog.android.sessionreplay.SessionReplayConfiguration fun setDynamicOptimizationEnabled(Boolean): Builder fun setSystemRequirements(SystemRequirementsConfiguration): Builder fun setHeatmapsEnabled(Boolean): Builder + fun setCompositionTreeRecordingEnabled(Boolean): Builder fun build(): SessionReplayConfiguration interface com.datadog.android.sessionreplay.SessionReplayInternalCallback fun getCurrentActivity(): android.app.Activity? @@ -192,14 +194,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 @@ -237,7 +239,7 @@ data class com.datadog.android.sessionreplay.model.MobileSegment fun fromJson(kotlin.String): MobileRecord fun fromJsonElement(com.google.gson.JsonElement): MobileRecord data class Data - constructor(kotlin.collections.List) + constructor(kotlin.collections.List, CompositionTree? = null) fun toJson(): com.google.gson.JsonElement companion object fun fromJson(kotlin.String): Data @@ -272,6 +274,13 @@ data class com.datadog.android.sessionreplay.model.MobileSegment companion object fun fromJson(kotlin.String): PointerInteractionData fun fromJsonObject(com.google.gson.JsonObject): PointerInteractionData + data class CompositionTreeMutationData : MobileIncrementalData + constructor(CompositionLayer? = null, kotlin.collections.List? = null, kotlin.collections.List? = null, kotlin.collections.List? = null) + val source: kotlin.Long + override fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): CompositionTreeMutationData + fun fromJsonObject(com.google.gson.JsonObject): CompositionTreeMutationData companion object fun fromJson(kotlin.String): MobileIncrementalData fun fromJsonElement(com.google.gson.JsonElement): MobileIncrementalData @@ -333,6 +342,12 @@ data class com.datadog.android.sessionreplay.model.MobileSegment companion object fun fromJson(kotlin.String): Wireframe fun fromJsonElement(com.google.gson.JsonElement): Wireframe + data class CompositionTree + constructor(CompositionLayer, kotlin.collections.List? = null) + fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): CompositionTree + fun fromJsonObject(com.google.gson.JsonObject): CompositionTree data class Add constructor(kotlin.Long? = null, Wireframe) fun toJson(): com.google.gson.JsonElement @@ -391,6 +406,18 @@ data class com.datadog.android.sessionreplay.model.MobileSegment companion object fun fromJson(kotlin.String): Position fun fromJsonObject(com.google.gson.JsonObject): Position + data class CompositionLayer + constructor(kotlin.Long, kotlin.Long, kotlin.Long, kotlin.Long, kotlin.Long, kotlin.collections.List, kotlin.collections.List? = null, CompositeOperation? = null) + fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): CompositionLayer + fun fromJsonObject(com.google.gson.JsonObject): CompositionLayer + data class CompositionLayerUpdate + constructor(kotlin.Long, kotlin.Long? = null, kotlin.Long? = null, kotlin.Long? = null, kotlin.Long? = null, kotlin.collections.List? = null, kotlin.collections.List? = null, CompositeOperation? = null) + fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): CompositionLayerUpdate + fun fromJsonObject(com.google.gson.JsonObject): CompositionLayerUpdate data class WireframeClip constructor(kotlin.Long? = null, kotlin.Long? = null, kotlin.Long? = null, kotlin.Long? = null) fun toJson(): com.google.gson.JsonElement @@ -398,7 +425,7 @@ data class com.datadog.android.sessionreplay.model.MobileSegment fun fromJson(kotlin.String): WireframeClip fun fromJsonObject(com.google.gson.JsonObject): WireframeClip data class ShapeStyle - constructor(kotlin.String? = null, kotlin.Number? = null, kotlin.Number? = null) + constructor(kotlin.String? = null, BackgroundGradient? = null, kotlin.Number? = null, kotlin.Number? = null) fun toJson(): com.google.gson.JsonElement companion object fun fromJson(kotlin.String): ShapeStyle @@ -421,6 +448,80 @@ data class com.datadog.android.sessionreplay.model.MobileSegment companion object fun fromJson(kotlin.String): TextPosition fun fromJsonObject(com.google.gson.JsonObject): TextPosition + data class CompositionLayerChild + constructor(Type, kotlin.Long) + fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): CompositionLayerChild + fun fromJsonObject(com.google.gson.JsonObject): CompositionLayerChild + sealed class CompositionLayerModifier + abstract fun toJson(): com.google.gson.JsonElement + data class CompositionLayerClipModifier : CompositionLayerModifier + constructor(kotlin.String, FillRule? = null) + val type: kotlin.String + override fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): CompositionLayerClipModifier + fun fromJsonObject(com.google.gson.JsonObject): CompositionLayerClipModifier + data class CompositionLayerOpacityModifier : CompositionLayerModifier + constructor(kotlin.Number) + val type: kotlin.String + override fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): CompositionLayerOpacityModifier + fun fromJsonObject(com.google.gson.JsonObject): CompositionLayerOpacityModifier + data class CompositionLayerColorMatrixModifier : CompositionLayerModifier + constructor(kotlin.collections.List) + val type: kotlin.String + override fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): CompositionLayerColorMatrixModifier + fun fromJsonObject(com.google.gson.JsonObject): CompositionLayerColorMatrixModifier + data class CompositionLayerGaussianBlurModifier : CompositionLayerModifier + constructor(kotlin.Number) + val type: kotlin.String + override fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): CompositionLayerGaussianBlurModifier + fun fromJsonObject(com.google.gson.JsonObject): CompositionLayerGaussianBlurModifier + data class CompositionLayerShadowModifier : CompositionLayerModifier + constructor(kotlin.String, kotlin.Number, kotlin.Number, kotlin.Number, kotlin.String? = null) + val type: kotlin.String + override fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): CompositionLayerShadowModifier + fun fromJsonObject(com.google.gson.JsonObject): CompositionLayerShadowModifier + data class CompositionLayerBrightnessBiasModifier : CompositionLayerModifier + constructor(kotlin.Number) + val type: kotlin.String + override fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): CompositionLayerBrightnessBiasModifier + fun fromJsonObject(com.google.gson.JsonObject): CompositionLayerBrightnessBiasModifier + data class CompositionLayerSaturateModifier : CompositionLayerModifier + constructor(kotlin.Number) + val type: kotlin.String + override fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): CompositionLayerSaturateModifier + fun fromJsonObject(com.google.gson.JsonObject): CompositionLayerSaturateModifier + data class CompositionLayerMaskImageModifier : CompositionLayerModifier + constructor(kotlin.String) + val type: kotlin.String + override fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): CompositionLayerMaskImageModifier + fun fromJsonObject(com.google.gson.JsonObject): CompositionLayerMaskImageModifier + companion object + fun fromJson(kotlin.String): CompositionLayerModifier + fun fromJsonElement(com.google.gson.JsonElement): CompositionLayerModifier + data class BackgroundGradient + constructor(kotlin.collections.List, StartPoint, StartPoint) + val type: kotlin.String + fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): BackgroundGradient + fun fromJsonObject(com.google.gson.JsonObject): BackgroundGradient data class Padding constructor(kotlin.Long? = null, kotlin.Long? = null, kotlin.Long? = null, kotlin.Long? = null) fun toJson(): com.google.gson.JsonElement @@ -433,6 +534,18 @@ data class com.datadog.android.sessionreplay.model.MobileSegment companion object fun fromJson(kotlin.String): Alignment fun fromJsonObject(com.google.gson.JsonObject): Alignment + data class ShapeGradientStop + constructor(kotlin.String, kotlin.Number) + fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): ShapeGradientStop + fun fromJsonObject(com.google.gson.JsonObject): ShapeGradientStop + data class StartPoint + constructor(kotlin.Number, kotlin.Number) + fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): StartPoint + fun fromJsonObject(com.google.gson.JsonObject): StartPoint enum Source constructor(kotlin.String) - ANDROID @@ -460,6 +573,15 @@ data class com.datadog.android.sessionreplay.model.MobileSegment fun toJson(): com.google.gson.JsonElement companion object fun fromJson(kotlin.String): PointerType + enum CompositeOperation + constructor(kotlin.String) + - SOURCEOVER + - DESTINATIONIN + - DESTINATIONOUT + - PLUSDARKER + fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): CompositeOperation enum TruncationMode constructor(kotlin.String) - CLIP @@ -469,6 +591,20 @@ data class com.datadog.android.sessionreplay.model.MobileSegment fun toJson(): com.google.gson.JsonElement companion object fun fromJson(kotlin.String): TruncationMode + enum Type + constructor(kotlin.String) + - WIREFRAME + - LAYER + fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Type + enum FillRule + constructor(kotlin.String) + - NONZERO + - EVENODD + fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): FillRule enum Horizontal constructor(kotlin.String) - LEFT 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..74a42f6473 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 @@ -1,3 +1,6 @@ +public abstract interface annotation class com/datadog/android/sessionreplay/ExperimentalSessionReplayApi : java/lang/annotation/Annotation { +} + public abstract interface class com/datadog/android/sessionreplay/ExtensionSupport { public abstract fun getCustomDrawableMapper ()Ljava/util/List; public abstract fun getCustomViewMappers ()Ljava/util/List; @@ -64,8 +67,8 @@ public final class com/datadog/android/sessionreplay/SessionReplay { } public final class com/datadog/android/sessionreplay/SessionReplayConfiguration { - public final fun copy (Ljava/lang/String;Lcom/datadog/android/sessionreplay/SessionReplayPrivacy;Ljava/util/List;Ljava/util/List;Ljava/util/List;FLcom/datadog/android/sessionreplay/ImagePrivacy;ZLcom/datadog/android/sessionreplay/TouchPrivacy;Lcom/datadog/android/sessionreplay/TextAndInputPrivacy;ZLcom/datadog/android/sessionreplay/SystemRequirementsConfiguration;Lcom/datadog/android/sessionreplay/SessionReplayInternalCallback;Z)Lcom/datadog/android/sessionreplay/SessionReplayConfiguration; - public static synthetic fun copy$default (Lcom/datadog/android/sessionreplay/SessionReplayConfiguration;Ljava/lang/String;Lcom/datadog/android/sessionreplay/SessionReplayPrivacy;Ljava/util/List;Ljava/util/List;Ljava/util/List;FLcom/datadog/android/sessionreplay/ImagePrivacy;ZLcom/datadog/android/sessionreplay/TouchPrivacy;Lcom/datadog/android/sessionreplay/TextAndInputPrivacy;ZLcom/datadog/android/sessionreplay/SystemRequirementsConfiguration;Lcom/datadog/android/sessionreplay/SessionReplayInternalCallback;ZILjava/lang/Object;)Lcom/datadog/android/sessionreplay/SessionReplayConfiguration; + public final fun copy (Ljava/lang/String;Lcom/datadog/android/sessionreplay/SessionReplayPrivacy;Ljava/util/List;Ljava/util/List;Ljava/util/List;FLcom/datadog/android/sessionreplay/ImagePrivacy;ZLcom/datadog/android/sessionreplay/TouchPrivacy;Lcom/datadog/android/sessionreplay/TextAndInputPrivacy;ZLcom/datadog/android/sessionreplay/SystemRequirementsConfiguration;Lcom/datadog/android/sessionreplay/SessionReplayInternalCallback;ZZ)Lcom/datadog/android/sessionreplay/SessionReplayConfiguration; + public static synthetic fun copy$default (Lcom/datadog/android/sessionreplay/SessionReplayConfiguration;Ljava/lang/String;Lcom/datadog/android/sessionreplay/SessionReplayPrivacy;Ljava/util/List;Ljava/util/List;Ljava/util/List;FLcom/datadog/android/sessionreplay/ImagePrivacy;ZLcom/datadog/android/sessionreplay/TouchPrivacy;Lcom/datadog/android/sessionreplay/TextAndInputPrivacy;ZLcom/datadog/android/sessionreplay/SystemRequirementsConfiguration;Lcom/datadog/android/sessionreplay/SessionReplayInternalCallback;ZZILjava/lang/Object;)Lcom/datadog/android/sessionreplay/SessionReplayConfiguration; public fun equals (Ljava/lang/Object;)Z public fun hashCode ()I public fun toString ()Ljava/lang/String; @@ -77,6 +80,7 @@ public final class com/datadog/android/sessionreplay/SessionReplayConfiguration$ public synthetic fun (FILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun addExtensionSupport (Lcom/datadog/android/sessionreplay/ExtensionSupport;)Lcom/datadog/android/sessionreplay/SessionReplayConfiguration$Builder; public final fun build ()Lcom/datadog/android/sessionreplay/SessionReplayConfiguration; + public final fun setCompositionTreeRecordingEnabled (Z)Lcom/datadog/android/sessionreplay/SessionReplayConfiguration$Builder; public final fun setDynamicOptimizationEnabled (Z)Lcom/datadog/android/sessionreplay/SessionReplayConfiguration$Builder; public final fun setHeatmapsEnabled (Z)Lcom/datadog/android/sessionreplay/SessionReplayConfiguration$Builder; public final fun setImagePrivacy (Lcom/datadog/android/sessionreplay/ImagePrivacy;)Lcom/datadog/android/sessionreplay/SessionReplayConfiguration$Builder; @@ -265,20 +269,371 @@ public final class com/datadog/android/sessionreplay/model/MobileSegment$Applica public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Application; } +public final class com/datadog/android/sessionreplay/model/MobileSegment$BackgroundGradient { + public static final field Companion Lcom/datadog/android/sessionreplay/model/MobileSegment$BackgroundGradient$Companion; + public fun (Ljava/util/List;Lcom/datadog/android/sessionreplay/model/MobileSegment$StartPoint;Lcom/datadog/android/sessionreplay/model/MobileSegment$StartPoint;)V + public final fun component1 ()Ljava/util/List; + public final fun component2 ()Lcom/datadog/android/sessionreplay/model/MobileSegment$StartPoint; + public final fun component3 ()Lcom/datadog/android/sessionreplay/model/MobileSegment$StartPoint; + public final fun copy (Ljava/util/List;Lcom/datadog/android/sessionreplay/model/MobileSegment$StartPoint;Lcom/datadog/android/sessionreplay/model/MobileSegment$StartPoint;)Lcom/datadog/android/sessionreplay/model/MobileSegment$BackgroundGradient; + public static synthetic fun copy$default (Lcom/datadog/android/sessionreplay/model/MobileSegment$BackgroundGradient;Ljava/util/List;Lcom/datadog/android/sessionreplay/model/MobileSegment$StartPoint;Lcom/datadog/android/sessionreplay/model/MobileSegment$StartPoint;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/model/MobileSegment$BackgroundGradient; + public fun equals (Ljava/lang/Object;)Z + public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$BackgroundGradient; + public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$BackgroundGradient; + public final fun getEndPoint ()Lcom/datadog/android/sessionreplay/model/MobileSegment$StartPoint; + public final fun getStartPoint ()Lcom/datadog/android/sessionreplay/model/MobileSegment$StartPoint; + public final fun getStops ()Ljava/util/List; + public final fun getType ()Ljava/lang/String; + public fun hashCode ()I + public final fun toJson ()Lcom/google/gson/JsonElement; + public fun toString ()Ljava/lang/String; +} + +public final class com/datadog/android/sessionreplay/model/MobileSegment$BackgroundGradient$Companion { + public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$BackgroundGradient; + public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$BackgroundGradient; +} + public final class com/datadog/android/sessionreplay/model/MobileSegment$Companion { public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment; public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment; } +public final class com/datadog/android/sessionreplay/model/MobileSegment$CompositeOperation : java/lang/Enum { + public static final field Companion Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositeOperation$Companion; + public static final field DESTINATIONIN Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositeOperation; + public static final field DESTINATIONOUT Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositeOperation; + public static final field PLUSDARKER Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositeOperation; + public static final field SOURCEOVER Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositeOperation; + public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositeOperation; + public final fun toJson ()Lcom/google/gson/JsonElement; + public static fun valueOf (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositeOperation; + public static fun values ()[Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositeOperation; +} + +public final class com/datadog/android/sessionreplay/model/MobileSegment$CompositeOperation$Companion { + public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositeOperation; +} + +public final class com/datadog/android/sessionreplay/model/MobileSegment$CompositionLayer { + public static final field Companion Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayer$Companion; + public fun (JJJJJLjava/util/List;Ljava/util/List;Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositeOperation;)V + public synthetic fun (JJJJJLjava/util/List;Ljava/util/List;Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositeOperation;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()J + public final fun component2 ()J + public final fun component3 ()J + public final fun component4 ()J + public final fun component5 ()J + public final fun component6 ()Ljava/util/List; + public final fun component7 ()Ljava/util/List; + public final fun component8 ()Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositeOperation; + public final fun copy (JJJJJLjava/util/List;Ljava/util/List;Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositeOperation;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayer; + public static synthetic fun copy$default (Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayer;JJJJJLjava/util/List;Ljava/util/List;Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositeOperation;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayer; + public fun equals (Ljava/lang/Object;)Z + public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayer; + public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayer; + public final fun getChildren ()Ljava/util/List; + public final fun getCompositeOperation ()Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositeOperation; + public final fun getHeight ()J + public final fun getId ()J + public final fun getModifiers ()Ljava/util/List; + public final fun getWidth ()J + public final fun getX ()J + public final fun getY ()J + public fun hashCode ()I + public final fun toJson ()Lcom/google/gson/JsonElement; + public fun toString ()Ljava/lang/String; +} + +public final class com/datadog/android/sessionreplay/model/MobileSegment$CompositionLayer$Companion { + public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayer; + public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayer; +} + +public final class com/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerChild { + public static final field Companion Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerChild$Companion; + public fun (Lcom/datadog/android/sessionreplay/model/MobileSegment$Type;J)V + public final fun component1 ()Lcom/datadog/android/sessionreplay/model/MobileSegment$Type; + public final fun component2 ()J + public final fun copy (Lcom/datadog/android/sessionreplay/model/MobileSegment$Type;J)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerChild; + public static synthetic fun copy$default (Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerChild;Lcom/datadog/android/sessionreplay/model/MobileSegment$Type;JILjava/lang/Object;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerChild; + public fun equals (Ljava/lang/Object;)Z + public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerChild; + public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerChild; + public final fun getId ()J + public final fun getType ()Lcom/datadog/android/sessionreplay/model/MobileSegment$Type; + public fun hashCode ()I + public final fun toJson ()Lcom/google/gson/JsonElement; + public fun toString ()Ljava/lang/String; +} + +public final class com/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerChild$Companion { + public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerChild; + public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerChild; +} + +public abstract class com/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier { + public static final field Companion Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$Companion; + public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier; + public static final fun fromJsonElement (Lcom/google/gson/JsonElement;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier; + public abstract fun toJson ()Lcom/google/gson/JsonElement; +} + +public final class com/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$Companion { + public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier; + public final fun fromJsonElement (Lcom/google/gson/JsonElement;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier; +} + +public final class com/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerBrightnessBiasModifier : com/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier { + public static final field Companion Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerBrightnessBiasModifier$Companion; + public fun (Ljava/lang/Number;)V + public final fun component1 ()Ljava/lang/Number; + public final fun copy (Ljava/lang/Number;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerBrightnessBiasModifier; + public static synthetic fun copy$default (Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerBrightnessBiasModifier;Ljava/lang/Number;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerBrightnessBiasModifier; + public fun equals (Ljava/lang/Object;)Z + public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerBrightnessBiasModifier; + public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerBrightnessBiasModifier; + public final fun getType ()Ljava/lang/String; + public final fun getValue ()Ljava/lang/Number; + public fun hashCode ()I + public fun toJson ()Lcom/google/gson/JsonElement; + public fun toString ()Ljava/lang/String; +} + +public final class com/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerBrightnessBiasModifier$Companion { + public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerBrightnessBiasModifier; + public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerBrightnessBiasModifier; +} + +public final class com/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerClipModifier : com/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier { + public static final field Companion Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerClipModifier$Companion; + public fun (Ljava/lang/String;Lcom/datadog/android/sessionreplay/model/MobileSegment$FillRule;)V + public synthetic fun (Ljava/lang/String;Lcom/datadog/android/sessionreplay/model/MobileSegment$FillRule;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Lcom/datadog/android/sessionreplay/model/MobileSegment$FillRule; + public final fun copy (Ljava/lang/String;Lcom/datadog/android/sessionreplay/model/MobileSegment$FillRule;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerClipModifier; + public static synthetic fun copy$default (Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerClipModifier;Ljava/lang/String;Lcom/datadog/android/sessionreplay/model/MobileSegment$FillRule;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerClipModifier; + public fun equals (Ljava/lang/Object;)Z + public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerClipModifier; + public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerClipModifier; + public final fun getFillRule ()Lcom/datadog/android/sessionreplay/model/MobileSegment$FillRule; + public final fun getPath ()Ljava/lang/String; + public final fun getType ()Ljava/lang/String; + public fun hashCode ()I + public fun toJson ()Lcom/google/gson/JsonElement; + public fun toString ()Ljava/lang/String; +} + +public final class com/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerClipModifier$Companion { + public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerClipModifier; + public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerClipModifier; +} + +public final class com/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerColorMatrixModifier : com/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier { + public static final field Companion Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerColorMatrixModifier$Companion; + public fun (Ljava/util/List;)V + public final fun component1 ()Ljava/util/List; + public final fun copy (Ljava/util/List;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerColorMatrixModifier; + public static synthetic fun copy$default (Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerColorMatrixModifier;Ljava/util/List;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerColorMatrixModifier; + public fun equals (Ljava/lang/Object;)Z + public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerColorMatrixModifier; + public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerColorMatrixModifier; + public final fun getMatrix ()Ljava/util/List; + public final fun getType ()Ljava/lang/String; + public fun hashCode ()I + public fun toJson ()Lcom/google/gson/JsonElement; + public fun toString ()Ljava/lang/String; +} + +public final class com/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerColorMatrixModifier$Companion { + public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerColorMatrixModifier; + public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerColorMatrixModifier; +} + +public final class com/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerGaussianBlurModifier : com/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier { + public static final field Companion Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerGaussianBlurModifier$Companion; + public fun (Ljava/lang/Number;)V + public final fun component1 ()Ljava/lang/Number; + public final fun copy (Ljava/lang/Number;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerGaussianBlurModifier; + public static synthetic fun copy$default (Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerGaussianBlurModifier;Ljava/lang/Number;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerGaussianBlurModifier; + public fun equals (Ljava/lang/Object;)Z + public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerGaussianBlurModifier; + public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerGaussianBlurModifier; + public final fun getRadius ()Ljava/lang/Number; + public final fun getType ()Ljava/lang/String; + public fun hashCode ()I + public fun toJson ()Lcom/google/gson/JsonElement; + public fun toString ()Ljava/lang/String; +} + +public final class com/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerGaussianBlurModifier$Companion { + public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerGaussianBlurModifier; + public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerGaussianBlurModifier; +} + +public final class com/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerMaskImageModifier : com/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier { + public static final field Companion Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerMaskImageModifier$Companion; + public fun (Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerMaskImageModifier; + public static synthetic fun copy$default (Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerMaskImageModifier;Ljava/lang/String;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerMaskImageModifier; + public fun equals (Ljava/lang/Object;)Z + public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerMaskImageModifier; + public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerMaskImageModifier; + public final fun getResourceId ()Ljava/lang/String; + public final fun getType ()Ljava/lang/String; + public fun hashCode ()I + public fun toJson ()Lcom/google/gson/JsonElement; + public fun toString ()Ljava/lang/String; +} + +public final class com/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerMaskImageModifier$Companion { + public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerMaskImageModifier; + public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerMaskImageModifier; +} + +public final class com/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerOpacityModifier : com/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier { + public static final field Companion Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerOpacityModifier$Companion; + public fun (Ljava/lang/Number;)V + public final fun component1 ()Ljava/lang/Number; + public final fun copy (Ljava/lang/Number;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerOpacityModifier; + public static synthetic fun copy$default (Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerOpacityModifier;Ljava/lang/Number;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerOpacityModifier; + public fun equals (Ljava/lang/Object;)Z + public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerOpacityModifier; + public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerOpacityModifier; + public final fun getType ()Ljava/lang/String; + public final fun getValue ()Ljava/lang/Number; + public fun hashCode ()I + public fun toJson ()Lcom/google/gson/JsonElement; + public fun toString ()Ljava/lang/String; +} + +public final class com/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerOpacityModifier$Companion { + public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerOpacityModifier; + public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerOpacityModifier; +} + +public final class com/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerSaturateModifier : com/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier { + public static final field Companion Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerSaturateModifier$Companion; + public fun (Ljava/lang/Number;)V + public final fun component1 ()Ljava/lang/Number; + public final fun copy (Ljava/lang/Number;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerSaturateModifier; + public static synthetic fun copy$default (Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerSaturateModifier;Ljava/lang/Number;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerSaturateModifier; + public fun equals (Ljava/lang/Object;)Z + public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerSaturateModifier; + public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerSaturateModifier; + public final fun getType ()Ljava/lang/String; + public final fun getValue ()Ljava/lang/Number; + public fun hashCode ()I + public fun toJson ()Lcom/google/gson/JsonElement; + public fun toString ()Ljava/lang/String; +} + +public final class com/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerSaturateModifier$Companion { + public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerSaturateModifier; + public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerSaturateModifier; +} + +public final class com/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerShadowModifier : com/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier { + public static final field Companion Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerShadowModifier$Companion; + public fun (Ljava/lang/String;Ljava/lang/Number;Ljava/lang/Number;Ljava/lang/Number;Ljava/lang/String;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/Number;Ljava/lang/Number;Ljava/lang/Number;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/Number; + public final fun component3 ()Ljava/lang/Number; + public final fun component4 ()Ljava/lang/Number; + public final fun component5 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/lang/Number;Ljava/lang/Number;Ljava/lang/Number;Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerShadowModifier; + public static synthetic fun copy$default (Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerShadowModifier;Ljava/lang/String;Ljava/lang/Number;Ljava/lang/Number;Ljava/lang/Number;Ljava/lang/String;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerShadowModifier; + public fun equals (Ljava/lang/Object;)Z + public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerShadowModifier; + public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerShadowModifier; + public final fun getColor ()Ljava/lang/String; + public final fun getOffsetX ()Ljava/lang/Number; + public final fun getOffsetY ()Ljava/lang/Number; + public final fun getPath ()Ljava/lang/String; + public final fun getRadius ()Ljava/lang/Number; + public final fun getType ()Ljava/lang/String; + public fun hashCode ()I + public fun toJson ()Lcom/google/gson/JsonElement; + public fun toString ()Ljava/lang/String; +} + +public final class com/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerShadowModifier$Companion { + public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerShadowModifier; + public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerModifier$CompositionLayerShadowModifier; +} + +public final class com/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerUpdate { + public static final field Companion Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerUpdate$Companion; + public fun (JLjava/lang/Long;Ljava/lang/Long;Ljava/lang/Long;Ljava/lang/Long;Ljava/util/List;Ljava/util/List;Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositeOperation;)V + public synthetic fun (JLjava/lang/Long;Ljava/lang/Long;Ljava/lang/Long;Ljava/lang/Long;Ljava/util/List;Ljava/util/List;Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositeOperation;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()J + 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 ()Ljava/util/List; + public final fun component7 ()Ljava/util/List; + public final fun component8 ()Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositeOperation; + public final fun copy (JLjava/lang/Long;Ljava/lang/Long;Ljava/lang/Long;Ljava/lang/Long;Ljava/util/List;Ljava/util/List;Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositeOperation;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerUpdate; + public static synthetic fun copy$default (Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerUpdate;JLjava/lang/Long;Ljava/lang/Long;Ljava/lang/Long;Ljava/lang/Long;Ljava/util/List;Ljava/util/List;Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositeOperation;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerUpdate; + public fun equals (Ljava/lang/Object;)Z + public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerUpdate; + public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerUpdate; + public final fun getChildren ()Ljava/util/List; + public final fun getCompositeOperation ()Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositeOperation; + public final fun getHeight ()Ljava/lang/Long; + public final fun getId ()J + public final fun getModifiers ()Ljava/util/List; + 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 toJson ()Lcom/google/gson/JsonElement; + public fun toString ()Ljava/lang/String; +} + +public final class com/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerUpdate$Companion { + public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerUpdate; + public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayerUpdate; +} + +public final class com/datadog/android/sessionreplay/model/MobileSegment$CompositionTree { + public static final field Companion Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionTree$Companion; + public fun (Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayer;Ljava/util/List;)V + public synthetic fun (Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayer;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayer; + public final fun component2 ()Ljava/util/List; + public final fun copy (Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayer;Ljava/util/List;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionTree; + public static synthetic fun copy$default (Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionTree;Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayer;Ljava/util/List;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionTree; + public fun equals (Ljava/lang/Object;)Z + public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionTree; + public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionTree; + public final fun getLayers ()Ljava/util/List; + public final fun getRoot ()Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayer; + public fun hashCode ()I + public final fun toJson ()Lcom/google/gson/JsonElement; + public fun toString ()Ljava/lang/String; +} + +public final class com/datadog/android/sessionreplay/model/MobileSegment$CompositionTree$Companion { + public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionTree; + public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionTree; +} + public final class com/datadog/android/sessionreplay/model/MobileSegment$Data { public static final field Companion Lcom/datadog/android/sessionreplay/model/MobileSegment$Data$Companion; - public fun (Ljava/util/List;)V + public fun (Ljava/util/List;Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionTree;)V + public synthetic fun (Ljava/util/List;Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionTree;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/util/List; - public final fun copy (Ljava/util/List;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Data; - public static synthetic fun copy$default (Lcom/datadog/android/sessionreplay/model/MobileSegment$Data;Ljava/util/List;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Data; + public final fun component2 ()Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionTree; + public final fun copy (Ljava/util/List;Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionTree;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Data; + public static synthetic fun copy$default (Lcom/datadog/android/sessionreplay/model/MobileSegment$Data;Ljava/util/List;Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionTree;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Data; public fun equals (Ljava/lang/Object;)Z public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Data; public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Data; + public final fun getCompositionTree ()Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionTree; public final fun getWireframes ()Ljava/util/List; public fun hashCode ()I public final fun toJson ()Lcom/google/gson/JsonElement; @@ -367,6 +722,20 @@ public final class com/datadog/android/sessionreplay/model/MobileSegment$Data3$C public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Data3; } +public final class com/datadog/android/sessionreplay/model/MobileSegment$FillRule : java/lang/Enum { + public static final field Companion Lcom/datadog/android/sessionreplay/model/MobileSegment$FillRule$Companion; + public static final field EVENODD Lcom/datadog/android/sessionreplay/model/MobileSegment$FillRule; + public static final field NONZERO Lcom/datadog/android/sessionreplay/model/MobileSegment$FillRule; + public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$FillRule; + public final fun toJson ()Lcom/google/gson/JsonElement; + public static fun valueOf (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$FillRule; + public static fun values ()[Lcom/datadog/android/sessionreplay/model/MobileSegment$FillRule; +} + +public final class com/datadog/android/sessionreplay/model/MobileSegment$FillRule$Companion { + public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$FillRule; +} + public final class com/datadog/android/sessionreplay/model/MobileSegment$Horizontal : java/lang/Enum { public static final field CENTER Lcom/datadog/android/sessionreplay/model/MobileSegment$Horizontal; public static final field Companion Lcom/datadog/android/sessionreplay/model/MobileSegment$Horizontal$Companion; @@ -394,6 +763,35 @@ public final class com/datadog/android/sessionreplay/model/MobileSegment$MobileI public final fun fromJsonElement (Lcom/google/gson/JsonElement;)Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileIncrementalData; } +public final class com/datadog/android/sessionreplay/model/MobileSegment$MobileIncrementalData$CompositionTreeMutationData : com/datadog/android/sessionreplay/model/MobileSegment$MobileIncrementalData { + public static final field Companion Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileIncrementalData$CompositionTreeMutationData$Companion; + public fun ()V + public fun (Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayer;Ljava/util/List;Ljava/util/List;Ljava/util/List;)V + public synthetic fun (Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayer;Ljava/util/List;Ljava/util/List;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayer; + public final fun component2 ()Ljava/util/List; + public final fun component3 ()Ljava/util/List; + public final fun component4 ()Ljava/util/List; + public final fun copy (Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayer;Ljava/util/List;Ljava/util/List;Ljava/util/List;)Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileIncrementalData$CompositionTreeMutationData; + public static synthetic fun copy$default (Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileIncrementalData$CompositionTreeMutationData;Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayer;Ljava/util/List;Ljava/util/List;Ljava/util/List;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileIncrementalData$CompositionTreeMutationData; + public fun equals (Ljava/lang/Object;)Z + public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileIncrementalData$CompositionTreeMutationData; + public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileIncrementalData$CompositionTreeMutationData; + public final fun getAdds ()Ljava/util/List; + public final fun getRemoves ()Ljava/util/List; + public final fun getRoot ()Lcom/datadog/android/sessionreplay/model/MobileSegment$CompositionLayer; + public final fun getSource ()J + public final fun getUpdates ()Ljava/util/List; + public fun hashCode ()I + public fun toJson ()Lcom/google/gson/JsonElement; + public fun toString ()Ljava/lang/String; +} + +public final class com/datadog/android/sessionreplay/model/MobileSegment$MobileIncrementalData$CompositionTreeMutationData$Companion { + public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileIncrementalData$CompositionTreeMutationData; + public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileIncrementalData$CompositionTreeMutationData; +} + public final class com/datadog/android/sessionreplay/model/MobileSegment$MobileIncrementalData$MobileMutationData : com/datadog/android/sessionreplay/model/MobileSegment$MobileIncrementalData { public static final field Companion Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileIncrementalData$MobileMutationData$Companion; public fun (Ljava/util/List;Ljava/util/List;Ljava/util/List;)V @@ -560,15 +958,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 +984,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 @@ -800,20 +1204,44 @@ public final class com/datadog/android/sessionreplay/model/MobileSegment$ShapeBo public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder; } +public final class com/datadog/android/sessionreplay/model/MobileSegment$ShapeGradientStop { + public static final field Companion Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeGradientStop$Companion; + public fun (Ljava/lang/String;Ljava/lang/Number;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/Number; + public final fun copy (Ljava/lang/String;Ljava/lang/Number;)Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeGradientStop; + public static synthetic fun copy$default (Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeGradientStop;Ljava/lang/String;Ljava/lang/Number;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeGradientStop; + public fun equals (Ljava/lang/Object;)Z + public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeGradientStop; + public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeGradientStop; + public final fun getColor ()Ljava/lang/String; + public final fun getPosition ()Ljava/lang/Number; + public fun hashCode ()I + public final fun toJson ()Lcom/google/gson/JsonElement; + public fun toString ()Ljava/lang/String; +} + +public final class com/datadog/android/sessionreplay/model/MobileSegment$ShapeGradientStop$Companion { + public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeGradientStop; + public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeGradientStop; +} + public final class com/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle { public static final field Companion Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle$Companion; public fun ()V - public fun (Ljava/lang/String;Ljava/lang/Number;Ljava/lang/Number;)V - public synthetic fun (Ljava/lang/String;Ljava/lang/Number;Ljava/lang/Number;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;Lcom/datadog/android/sessionreplay/model/MobileSegment$BackgroundGradient;Ljava/lang/Number;Ljava/lang/Number;)V + public synthetic fun (Ljava/lang/String;Lcom/datadog/android/sessionreplay/model/MobileSegment$BackgroundGradient;Ljava/lang/Number;Ljava/lang/Number;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; - public final fun component2 ()Ljava/lang/Number; + public final fun component2 ()Lcom/datadog/android/sessionreplay/model/MobileSegment$BackgroundGradient; public final fun component3 ()Ljava/lang/Number; - public final fun copy (Ljava/lang/String;Ljava/lang/Number;Ljava/lang/Number;)Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle; - public static synthetic fun copy$default (Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Ljava/lang/String;Ljava/lang/Number;Ljava/lang/Number;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle; + public final fun component4 ()Ljava/lang/Number; + public final fun copy (Ljava/lang/String;Lcom/datadog/android/sessionreplay/model/MobileSegment$BackgroundGradient;Ljava/lang/Number;Ljava/lang/Number;)Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle; + public static synthetic fun copy$default (Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Ljava/lang/String;Lcom/datadog/android/sessionreplay/model/MobileSegment$BackgroundGradient;Ljava/lang/Number;Ljava/lang/Number;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle; public fun equals (Ljava/lang/Object;)Z public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle; public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle; public final fun getBackgroundColor ()Ljava/lang/String; + public final fun getBackgroundGradient ()Lcom/datadog/android/sessionreplay/model/MobileSegment$BackgroundGradient; public final fun getCornerRadius ()Ljava/lang/Number; public final fun getOpacity ()Ljava/lang/Number; public fun hashCode ()I @@ -844,6 +1272,28 @@ public final class com/datadog/android/sessionreplay/model/MobileSegment$Source$ public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Source; } +public final class com/datadog/android/sessionreplay/model/MobileSegment$StartPoint { + public static final field Companion Lcom/datadog/android/sessionreplay/model/MobileSegment$StartPoint$Companion; + public fun (Ljava/lang/Number;Ljava/lang/Number;)V + public final fun component1 ()Ljava/lang/Number; + public final fun component2 ()Ljava/lang/Number; + public final fun copy (Ljava/lang/Number;Ljava/lang/Number;)Lcom/datadog/android/sessionreplay/model/MobileSegment$StartPoint; + public static synthetic fun copy$default (Lcom/datadog/android/sessionreplay/model/MobileSegment$StartPoint;Ljava/lang/Number;Ljava/lang/Number;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/model/MobileSegment$StartPoint; + public fun equals (Ljava/lang/Object;)Z + public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$StartPoint; + public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$StartPoint; + public final fun getX ()Ljava/lang/Number; + public final fun getY ()Ljava/lang/Number; + public fun hashCode ()I + public final fun toJson ()Lcom/google/gson/JsonElement; + public fun toString ()Ljava/lang/String; +} + +public final class com/datadog/android/sessionreplay/model/MobileSegment$StartPoint$Companion { + public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$StartPoint; + public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$StartPoint; +} + public final class com/datadog/android/sessionreplay/model/MobileSegment$TextPosition { public static final field Companion Lcom/datadog/android/sessionreplay/model/MobileSegment$TextPosition$Companion; public fun ()V @@ -911,6 +1361,20 @@ public final class com/datadog/android/sessionreplay/model/MobileSegment$Truncat public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$TruncationMode; } +public final class com/datadog/android/sessionreplay/model/MobileSegment$Type : java/lang/Enum { + public static final field Companion Lcom/datadog/android/sessionreplay/model/MobileSegment$Type$Companion; + public static final field LAYER Lcom/datadog/android/sessionreplay/model/MobileSegment$Type; + public static final field WIREFRAME Lcom/datadog/android/sessionreplay/model/MobileSegment$Type; + public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Type; + public final fun toJson ()Lcom/google/gson/JsonElement; + public static fun valueOf (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Type; + public static fun values ()[Lcom/datadog/android/sessionreplay/model/MobileSegment$Type; +} + +public final class com/datadog/android/sessionreplay/model/MobileSegment$Type$Companion { + public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Type; +} + public final class com/datadog/android/sessionreplay/model/MobileSegment$Vertical : java/lang/Enum { public static final field BOTTOM Lcom/datadog/android/sessionreplay/model/MobileSegment$Vertical; public static final field CENTER Lcom/datadog/android/sessionreplay/model/MobileSegment$Vertical; diff --git a/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/composition-layer-brightness-bias-modifier-schema.json b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/composition-layer-brightness-bias-modifier-schema.json new file mode 100644 index 0000000000..fc90620782 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/composition-layer-brightness-bias-modifier-schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "session-replay/mobile/composition-layer-brightness-bias-modifier-schema.json", + "title": "CompositionLayerBrightnessBiasModifier", + "type": "object", + "description": "Adds a signed brightness bias to the rendered layer contents.", + "required": ["type", "value"], + "properties": { + "type": { + "type": "string", + "const": "brightnessBias", + "description": "The type of the modifier.", + "readOnly": true + }, + "value": { + "type": "number", + "minimum": -1, + "maximum": 1, + "description": "Brightness bias from -1 to 1 added to each normalized RGB channel (alpha is unchanged). 0 leaves content unchanged. Positive values brighten; negative values darken. Each channel is clamped to [0, 1] after the bias is applied.", + "readOnly": true + } + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/composition-layer-child-schema.json b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/composition-layer-child-schema.json new file mode 100644 index 0000000000..a04ce6af42 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/composition-layer-child-schema.json @@ -0,0 +1,21 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "session-replay/mobile/composition-layer-child-schema.json", + "title": "CompositionLayerChild", + "type": "object", + "description": "A reference to a child wireframe or child layer in a composition layer.", + "required": ["type", "id"], + "properties": { + "type": { + "type": "string", + "enum": ["wireframe", "layer"], + "description": "The type of the child reference.", + "readOnly": true + }, + "id": { + "type": "integer", + "description": "The id of the referenced wireframe or layer.", + "readOnly": true + } + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/composition-layer-clip-modifier-schema.json b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/composition-layer-clip-modifier-schema.json new file mode 100644 index 0000000000..060d28c6ec --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/composition-layer-clip-modifier-schema.json @@ -0,0 +1,27 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "session-replay/mobile/composition-layer-clip-modifier-schema.json", + "title": "CompositionLayerClipModifier", + "type": "object", + "description": "Geometric clipping applied to the composed layer output, in coordinates local to the layer rectangle.", + "required": ["type", "path"], + "properties": { + "type": { + "type": "string", + "const": "clip", + "description": "The type of the modifier.", + "readOnly": true + }, + "path": { + "type": "string", + "description": "SVG path string defining the clip region, in coordinates local to the layer rectangle.", + "readOnly": true + }, + "fillRule": { + "type": "string", + "enum": ["nonzero", "evenodd"], + "description": "Path fill rule. Defaults to 'nonzero'.", + "readOnly": true + } + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/composition-layer-color-matrix-modifier-schema.json b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/composition-layer-color-matrix-modifier-schema.json new file mode 100644 index 0000000000..11c07daa85 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/composition-layer-color-matrix-modifier-schema.json @@ -0,0 +1,26 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "session-replay/mobile/composition-layer-color-matrix-modifier-schema.json", + "title": "CompositionLayerColorMatrixModifier", + "type": "object", + "description": "Color transformation using a 4x5 matrix applied to the composed layer output.", + "required": ["type", "matrix"], + "properties": { + "type": { + "type": "string", + "const": "colorMatrix", + "description": "The type of the modifier.", + "readOnly": true + }, + "matrix": { + "type": "array", + "items": { + "type": "number" + }, + "minItems": 20, + "maxItems": 20, + "description": "4x5 color matrix encoded as 20 numbers in row-major order. Input and output color channels are normalized to [0, 1]. The transform for each output channel is: R' = m[0]*R + m[1]*G + m[2]*B + m[3]*A + m[4], G' = m[5]*R + m[6]*G + m[7]*B + m[8]*A + m[9], B' = m[10]*R + m[11]*G + m[12]*B + m[13]*A + m[14], A' = m[15]*R + m[16]*G + m[17]*B + m[18]*A + m[19]. Each output channel is clamped to [0, 1] after evaluation.", + "readOnly": true + } + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/composition-layer-gaussian-blur-modifier-schema.json b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/composition-layer-gaussian-blur-modifier-schema.json new file mode 100644 index 0000000000..fe8e228f88 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/composition-layer-gaussian-blur-modifier-schema.json @@ -0,0 +1,22 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "session-replay/mobile/composition-layer-gaussian-blur-modifier-schema.json", + "title": "CompositionLayerGaussianBlurModifier", + "type": "object", + "description": "Gaussian blur applied to the composed layer output.", + "required": ["type", "radius"], + "properties": { + "type": { + "type": "string", + "const": "gaussianBlur", + "description": "The type of the modifier.", + "readOnly": true + }, + "radius": { + "type": "number", + "minimum": 0, + "description": "Gaussian blur radius.", + "readOnly": true + } + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/composition-layer-mask-image-modifier-schema.json b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/composition-layer-mask-image-modifier-schema.json new file mode 100644 index 0000000000..6983c75eb3 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/composition-layer-mask-image-modifier-schema.json @@ -0,0 +1,21 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "session-replay/mobile/composition-layer-mask-image-modifier-schema.json", + "title": "CompositionLayerMaskImageModifier", + "type": "object", + "description": "Image mask applied to the composed layer output at this point in the modifier order. The referenced image is mapped to the layer bounds and interpreted as an alpha mask: transparent pixels hide content, opaque pixels keep content, and partial alpha multiplies content alpha. RGB channels are ignored.", + "required": ["type", "resourceId"], + "properties": { + "type": { + "type": "string", + "const": "maskImage", + "description": "The type of the modifier.", + "readOnly": true + }, + "resourceId": { + "type": "string", + "description": "Unique identifier of the image resource used as a bounds-aligned alpha mask.", + "readOnly": true + } + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/composition-layer-modifier-schema.json b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/composition-layer-modifier-schema.json new file mode 100644 index 0000000000..69d374481f --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/composition-layer-modifier-schema.json @@ -0,0 +1,33 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "session-replay/mobile/composition-layer-modifier-schema.json", + "title": "CompositionLayerModifier", + "type": "object", + "description": "A rendering modifier applied to the composed layer output.", + "oneOf": [ + { + "$ref": "composition-layer-clip-modifier-schema.json" + }, + { + "$ref": "composition-layer-opacity-modifier-schema.json" + }, + { + "$ref": "composition-layer-color-matrix-modifier-schema.json" + }, + { + "$ref": "composition-layer-gaussian-blur-modifier-schema.json" + }, + { + "$ref": "composition-layer-shadow-modifier-schema.json" + }, + { + "$ref": "composition-layer-brightness-bias-modifier-schema.json" + }, + { + "$ref": "composition-layer-saturate-modifier-schema.json" + }, + { + "$ref": "composition-layer-mask-image-modifier-schema.json" + } + ] +} diff --git a/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/composition-layer-opacity-modifier-schema.json b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/composition-layer-opacity-modifier-schema.json new file mode 100644 index 0000000000..7a91fd13cf --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/composition-layer-opacity-modifier-schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "session-replay/mobile/composition-layer-opacity-modifier-schema.json", + "title": "CompositionLayerOpacityModifier", + "type": "object", + "description": "Opacity applied to the composed layer output at this point in the modifier order.", + "required": ["type", "value"], + "properties": { + "type": { + "type": "string", + "const": "opacity", + "description": "The type of the modifier.", + "readOnly": true + }, + "value": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Opacity value from 0 to 1.", + "readOnly": true + } + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/composition-layer-saturate-modifier-schema.json b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/composition-layer-saturate-modifier-schema.json new file mode 100644 index 0000000000..f32ff713c9 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/composition-layer-saturate-modifier-schema.json @@ -0,0 +1,22 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "session-replay/mobile/composition-layer-saturate-modifier-schema.json", + "title": "CompositionLayerSaturateModifier", + "type": "object", + "description": "Applies a saturation adjustment to the rendered layer contents.", + "required": ["type", "value"], + "properties": { + "type": { + "type": "string", + "const": "saturate", + "description": "The type of the modifier.", + "readOnly": true + }, + "value": { + "type": "number", + "minimum": 0, + "description": "Saturation multiplier. 1 leaves content unchanged. 0 removes saturation.", + "readOnly": true + } + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/composition-layer-schema.json b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/composition-layer-schema.json new file mode 100644 index 0000000000..004cb47d8c --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/composition-layer-schema.json @@ -0,0 +1,57 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "session-replay/mobile/composition-layer-schema.json", + "title": "CompositionLayer", + "type": "object", + "description": "A rendering group that groups child wireframes and child layers. Does not draw pixels itself. Ordered rendering modifiers and compositing are applied to its composed output.", + "required": ["id", "x", "y", "width", "height", "children"], + "properties": { + "id": { + "type": "integer", + "description": "Stable layer identifier, persistent throughout the view lifetime.", + "readOnly": true + }, + "x": { + "type": "integer", + "description": "The position in pixels on the X axis of the layer in absolute coordinates. Uses the same coordinate space as mobile wireframes.", + "readOnly": true + }, + "y": { + "type": "integer", + "description": "The position in pixels on the Y axis of the layer in absolute coordinates. Uses the same coordinate space as mobile wireframes.", + "readOnly": true + }, + "width": { + "type": "integer", + "description": "The width in pixels of the layer. Uses the same coordinate space as mobile wireframes.", + "readOnly": true + }, + "height": { + "type": "integer", + "description": "The height in pixels of the layer. Uses the same coordinate space as mobile wireframes.", + "readOnly": true + }, + "children": { + "type": "array", + "items": { + "$ref": "composition-layer-child-schema.json" + }, + "description": "Ordered back-to-front references to child wireframes or child layers.", + "readOnly": true + }, + "modifiers": { + "type": "array", + "items": { + "$ref": "composition-layer-modifier-schema.json" + }, + "description": "Ordered list of rendering modifiers applied to the composed layer output in array order.", + "readOnly": true + }, + "compositeOperation": { + "type": "string", + "enum": ["sourceOver", "destinationIn", "destinationOut", "plusDarker"], + "description": "Operation used when compositing the rendered group into its parent.", + "readOnly": true + } + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/composition-layer-shadow-modifier-schema.json b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/composition-layer-shadow-modifier-schema.json new file mode 100644 index 0000000000..addb7af767 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/composition-layer-shadow-modifier-schema.json @@ -0,0 +1,43 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "session-replay/mobile/composition-layer-shadow-modifier-schema.json", + "title": "CompositionLayerShadowModifier", + "type": "object", + "description": "Drop shadow drawn behind the composed layer output.", + "required": ["type", "color", "offsetX", "offsetY", "radius"], + "properties": { + "type": { + "type": "string", + "const": "shadow", + "description": "The type of the modifier.", + "readOnly": true + }, + "color": { + "type": "string", + "pattern": "^#[A-Fa-f0-9]{6}([A-Fa-f0-9]{2})?$", + "description": "The shadow color as a String hexadecimal. Follows the #RRGGBBAA color format with the alpha value as optional. SDKs should encode the effective shadow alpha in this color and omit the shadow modifier when the effective alpha is 0.", + "readOnly": true + }, + "offsetX": { + "type": "number", + "description": "Horizontal shadow offset in pixels.", + "readOnly": true + }, + "offsetY": { + "type": "number", + "description": "Vertical shadow offset in pixels.", + "readOnly": true + }, + "radius": { + "type": "number", + "minimum": 0, + "description": "Blur radius used to create the shadow.", + "readOnly": true + }, + "path": { + "type": "string", + "description": "Optional SVG path string defining the shadow outline, in coordinates local to the layer rectangle. When present, the path is interpreted using the non-zero winding rule. When omitted, the shadow follows the composed layer alpha.", + "readOnly": true + } + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/composition-layer-update-schema.json b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/composition-layer-update-schema.json new file mode 100644 index 0000000000..6af443467b --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/composition-layer-update-schema.json @@ -0,0 +1,57 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "session-replay/mobile/composition-layer-update-schema.json", + "title": "CompositionLayerUpdate", + "type": "object", + "description": "Sparse update for a composition layer. Omitted fields are unchanged.", + "required": ["id"], + "properties": { + "id": { + "type": "integer", + "description": "The id of the layer to update.", + "readOnly": true + }, + "x": { + "type": "integer", + "description": "Updated X position in absolute coordinates. Uses the same coordinate space as mobile wireframes.", + "readOnly": true + }, + "y": { + "type": "integer", + "description": "Updated Y position in absolute coordinates. Uses the same coordinate space as mobile wireframes.", + "readOnly": true + }, + "width": { + "type": "integer", + "description": "Updated width in pixels. Uses the same coordinate space as mobile wireframes.", + "readOnly": true + }, + "height": { + "type": "integer", + "description": "Updated height in pixels. Uses the same coordinate space as mobile wireframes.", + "readOnly": true + }, + "children": { + "type": "array", + "items": { + "$ref": "composition-layer-child-schema.json" + }, + "description": "When present, replaces the full child list for this layer.", + "readOnly": true + }, + "modifiers": { + "type": "array", + "items": { + "$ref": "composition-layer-modifier-schema.json" + }, + "description": "When present, replaces the full modifier list for this layer.", + "readOnly": true + }, + "compositeOperation": { + "type": "string", + "enum": ["sourceOver", "destinationIn", "destinationOut", "plusDarker"], + "description": "Updated composite operation for this layer.", + "readOnly": true + } + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/composition-tree-mutation-data-schema.json b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/composition-tree-mutation-data-schema.json new file mode 100644 index 0000000000..f3acda23d0 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/composition-tree-mutation-data-schema.json @@ -0,0 +1,43 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "session-replay/mobile/composition-tree-mutation-data-schema.json", + "title": "CompositionTreeMutationData", + "type": "object", + "description": "Mobile-specific. Incremental data carrying composition tree layer mutations.", + "required": ["source"], + "properties": { + "source": { + "type": "integer", + "const": 10, + "description": "The source of this type of incremental data.", + "readOnly": true + }, + "root": { + "$ref": "composition-layer-schema.json" + }, + "adds": { + "type": "array", + "items": { + "$ref": "composition-layer-schema.json" + }, + "description": "Full layer definitions for newly added layers.", + "readOnly": true + }, + "removes": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Ids of layer definitions to remove. Removing a referenced layer also requires updating the parent or root child list.", + "readOnly": true + }, + "updates": { + "type": "array", + "items": { + "$ref": "composition-layer-update-schema.json" + }, + "description": "Sparse updates for existing layers.", + "readOnly": true + } + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/composition-tree-schema.json b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/composition-tree-schema.json new file mode 100644 index 0000000000..1514557617 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/composition-tree-schema.json @@ -0,0 +1,21 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "session-replay/mobile/composition-tree-schema.json", + "title": "CompositionTree", + "type": "object", + "description": "Optional composition tree describing the rendering hierarchy for a full snapshot. When present, the player uses this tree for rendering order and group operations instead of rendering the wireframes as a flat array.", + "required": ["root"], + "properties": { + "root": { + "$ref": "composition-layer-schema.json" + }, + "layers": { + "type": "array", + "items": { + "$ref": "composition-layer-schema.json" + }, + "description": "Non-root composition layers referenced by the tree.", + "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..c6067f4a62 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 @@ -29,8 +29,18 @@ }, "description": "The Wireframes contained by this Record.", "readOnly": true + }, + "compositionTree": { + "$ref": "composition-tree-schema.json", + "description": "Optional composition tree describing the rendering hierarchy. When present, the player uses this tree for rendering order and group operations.", + "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-data-schema.json b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/incremental-data-schema.json index eb24f66b35..68b5329143 100644 --- a/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/incremental-data-schema.json +++ b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/incremental-data-schema.json @@ -16,6 +16,9 @@ }, { "$ref": "../common/pointer-interaction-data-schema.json" + }, + { + "$ref": "composition-tree-mutation-data-schema.json" } ] } 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/shape-gradient-point-schema.json b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/shape-gradient-point-schema.json new file mode 100644 index 0000000000..5a05d455c0 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/shape-gradient-point-schema.json @@ -0,0 +1,20 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "session-replay/mobile/shape-gradient-point-schema.json", + "title": "ShapeGradientPoint", + "type": "object", + "description": "A point in the wireframe's normalized coordinate space. The top-left corner is (0, 0), the bottom-right corner is (1, 1), and values outside this range place the point outside the wireframe bounds.", + "required": ["x", "y"], + "properties": { + "x": { + "type": "number", + "description": "Horizontal position, where 0 is the left edge and 1 is the right edge.", + "readOnly": true + }, + "y": { + "type": "number", + "description": "Vertical position, where 0 is the top edge and 1 is the bottom edge.", + "readOnly": true + } + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/shape-gradient-schema.json b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/shape-gradient-schema.json new file mode 100644 index 0000000000..832a9a1b53 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/shape-gradient-schema.json @@ -0,0 +1,11 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "session-replay/mobile/shape-gradient-schema.json", + "title": "ShapeGradient", + "description": "A background gradient for a shape wireframe. When backgroundColor is also present, the background color is painted first and the gradient is painted over it. The gradient is clipped to the wireframe shape, the border is painted afterward, and shape opacity applies to the combined result.", + "oneOf": [ + { + "$ref": "shape-linear-gradient-schema.json" + } + ] +} diff --git a/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/shape-gradient-stop-schema.json b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/shape-gradient-stop-schema.json new file mode 100644 index 0000000000..bff891c27a --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/shape-gradient-stop-schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "session-replay/mobile/shape-gradient-stop-schema.json", + "title": "ShapeGradientStop", + "type": "object", + "description": "A color and its relative position in a shape gradient.", + "required": ["color", "position"], + "properties": { + "color": { + "type": "string", + "pattern": "^#[A-Fa-f0-9]{6}([A-Fa-f0-9]{2})?$", + "description": "The stop color as a hexadecimal string in #RRGGBB or #RRGGBBAA format.", + "readOnly": true + }, + "position": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Relative stop position between 0 and 1. Stops must be ordered by non-decreasing position.", + "readOnly": true + } + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/shape-linear-gradient-schema.json b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/shape-linear-gradient-schema.json new file mode 100644 index 0000000000..e7b125fa4f --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/shape-linear-gradient-schema.json @@ -0,0 +1,43 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "session-replay/mobile/shape-linear-gradient-schema.json", + "title": "ShapeLinearGradient", + "type": "object", + "description": "A linear background gradient for a shape wireframe. Colors before the first stop and after the last stop are clamped to the nearest stop color.", + "required": ["type", "stops", "startPoint", "endPoint"], + "properties": { + "type": { + "type": "string", + "const": "linear", + "description": "The type of the gradient.", + "readOnly": true + }, + "stops": { + "type": "array", + "items": { + "$ref": "shape-gradient-stop-schema.json" + }, + "minItems": 2, + "description": "Ordered gradient color stops. Positions must be non-decreasing.", + "readOnly": true + }, + "startPoint": { + "description": "The point where position 0 of the gradient is placed.", + "readOnly": true, + "allOf": [ + { + "$ref": "shape-gradient-point-schema.json" + } + ] + }, + "endPoint": { + "description": "The point where position 1 of the gradient is placed.", + "readOnly": true, + "allOf": [ + { + "$ref": "shape-gradient-point-schema.json" + } + ] + } + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/shape-style-schema.json b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/shape-style-schema.json index 166267be44..4a34d8d807 100644 --- a/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/shape-style-schema.json +++ b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/shape-style-schema.json @@ -13,6 +13,15 @@ "description": "The background color for this wireframe as a String hexadecimal. Follows the #RRGGBBAA color format with the alpha value as optional. The default value is #FFFFFF00.", "readOnly": true }, + "backgroundGradient": { + "description": "The background gradient for this wireframe.", + "readOnly": true, + "allOf": [ + { + "$ref": "shape-gradient-schema.json" + } + ] + }, "opacity": { "type": "number", "description": "The opacity of this wireframe. Takes values from 0 to 1, default value is 1.", diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/ExperimentalSessionReplayApi.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/ExperimentalSessionReplayApi.kt new file mode 100644 index 0000000000..0072aba2da --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/ExperimentalSessionReplayApi.kt @@ -0,0 +1,18 @@ +/* + * 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 + +/** + * Marker for the experimental Session Replay API. + */ +@RequiresOptIn( + message = "This is an experimental Session Replay API." + + " It may change in the future.", + level = RequiresOptIn.Level.WARNING +) +@Retention(AnnotationRetention.BINARY) +annotation class ExperimentalSessionReplayApi 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..967694c168 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 @@ -61,7 +61,8 @@ object SessionReplay { startRecordingImmediately = sessionReplayConfiguration.startRecordingImmediately, dynamicOptimizationEnabled = sessionReplayConfiguration.dynamicOptimizationEnabled, internalCallback = sessionReplayConfiguration.internalCallback, - heatmapsEnabled = sessionReplayConfiguration.heatmapsEnabled + heatmapsEnabled = sessionReplayConfiguration.heatmapsEnabled, + compositionTreeRecordingEnabled = sessionReplayConfiguration.compositionTreeRecordingEnabled ) if (isAlreadyRegistered()) { diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/SessionReplayConfiguration.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/SessionReplayConfiguration.kt index c34c6688c1..48c28ba670 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/SessionReplayConfiguration.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/SessionReplayConfiguration.kt @@ -6,6 +6,7 @@ package com.datadog.android.sessionreplay +import android.view.View import androidx.annotation.FloatRange import com.datadog.android.api.InternalLogger import com.datadog.android.sessionreplay.internal.recorder.SessionReplayRecorder @@ -30,7 +31,8 @@ data class SessionReplayConfiguration internal constructor( internal val dynamicOptimizationEnabled: Boolean, internal val systemRequirementsConfiguration: SystemRequirementsConfiguration, internal val internalCallback: SessionReplayInternalCallback, - internal val heatmapsEnabled: Boolean + internal val heatmapsEnabled: Boolean, + internal val compositionTreeRecordingEnabled: Boolean ) { /** @@ -78,6 +80,7 @@ data class SessionReplayConfiguration internal constructor( private var systemRequirementsConfiguration = SystemRequirementsConfiguration.NONE private var internalCallback: SessionReplayInternalCallback = NoOpSessionReplayInternalCallback() private var heatmapsEnabled = false + private var compositionTreeRecordingEnabled = false /** * Adds an extension support implementation. This is mostly used when you want to provide @@ -239,6 +242,24 @@ data class SessionReplayConfiguration internal constructor( return this } + /** + * Enables the experimental composition-tree recording pipeline. + * + * The default recorder builds replay wireframes from semantic information extracted from + * Android [View] properties. This pipeline uses the rendered composition tree as its primary + * source, preserving its structure and rendering effects to improve replay fidelity in + * Android View and Jetpack Compose applications. + * + * Disabled by default. + * + * @param enabled whether composition-tree recording should be used. + */ + @ExperimentalSessionReplayApi + fun setCompositionTreeRecordingEnabled(enabled: Boolean): Builder { + compositionTreeRecordingEnabled = enabled + return this + } + /** * Builds a [SessionReplayConfiguration] based on the current state of this Builder. */ @@ -257,7 +278,8 @@ data class SessionReplayConfiguration internal constructor( dynamicOptimizationEnabled = dynamicOptimizationEnabled, systemRequirementsConfiguration = systemRequirementsConfiguration, internalCallback = internalCallback, - heatmapsEnabled = heatmapsEnabled + heatmapsEnabled = heatmapsEnabled, + compositionTreeRecordingEnabled = compositionTreeRecordingEnabled ) } 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..65130b324b 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,8 @@ import com.datadog.android.sessionreplay.ImagePrivacy import com.datadog.android.sessionreplay.MapperTypeWrapper import com.datadog.android.sessionreplay.SessionReplayInternalCallback import com.datadog.android.sessionreplay.TextAndInputPrivacy +import com.datadog.android.sessionreplay.internal.composition.CapturePipelineSelector +import com.datadog.android.sessionreplay.internal.composition.CompositionCapturePipeline 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 @@ -67,7 +69,9 @@ internal class DefaultRecorderProvider( private val customDrawableMappers: List, private val dynamicOptimizationEnabled: Boolean, private val internalCallback: SessionReplayInternalCallback, - private val heatmapsEnabled: Boolean + private val heatmapsEnabled: Boolean, + private val compositionTreeRecordingEnabled: Boolean, + private val compositionPipelineFactory: () -> Recorder = { CompositionCapturePipeline() } ) : RecorderProvider { override fun provideSessionReplayRecorder( @@ -77,24 +81,31 @@ internal class DefaultRecorderProvider( rumContextProvider: RumContextProvider, application: Application ): Recorder { - return SessionReplayRecorder( - application, - resourceDataStoreManager = resourceDataStoreManager, - resourcesWriter = resourceWriter, - rumContextProvider = rumContextProvider, - imagePrivacy = imagePrivacy, - touchPrivacyManager = touchPrivacyManager, - textAndInputPrivacy = textAndInputPrivacy, - recordWriter = recordWriter, - timeProvider = sdkCore.timeProvider, - mappers = customMappers + builtInMappers(), - customOptionSelectorDetectors = customOptionSelectorDetectors, - customDrawableMappers = customDrawableMappers, - sdkCore = sdkCore, - dynamicOptimizationEnabled = dynamicOptimizationEnabled, - internalCallback = internalCallback, - heatmapIdentifierRegistry = if (heatmapsEnabled) LazyHeatmapIdentifierRegistry(sdkCore) else null - ) + val heatmapIdentifierRegistry = if (heatmapsEnabled) LazyHeatmapIdentifierRegistry(sdkCore) else null + return CapturePipelineSelector( + compositionEnabled = compositionTreeRecordingEnabled, + compositionFactory = compositionPipelineFactory, + legacyFactory = { + SessionReplayRecorder( + application, + resourceDataStoreManager = resourceDataStoreManager, + resourcesWriter = resourceWriter, + rumContextProvider = rumContextProvider, + imagePrivacy = imagePrivacy, + touchPrivacyManager = touchPrivacyManager, + textAndInputPrivacy = textAndInputPrivacy, + recordWriter = recordWriter, + timeProvider = sdkCore.timeProvider, + mappers = customMappers + builtInMappers(), + customOptionSelectorDetectors = customOptionSelectorDetectors, + customDrawableMappers = customDrawableMappers, + sdkCore = sdkCore, + dynamicOptimizationEnabled = dynamicOptimizationEnabled, + internalCallback = internalCallback, + heatmapIdentifierRegistry = heatmapIdentifierRegistry + ) + } + ).create() } @Suppress("LongMethod") 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..d65484fc30 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 @@ -75,7 +75,8 @@ internal class SessionReplayFeature( startRecordingImmediately: Boolean, dynamicOptimizationEnabled: Boolean, internalCallback: SessionReplayInternalCallback, - heatmapsEnabled: Boolean + heatmapsEnabled: Boolean, + compositionTreeRecordingEnabled: Boolean ) : this( sdkCore, customEndpointUrl, @@ -95,7 +96,8 @@ internal class SessionReplayFeature( customDrawableMappers, dynamicOptimizationEnabled, internalCallback, - heatmapsEnabled + heatmapsEnabled, + compositionTreeRecordingEnabled ) ) diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CapturedIdentity.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CapturedIdentity.kt new file mode 100644 index 0000000000..76973423b6 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CapturedIdentity.kt @@ -0,0 +1,270 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.composition + +import com.datadog.android.api.InternalLogger + +internal data class RumViewIdentityScope(val value: String) + +internal enum class CapturedIdentityKind { + SCREEN_ROOT, + WINDOW, + VIEW, + COMPOSE_HOST, + COMPOSE_NODE, + LAYER, + WIREFRAME +} + +private const val WEB_VIEW_WIREFRAME_NAMESPACE = 0L +private const val SHAPE_WIREFRAME_NAMESPACE = 1L +private const val TEXT_WIREFRAME_NAMESPACE = 2L +private const val IMAGE_WIREFRAME_NAMESPACE = 3L +private const val PLACEHOLDER_WIREFRAME_NAMESPACE = 4L + +/** Matches the wireframe identifier namespaces used by the iOS Core Animation pipeline. */ +internal enum class CapturedWireframeKind(internal val wireIdNamespace: Long) { + /** + * Deliberately namespace `0`, i.e. unshifted: the wire id for a web-view wireframe must equal + * its `slotId` verbatim (not `slotId` shifted into a namespace), matching the pre-existing + * `id == slotId` contract the legacy recorder and iOS both rely on for WebView/JS-bridge + * correlation (see [CapturedIdentityFactory.webViewWireframe]). `slotId` is caller-supplied and, + * once wired to the real view-identity resolver, may be any `Int`-range value (including + * negative), so this is the one identity kind not structurally guaranteed collision-free + * against the rest of the tree. [DefaultCapturedIdentityFactory] offsets every non-wireframe + * layer id by [LAYER_ID_OFFSET] specifically so that raw layer ids can never land in that same + * `Int` range; a residual, validation-caught collision remains possible only between two + * web-view wireframes with a colliding `slotId` in the same view scope. + */ + WEB_VIEW(WEB_VIEW_WIREFRAME_NAMESPACE), + SHAPE(SHAPE_WIREFRAME_NAMESPACE), + TEXT(TEXT_WIREFRAME_NAMESPACE), + IMAGE(IMAGE_WIREFRAME_NAMESPACE), + PLACEHOLDER(PLACEHOLDER_WIREFRAME_NAMESPACE) +} + +internal data class CapturedIdentity internal constructor( + val scope: RumViewIdentityScope, + val kind: CapturedIdentityKind, + val wireframeKind: CapturedWireframeKind?, + val namespace: List, + val localId: String, + val wireId: Long +) { + fun path(): List = namespace + kind.name + localId +} + +internal fun interface CapturedReplayIdGenerator { + fun next(): Long +} + +internal class AutoIncrementingCapturedReplayIdGenerator( + initialId: Long = 0 +) : CapturedReplayIdGenerator { + private var currentId = initialId + + @Synchronized + override fun next(): Long { + val replayId = currentId + currentId = if (currentId < Int.MAX_VALUE) currentId + 1 else 0 + return replayId + } +} + +@Suppress("TooManyFunctions") // Each function creates one supported capture identity type. +internal interface CapturedIdentityFactory { + val scope: RumViewIdentityScope + + fun screenRoot(): CapturedIdentity + + fun window(windowId: String): CapturedIdentity + + fun view(window: CapturedIdentity, viewId: String): CapturedIdentity + + fun composeHost(window: CapturedIdentity, hostId: String): CapturedIdentity + + fun composeNode(host: CapturedIdentity, nodeId: String): CapturedIdentity + + fun layer(owner: CapturedIdentity, layerId: String): CapturedIdentity + + fun shapeWireframe(owner: CapturedIdentity): CapturedIdentity + + fun textWireframe(owner: CapturedIdentity): CapturedIdentity + + fun imageWireframe(owner: CapturedIdentity): CapturedIdentity + + fun placeholderWireframe(owner: CapturedIdentity): CapturedIdentity + + fun webViewWireframe(owner: CapturedIdentity, slotId: Long): CapturedIdentity +} + +internal class DefaultCapturedIdentityFactory( + override val scope: RumViewIdentityScope, + private val replayIdGenerator: CapturedReplayIdGenerator = SHARED_REPLAY_ID_GENERATOR, + private val internalLogger: InternalLogger = InternalLogger.UNBOUND +) : CapturedIdentityFactory { + + private val identities = mutableMapOf() + + override fun screenRoot(): CapturedIdentity = + createLayerIdentity(CapturedIdentityKind.SCREEN_ROOT, emptyList(), SCREEN_ROOT_LOCAL_ID) + + override fun window(windowId: String): CapturedIdentity = + createLayerIdentity(CapturedIdentityKind.WINDOW, emptyList(), windowId) + + override fun view(window: CapturedIdentity, viewId: String): CapturedIdentity { + validateOwner(window, CapturedIdentityKind.WINDOW) + return createLayerIdentity(CapturedIdentityKind.VIEW, window.path(), viewId) + } + + override fun composeHost(window: CapturedIdentity, hostId: String): CapturedIdentity { + validateOwner(window, CapturedIdentityKind.WINDOW) + return createLayerIdentity(CapturedIdentityKind.COMPOSE_HOST, window.path(), hostId) + } + + override fun composeNode(host: CapturedIdentity, nodeId: String): CapturedIdentity { + validateOwner(host, CapturedIdentityKind.COMPOSE_HOST) + return createLayerIdentity(CapturedIdentityKind.COMPOSE_NODE, host.path(), nodeId) + } + + override fun layer(owner: CapturedIdentity, layerId: String): CapturedIdentity { + validateLayerOwner(owner) + return createLayerIdentity(CapturedIdentityKind.LAYER, owner.path(), layerId) + } + + override fun shapeWireframe(owner: CapturedIdentity): CapturedIdentity = + createNamespacedWireframeIdentity(owner, CapturedWireframeKind.SHAPE) + + override fun textWireframe(owner: CapturedIdentity): CapturedIdentity = + createNamespacedWireframeIdentity(owner, CapturedWireframeKind.TEXT) + + override fun imageWireframe(owner: CapturedIdentity): CapturedIdentity = + createNamespacedWireframeIdentity(owner, CapturedWireframeKind.IMAGE) + + override fun placeholderWireframe(owner: CapturedIdentity): CapturedIdentity = + createNamespacedWireframeIdentity(owner, CapturedWireframeKind.PLACEHOLDER) + + override fun webViewWireframe(owner: CapturedIdentity, slotId: Long): CapturedIdentity { + validateLayerOwner(owner) + return createIdentity( + kind = CapturedIdentityKind.WIREFRAME, + wireframeKind = CapturedWireframeKind.WEB_VIEW, + namespace = owner.path(), + localId = slotId.toString(), + wireId = slotId + ) + } + + private fun createLayerIdentity( + kind: CapturedIdentityKind, + namespace: List, + localId: String + ): CapturedIdentity { + val key = IdentityKey(kind, null, namespace.toList(), localId) + identities[key]?.let { return it } + // Offset (not namespace-shifted) so raw layer ids can never collide with an unshifted + // web-view slotId, which is caller-supplied and may fall anywhere in the Int range - while + // staying below 1L shl 32 so createNamespacedWireframeIdentity's `... or owner.wireId` still + // composes cleanly against a namespace shifted into the bits above it. See + // CapturedWireframeKind.WEB_VIEW. + return createIdentity(key, LAYER_ID_OFFSET + replayIdGenerator.next()) + } + + private fun createNamespacedWireframeIdentity( + owner: CapturedIdentity, + wireframeKind: CapturedWireframeKind + ): CapturedIdentity { + validateLayerOwner(owner) + return createIdentity( + kind = CapturedIdentityKind.WIREFRAME, + wireframeKind = wireframeKind, + namespace = owner.path(), + localId = wireframeKind.name, + wireId = (wireframeKind.wireIdNamespace shl NAMESPACE_SHIFT) or owner.wireId + ) + } + + private fun createIdentity( + kind: CapturedIdentityKind, + wireframeKind: CapturedWireframeKind, + namespace: List, + localId: String, + wireId: Long + ): CapturedIdentity = createIdentity( + IdentityKey(kind, wireframeKind, namespace.toList(), localId), + wireId + ) + + private fun createIdentity(key: IdentityKey, wireId: Long): CapturedIdentity { + identities[key]?.let { return it } + return CapturedIdentity( + scope = scope, + kind = key.kind, + wireframeKind = key.wireframeKind, + namespace = key.namespace, + localId = key.localId, + wireId = wireId + ).also { identities[key] = it } + } + + // These checks guard against programmer-only misuse of the internal identity factory. They + // must never throw: a bug tripping them would otherwise crash the host application, so any + // violation is reported to the maintainer telemetry instead and construction proceeds. + private fun validateLayerOwner(owner: CapturedIdentity) { + validateOwner(owner) + if (owner.kind == CapturedIdentityKind.WIREFRAME) { + internalLogger.log( + InternalLogger.Level.WARN, + InternalLogger.Target.MAINTAINER, + { "Identity owner must be a captured layer." } + ) + } + } + + private fun validateOwner( + owner: CapturedIdentity, + expectedKind: CapturedIdentityKind? = null + ) { + if (owner.scope != scope) { + internalLogger.log( + InternalLogger.Level.WARN, + InternalLogger.Target.MAINTAINER, + { "Identity owner belongs to a different RUM view scope." } + ) + } + if (expectedKind != null && owner.kind != expectedKind) { + internalLogger.log( + InternalLogger.Level.WARN, + InternalLogger.Target.MAINTAINER, + { "Identity owner must be a $expectedKind." } + ) + } + } + + private data class IdentityKey( + val kind: CapturedIdentityKind, + val wireframeKind: CapturedWireframeKind?, + val namespace: List, + val localId: String + ) + + private companion object { + const val SCREEN_ROOT_LOCAL_ID = "screen" + const val NAMESPACE_SHIFT = 32 + val SHARED_REPLAY_ID_GENERATOR = AutoIncrementingCapturedReplayIdGenerator() + } +} + +/** + * Added to every raw layer replay id so that layer wire ids always land above the Int range + * (`[1L shl 31, (1L shl 32) - 1]` given the generator wraps at [Int.MAX_VALUE]) - outside the range + * an unshifted web-view slotId can occupy - while staying below `1L shl 32`, so + * [DefaultCapturedIdentityFactory.createNamespacedWireframeIdentity]'s + * `(wireframeKind.wireIdNamespace shl 32) or owner.wireId` still composes cleanly against a layer + * owner. See [CapturedWireframeKind.WEB_VIEW]. + */ +internal const val LAYER_ID_OFFSET = 1L shl 31 diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CapturedMutation.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CapturedMutation.kt new file mode 100644 index 0000000000..a17a6a8e6b --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CapturedMutation.kt @@ -0,0 +1,33 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.composition + +internal sealed interface CapturedChange { + object Unchanged : CapturedChange + + data class Set(val value: T) : CapturedChange +} + +internal data class CapturedLayerUpdate( + val identity: CapturedIdentity, + val x: CapturedChange = CapturedChange.Unchanged, + val y: CapturedChange = CapturedChange.Unchanged, + val width: CapturedChange = CapturedChange.Unchanged, + val height: CapturedChange = CapturedChange.Unchanged, + val children: CapturedChange> = CapturedChange.Unchanged, + val modifiers: CapturedChange> = CapturedChange.Unchanged, + val compositeOperation: CapturedChange = CapturedChange.Unchanged +) + +internal data class CapturedMutationSet( + val timestamp: Long, + val scope: RumViewIdentityScope, + val root: CapturedChange = CapturedChange.Unchanged, + val adds: CapturedChange> = CapturedChange.Unchanged, + val removes: CapturedChange> = CapturedChange.Unchanged, + val updates: CapturedChange> = CapturedChange.Unchanged +) diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CapturedMutationValidation.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CapturedMutationValidation.kt new file mode 100644 index 0000000000..675b8b50fd --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CapturedMutationValidation.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.composition + +@Suppress("TooManyFunctions") // Each function validates one independent mutation invariant. +internal class CapturedMutationValidation( + private val mutation: CapturedMutationSet, + private val base: CapturedFullSnapshot +) { + private val failures = mutableListOf() + private val adds = mutation.adds.valueOrEmpty() + private val removes = mutation.removes.valueOrEmpty() + private val updates = mutation.updates.valueOrEmpty() + private val baseLayers = base.layers.associateBy { it.identity.wireId } + private val baseWireframes = base.wireframes.associateBy { it.identity.wireId } + + fun validate(): CaptureValidationResult { + validateScope() + validateOperationDuplicates() + validateContradictions() + validateTargets() + val replacementRoot = validateReplacementRoot() + + if (failures.isNotEmpty()) return CaptureValidationResult.Invalid(failures) + return CapturedSnapshotValidation( + snapshot = effectiveSnapshot(replacementRoot), + validateWireframeDefinitions = false + ).validate() + } + + private fun validateScope() { + if (mutation.scope != base.scope) { + failures += validationFailure(CaptureValidationErrorCode.WRONG_IDENTITY_SCOPE) + } + } + + private fun validateOperationDuplicates() { + validateOperationDuplicates(adds.map { it.identity }) + validateOperationDuplicates(removes) + validateOperationDuplicates(updates.map { it.identity }) + } + + private fun validateOperationDuplicates(identities: List) { + identities.groupBy(CapturedIdentity::wireId).values.forEach { operations -> + if (operations.size > 1) { + operations.firstOrNull()?.let { + failures += validationFailure(CaptureValidationErrorCode.DUPLICATE_MUTATION_OPERATION, it) + } + } + } + } + + private fun validateContradictions() { + val addIds = adds.map { it.identity.wireId }.toSet() + val removeIds = removes.map(CapturedIdentity::wireId).toSet() + val updateIds = updates.map { it.identity.wireId }.toSet() + (addIds intersect removeIds).forEach { reportContradiction(it) } + (addIds intersect updateIds).forEach { reportContradiction(it) } + (removeIds intersect updateIds).forEach { reportContradiction(it) } + } + + private fun reportContradiction(wireId: Long) { + val identity = adds.firstOrNull { it.identity.wireId == wireId }?.identity + ?: removes.firstOrNull { it.wireId == wireId } + ?: updates.firstOrNull { it.identity.wireId == wireId }?.identity + failures += validationFailure(CaptureValidationErrorCode.CONTRADICTORY_MUTATION, identity) + } + + private fun validateTargets() { + removes.forEach(::validateRemoval) + updates.forEach(::validateUpdate) + adds.forEach(::validateAddition) + } + + private fun validateRemoval(identity: CapturedIdentity) { + if (identity.scope != mutation.scope) { + failures += validationFailure(CaptureValidationErrorCode.WRONG_IDENTITY_SCOPE, identity) + } + validateExistingTarget(identity) + } + + private fun validateUpdate(update: CapturedLayerUpdate) { + if (update.identity.scope != mutation.scope) { + failures += validationFailure(CaptureValidationErrorCode.WRONG_IDENTITY_SCOPE, update.identity) + } + validateExistingTarget(update.identity) + } + + private fun validateExistingTarget(identity: CapturedIdentity) { + val target = baseLayers[identity.wireId] + when { + target == null -> failures += validationFailure( + CaptureValidationErrorCode.UNKNOWN_MUTATION_TARGET, + identity + ) + + target.identity != identity -> failures += validationFailure( + CaptureValidationErrorCode.REFERENCE_IDENTITY_MISMATCH, + identity + ) + } + } + + private fun validateAddition(layer: CapturedLayer) { + if (layer.identity.scope != mutation.scope) { + failures += validationFailure(CaptureValidationErrorCode.WRONG_IDENTITY_SCOPE, layer.identity) + } + val identityAlreadyExists = baseLayers[layer.identity.wireId] != null || + baseWireframes[layer.identity.wireId] != null || + base.root?.identity?.wireId == layer.identity.wireId + if (identityAlreadyExists) { + failures += validationFailure(CaptureValidationErrorCode.CONTRADICTORY_MUTATION, layer.identity) + } + } + + private fun validateReplacementRoot(): CapturedLayer? { + val replacement = (mutation.root as? CapturedChange.Set)?.value ?: return null + val isValid = replacement.kind == CapturedLayerKind.SYNTHETIC_SCREEN_ROOT && + replacement.identity.kind == CapturedIdentityKind.SCREEN_ROOT && + replacement.identity.scope == mutation.scope && + (base.root == null || replacement.identity == base.root.identity) && + // Only meaningful when base.root == null (a stable root's own wireId can't newly + // collide with a wireframe): establishing the root for the first time via a mutation + // must not claim a wireId an existing wireframe already owns, or full-snapshot + // validation of the equivalent state would reject it as DUPLICATE_IDENTITY. + baseWireframes[replacement.identity.wireId] == null + if (!isValid) { + failures += validationFailure(CaptureValidationErrorCode.INVALID_ROOT_REPLACEMENT, replacement.identity) + } + return replacement + } + + // The map is a mutable copy owned by this validation operation. + @Suppress("UnsafeThirdPartyFunctionCall") + private fun effectiveSnapshot(replacementRoot: CapturedLayer?): CapturedFullSnapshot { + val effectiveLayers = baseLayers.toMutableMap() + removes.forEach { effectiveLayers.remove(it.wireId) } + adds.forEach { effectiveLayers[it.identity.wireId] = it } + updates.forEach { update -> + effectiveLayers[update.identity.wireId]?.let { + effectiveLayers[update.identity.wireId] = it.apply(update) + } + } + return base.copy( + timestamp = mutation.timestamp, + root = replacementRoot ?: base.root, + layers = effectiveLayers.values.toList() + ) + } + + private fun CapturedLayer.apply(update: CapturedLayerUpdate): CapturedLayer { + val effectiveCompositeOperation = when (val operation = update.compositeOperation) { + is CapturedChange.Set -> operation.value + CapturedChange.Unchanged -> compositeOperation + } + return copy( + bounds = bounds.copy( + x = update.x.valueOr(bounds.x), + y = update.y.valueOr(bounds.y), + width = update.width.valueOr(bounds.width), + height = update.height.valueOr(bounds.height) + ), + children = update.children.valueOr(children), + modifiers = update.modifiers.valueOr(modifiers), + compositeOperation = effectiveCompositeOperation + ) + } + + private fun CapturedChange>.valueOrEmpty(): List = + (this as? CapturedChange.Set)?.value.orEmpty() + + private fun CapturedChange.valueOr(default: T): T = + (this as? CapturedChange.Set)?.value ?: default +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CapturedSnapshotValidation.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CapturedSnapshotValidation.kt new file mode 100644 index 0000000000..37472dce57 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CapturedSnapshotValidation.kt @@ -0,0 +1,440 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.composition + +@Suppress("TooManyFunctions") // Each function validates one independent snapshot invariant. +internal class CapturedSnapshotValidation( + private val snapshot: CapturedFullSnapshot, + private val validateWireframeDefinitions: Boolean = true +) { + private val failures = mutableListOf() + + // Wireframe identities referenced by a layer child but absent from wireframesById while + // validateWireframeDefinitions is false - i.e. delivered outside this mutation. Tracked + // separately from snapshot.wireframes so validateParentCounts can still catch two references + // to the same not-yet-delivered wireframe, without resurrecting UNREFERENCED_WIREFRAME/ + // DANGLING_WIREFRAME_REFERENCE for wireframes this validation pass has no definition for. + private val referencedUnknownWireframes = mutableMapOf() + + fun validate(): CaptureValidationResult { + val root = snapshot.root ?: return missingRootResult() + validateRoot(root) + + // All collections are local captured values and are not mutated while validating. + @Suppress("UnsafeThirdPartyFunctionCall") + val layers = listOf(root) + snapshot.layers + validateDefinitions(layers) + + val layersById = snapshot.layers.associateBy { it.identity.wireId } + val wireframesById = snapshot.wireframes.associateBy { it.identity.wireId } + val parentCounts = mutableMapOf() + validateReferences(layers, layersById, wireframesById, parentCounts) + validateParentCounts(parentCounts) + validateAcyclic(layers, layersById) + return failures.toValidationResult() + } + + // This result always contains the single missing-root failure constructed here. + @Suppress("UnsafeThirdPartyFunctionCall") + private fun missingRootResult(): CaptureValidationResult = CaptureValidationResult.Invalid( + listOf(validationFailure(CaptureValidationErrorCode.MISSING_ROOT)) + ) + + private fun validateRoot(root: CapturedLayer) { + if (root.kind != CapturedLayerKind.SYNTHETIC_SCREEN_ROOT || + root.identity.kind != CapturedIdentityKind.SCREEN_ROOT + ) { + failures += validationFailure(CaptureValidationErrorCode.INVALID_ROOT, root.identity) + } + } + + private fun validateDefinitions(layers: List) { + val wireframeIdentities = if (validateWireframeDefinitions) { + snapshot.wireframes.map { it.identity } + } else { + emptyList() + } + val identities = layers.map { it.identity } + wireframeIdentities + validateIdentityDefinitions(identities) + layers.forEach(::validateLayer) + if (validateWireframeDefinitions) snapshot.wireframes.forEach(::validateWireframe) + } + + private fun validateIdentityDefinitions(identities: List) { + identities.filter { it.scope != snapshot.scope }.forEach { + failures += validationFailure(CaptureValidationErrorCode.WRONG_IDENTITY_SCOPE, it) + } + identities.groupBy(CapturedIdentity::wireId).values.forEach { matchingWireIds -> + if (matchingWireIds.size > 1) { + matchingWireIds.firstOrNull()?.let { + failures += validationFailure(CaptureValidationErrorCode.DUPLICATE_IDENTITY, it) + } + } + } + identities.groupBy { + IdentityDefinitionKey(it.scope, it.kind, it.wireframeKind, it.namespace, it.localId) + } + .values + .forEach { matchingIdentities -> + if (matchingIdentities.size > 1) { + matchingIdentities.firstOrNull()?.let { + failures += validationFailure(CaptureValidationErrorCode.DUPLICATE_IDENTITY, it) + } + } + } + } + + private fun validateLayer(layer: CapturedLayer) { + if (layer.identity.scope != snapshot.scope) { + failures += validationFailure(CaptureValidationErrorCode.WRONG_IDENTITY_SCOPE, layer.identity) + } + if (layer.identity.kind != layer.kind.identityKind || layer.identity.wireframeKind != null) { + failures += validationFailure(CaptureValidationErrorCode.IDENTITY_KIND_MISMATCH, layer.identity) + } + if (layer.bounds.width < 0 || layer.bounds.height < 0) { + failures += validationFailure(CaptureValidationErrorCode.INVALID_BOUNDS, layer.identity) + } + layer.modifiers.forEach { validateModifier(it, layer.identity) } + } + + private fun validateWireframe(captured: CapturedWireframe) { + if (captured.identity.scope != snapshot.scope) { + failures += validationFailure(CaptureValidationErrorCode.WRONG_IDENTITY_SCOPE, captured.identity) + } + if (captured.identity.kind != CapturedIdentityKind.WIREFRAME || + captured.identity.wireframeKind != captured.identityKind + ) { + failures += validationFailure(CaptureValidationErrorCode.IDENTITY_KIND_MISMATCH, captured.identity) + } + if (captured.bounds.width < 0 || captured.bounds.height < 0) { + failures += validationFailure(CaptureValidationErrorCode.INVALID_BOUNDS, captured.identity) + } + if (captured is CapturedWireframe.Pixel && !captured.resource.isResolved()) { + failures += validationFailure( + CaptureValidationErrorCode.UNRESOLVED_PIXEL_RESOURCE, + captured.identity + ) + } + validateStyle(captured.style, captured.identity) + validateBackgroundGradient(captured.style, captured.identity) + validateBorder(captured.border, captured.identity) + if (captured is CapturedWireframe.Text) validateTextStyle(captured.textStyle, captured.identity) + } + + private fun validateReferences( + layers: List, + layersById: Map, + wireframesById: Map, + parentCounts: MutableMap + ) { + layers.forEach { parent -> + parent.children.forEach { child -> + validateReference(child, layersById, wireframesById, parentCounts) + } + } + } + + private fun validateReference( + child: CapturedChild, + layersById: Map, + wireframesById: Map, + parentCounts: MutableMap + ) { + when (child) { + is CapturedChild.Layer -> validateLayerReference(child, layersById, parentCounts) + is CapturedChild.Wireframe -> validateWireframeReference(child, layersById, wireframesById, parentCounts) + } + } + + private fun validateLayerReference( + child: CapturedChild.Layer, + layersById: Map, + parentCounts: MutableMap + ) { + if (child.identity.kind == CapturedIdentityKind.WIREFRAME) { + failures += validationFailure(CaptureValidationErrorCode.IDENTITY_KIND_MISMATCH, child.identity) + } + validateReferenceScope(child.identity) + val definition = layersById[child.identity.wireId] + when { + definition == null -> failures += validationFailure( + CaptureValidationErrorCode.DANGLING_LAYER_REFERENCE, + child.identity + ) + + definition.identity != child.identity -> failures += validationFailure( + CaptureValidationErrorCode.REFERENCE_IDENTITY_MISMATCH, + child.identity + ) + + else -> parentCounts.increment(child.identity.wireId) + } + } + + private fun validateWireframeReference( + child: CapturedChild.Wireframe, + layersById: Map, + wireframesById: Map, + parentCounts: MutableMap + ) { + validateWireframeReferenceIdentity(child) + val definition = wireframesById[child.identity.wireId] + when { + // Only a wholly absent definition is tolerated during mutation validation - the + // wireframe may be delivered outside this mutation - but it's still counted, or a + // second reference introduced by this mutation's layer changes would silently give + // the same wireframe multiple parents. See validateParentCounts. A definition that + // DOES exist under this wireId but doesn't match is a real collision (e.g. two + // WebViews from different owners landing on the same unshifted slot id) and is + // rejected below regardless of validateWireframeDefinitions. An undelivered wireframe + // id colliding with an existing layer or the root is also a real collision - full + // snapshot validation would reject the equivalent state as DUPLICATE_IDENTITY - so it + // isn't waved through just because no wireframe claims that id yet. + definition == null && !validateWireframeDefinitions -> { + val collidesWithLayer = layersById[child.identity.wireId] != null || + snapshot.root?.identity?.wireId == child.identity.wireId + if (collidesWithLayer) { + failures += validationFailure(CaptureValidationErrorCode.DUPLICATE_IDENTITY, child.identity) + } else { + parentCounts.increment(child.identity.wireId) + referencedUnknownWireframes[child.identity.wireId] = child.identity + } + } + + definition == null -> failures += validationFailure( + CaptureValidationErrorCode.DANGLING_WIREFRAME_REFERENCE, + child.identity + ) + + definition.identity != child.identity -> failures += validationFailure( + CaptureValidationErrorCode.REFERENCE_IDENTITY_MISMATCH, + child.identity + ) + + else -> parentCounts.increment(child.identity.wireId) + } + } + + private fun validateWireframeReferenceIdentity(child: CapturedChild.Wireframe) { + if (child.identity.kind != CapturedIdentityKind.WIREFRAME) { + failures += validationFailure(CaptureValidationErrorCode.IDENTITY_KIND_MISMATCH, child.identity) + } + validateReferenceScope(child.identity) + } + + private fun validateReferenceScope(identity: CapturedIdentity) { + if (identity.scope != snapshot.scope) { + failures += validationFailure(CaptureValidationErrorCode.WRONG_IDENTITY_SCOPE, identity) + } + } + + private fun validateParentCounts(parentCounts: Map) { + snapshot.layers.forEach { + validateParentCount(it.identity, parentCounts, CaptureValidationErrorCode.UNREFERENCED_LAYER) + } + // Multiple parents on a wireframe is invalid regardless of validateWireframeDefinitions - a + // mutation's layer additions/updates can introduce a second reference to an existing base + // wireframe even though wireframes themselves aren't part of the mutation. Zero parents, by + // contrast, is expected during mutations: CapturedMutationSet has no way to represent a + // wireframe addition, so a layer's children can legitimately reference a wireframe that isn't + // in base.wireframes yet (delivered separately), and only get counted once it is. + snapshot.wireframes.forEach { + validateWireframeParentCount(it, parentCounts) + } + // Same invariant as above, for wireframe identities this pass has no definition for: still + // referenced ≥1 time by construction (that's how they ended up in this map), so only the + // multiple-parents case is meaningful here. + referencedUnknownWireframes.forEach { (wireId, identity) -> + if ((parentCounts[wireId] ?: 0) > 1) { + failures += validationFailure(CaptureValidationErrorCode.MULTIPLE_PARENTS, identity) + } + } + } + + private fun validateWireframeParentCount( + wireframe: CapturedWireframe, + parentCounts: Map + ) { + when (parentCounts[wireframe.identity.wireId] ?: 0) { + 0 -> if (validateWireframeDefinitions && !wireframe.isHiddenSlot) { + failures += validationFailure( + CaptureValidationErrorCode.UNREFERENCED_WIREFRAME, + wireframe.identity + ) + } + + 1 -> Unit + else -> failures += validationFailure( + CaptureValidationErrorCode.MULTIPLE_PARENTS, + wireframe.identity + ) + } + } + + private fun validateParentCount( + identity: CapturedIdentity, + parentCounts: Map, + unreferencedCode: CaptureValidationErrorCode + ) { + when (parentCounts[identity.wireId] ?: 0) { + 0 -> failures += validationFailure(unreferencedCode, identity) + 1 -> Unit + else -> failures += validationFailure(CaptureValidationErrorCode.MULTIPLE_PARENTS, identity) + } + } + + // The regular expression is compiled from a constant controlled by this class. + @Suppress("UnsafeThirdPartyFunctionCall") + private fun validateModifier(modifier: CapturedModifier, identity: CapturedIdentity) { + val valid = when (modifier) { + is CapturedModifier.BrightnessBias -> modifier.value in -1.0..1.0 + is CapturedModifier.Clip -> modifier.path.isNotBlank() + is CapturedModifier.ColorMatrix -> + modifier.values.size == COLOR_MATRIX_SIZE && modifier.values.all { it.isFinite() } + is CapturedModifier.GaussianBlur -> modifier.radius >= 0 && modifier.radius.isFinite() + is CapturedModifier.MaskImage -> modifier.resourceId.isNotBlank() + is CapturedModifier.Opacity -> modifier.value in 0.0..1.0 + is CapturedModifier.Saturate -> modifier.value >= 0 && modifier.value.isFinite() + is CapturedModifier.Shadow -> + modifier.radius >= 0 && modifier.radius.isFinite() && + modifier.offsetX.isFinite() && modifier.offsetY.isFinite() && + HEX_COLOR.matches(modifier.color) + } + if (!valid) failures += validationFailure(CaptureValidationErrorCode.INVALID_MODIFIER, identity) + } + + // The regular expression is compiled from a constant controlled by this class. + @Suppress("UnsafeThirdPartyFunctionCall") + private fun validateStyle(style: CapturedShapeStyle?, identity: CapturedIdentity) { + if (style == null) return + val valid = (style.opacity == null || style.opacity.toDouble() in 0.0..1.0) && + style.cornerRadius.isFiniteOrNull() && + (style.backgroundColor == null || HEX_COLOR.matches(style.backgroundColor)) + if (!valid) failures += validationFailure(CaptureValidationErrorCode.INVALID_STYLE, identity) + } + + // The regular expression is compiled from a constant controlled by this class. + @Suppress("UnsafeThirdPartyFunctionCall") + private fun validateBorder(border: CapturedShapeBorder?, identity: CapturedIdentity) { + if (border == null) return + if (!HEX_COLOR.matches(border.color)) { + failures += validationFailure(CaptureValidationErrorCode.INVALID_STYLE, identity) + } + } + + // The regular expression is compiled from a constant controlled by this class. + @Suppress("UnsafeThirdPartyFunctionCall") + private fun validateTextStyle(textStyle: CapturedTextStyle, identity: CapturedIdentity) { + if (!HEX_COLOR.matches(textStyle.color)) { + failures += validationFailure(CaptureValidationErrorCode.INVALID_STYLE, identity) + } + } + + private fun Number?.isFiniteOrNull(): Boolean = this == null || toDouble().isFinite() + + // The regular expression is compiled from a constant controlled by this class. + @Suppress("UnsafeThirdPartyFunctionCall") + private fun validateBackgroundGradient(style: CapturedShapeStyle?, identity: CapturedIdentity) { + val gradient = style?.backgroundGradient ?: return + val stops = gradient.stops + val endpoints = listOf(gradient.startPoint.x, gradient.startPoint.y, gradient.endPoint.x, gradient.endPoint.y) + val valid = stops.size >= MIN_GRADIENT_STOPS && + stops.all { it.offset in 0.0..1.0 && HEX_COLOR.matches(it.color) } && + stops.zipWithNext().all { (previous, next) -> previous.offset <= next.offset } && + endpoints.all { it.isFinite() } + if (!valid) failures += validationFailure(CaptureValidationErrorCode.INVALID_GRADIENT, identity) + } + + // Set membership operations cannot fail for these locally owned mutable sets. + @Suppress("UnsafeThirdPartyFunctionCall") + private fun validateAcyclic(layers: List, layersById: Map) { + val visiting = mutableSetOf() + val visited = mutableSetOf() + + fun visit(layer: CapturedLayer) { + if (!visiting.add(layer.identity.wireId)) { + failures += validationFailure(CaptureValidationErrorCode.CYCLE, layer.identity) + return + } + if (visited.add(layer.identity.wireId)) { + layer.children.filterIsInstance().forEach { + layersById[it.identity.wireId]?.let(::visit) + } + } + visiting.remove(layer.identity.wireId) + } + // Every layer is checked as a potential DFS root, not just the tree root, so a cycle among + // layers unreachable from root (e.g. two layers that only reference each other) is still + // caught - such a component would otherwise have every node reporting a valid one-parent + // count without ever being visited by a root-only traversal. + layers.filterNot { it.identity.wireId in visited }.forEach(::visit) + } + + private fun PixelResource.isResolved(): Boolean = + this is PixelResource.Resolved && resourceId.isNotBlank() + + private val CapturedWireframe.isHiddenSlot: Boolean + get() = when (this) { + is CapturedWireframe.WebView -> isVisible == false + else -> false + } + + private fun MutableMap.increment(id: Long) { + this[id] = (this[id] ?: 0) + 1 + } + + private val CapturedLayerKind.identityKind: CapturedIdentityKind + get() = when (this) { + CapturedLayerKind.SYNTHETIC_SCREEN_ROOT -> CapturedIdentityKind.SCREEN_ROOT + CapturedLayerKind.WINDOW_ROOT -> CapturedIdentityKind.WINDOW + CapturedLayerKind.NATIVE_VIEW -> CapturedIdentityKind.VIEW + CapturedLayerKind.COMPOSE_HOST -> CapturedIdentityKind.COMPOSE_HOST + CapturedLayerKind.COMPOSE_NODE -> CapturedIdentityKind.COMPOSE_NODE + CapturedLayerKind.COMPOSITION_LAYER -> CapturedIdentityKind.LAYER + } + + private val CapturedWireframe.identityKind: CapturedWireframeKind + get() = when (this) { + is CapturedWireframe.Shape -> CapturedWireframeKind.SHAPE + is CapturedWireframe.Text -> CapturedWireframeKind.TEXT + is CapturedWireframe.Pixel -> CapturedWireframeKind.IMAGE + is CapturedWireframe.PrivacyPlaceholder -> CapturedWireframeKind.PLACEHOLDER + is CapturedWireframe.WebView -> CapturedWireframeKind.WEB_VIEW + } + + private val CapturedWireframe.style: CapturedShapeStyle? + get() = when (this) { + is CapturedWireframe.Shape -> style + is CapturedWireframe.Text -> style + is CapturedWireframe.WebView -> style + is CapturedWireframe.Pixel -> style + is CapturedWireframe.PrivacyPlaceholder -> null + } + + private val CapturedWireframe.border: CapturedShapeBorder? + get() = when (this) { + is CapturedWireframe.Shape -> border + is CapturedWireframe.Text -> border + is CapturedWireframe.WebView -> border + is CapturedWireframe.Pixel -> border + is CapturedWireframe.PrivacyPlaceholder -> null + } + + private companion object { + const val COLOR_MATRIX_SIZE = 20 + const val MIN_GRADIENT_STOPS = 2 + val HEX_COLOR = Regex("^#[A-Fa-f0-9]{6}([A-Fa-f0-9]{2})?$") + } + + private data class IdentityDefinitionKey( + val scope: RumViewIdentityScope, + val kind: CapturedIdentityKind, + val wireframeKind: CapturedWireframeKind?, + val namespace: List, + val localId: String + ) +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CapturedTree.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CapturedTree.kt new file mode 100644 index 0000000000..a0df331812 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CapturedTree.kt @@ -0,0 +1,230 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.composition + +internal fun interface CapturedSnapshotProducer { + fun capture(): CapturedFullSnapshot? +} + +internal data class CapturedBounds( + val x: Long, + val y: Long, + val width: Long, + val height: Long +) + +internal enum class CapturedLayerKind { + SYNTHETIC_SCREEN_ROOT, + WINDOW_ROOT, + NATIVE_VIEW, + COMPOSE_HOST, + COMPOSE_NODE, + COMPOSITION_LAYER +} + +internal data class CapturedLayer( + val identity: CapturedIdentity, + val kind: CapturedLayerKind, + val bounds: CapturedBounds, + val children: List, + val modifiers: List = emptyList(), + val compositeOperation: CapturedCompositeOperation? = null +) + +internal sealed interface CapturedChild { + val identity: CapturedIdentity + + data class Layer( + override val identity: CapturedIdentity + ) : CapturedChild + + data class Wireframe( + override val identity: CapturedIdentity + ) : CapturedChild +} + +internal sealed interface CapturedWireframe { + val identity: CapturedIdentity + val bounds: CapturedBounds + val clip: CapturedClip? + val permanentId: String? + + data class Shape( + override val identity: CapturedIdentity, + override val bounds: CapturedBounds, + override val clip: CapturedClip? = null, + val style: CapturedShapeStyle? = null, + val border: CapturedShapeBorder? = null, + override val permanentId: String? = null + ) : CapturedWireframe + + data class Text( + override val identity: CapturedIdentity, + override val bounds: CapturedBounds, + override val clip: CapturedClip? = null, + val style: CapturedShapeStyle? = null, + val border: CapturedShapeBorder? = null, + val text: String, + val textStyle: CapturedTextStyle, + val textPosition: CapturedTextPosition? = null, + override val permanentId: String? = null + ) : CapturedWireframe + + // No separate slotId field: the identity produced by CapturedIdentityFactory.webViewWireframe + // already carries the slot id verbatim as its wire id, so the wire mapper derives slotId from + // `identity.wireId` directly. A separate field would let the two silently drift apart, breaking + // the id == slotId JS-bridge correlation contract (see CapturedWireframeKind.WEB_VIEW). + data class WebView( + override val identity: CapturedIdentity, + override val bounds: CapturedBounds, + override val clip: CapturedClip? = null, + val style: CapturedShapeStyle? = null, + val border: CapturedShapeBorder? = null, + val isVisible: Boolean? = null, + override val permanentId: String? = null + ) : CapturedWireframe + + data class Pixel( + override val identity: CapturedIdentity, + override val bounds: CapturedBounds, + override val clip: CapturedClip? = null, + val style: CapturedShapeStyle? = null, + val border: CapturedShapeBorder? = null, + val resource: PixelResource, + val isEmpty: Boolean? = null, + override val permanentId: String? = null + ) : CapturedWireframe + + data class PrivacyPlaceholder( + override val identity: CapturedIdentity, + override val bounds: CapturedBounds, + override val clip: CapturedClip? = null, + val label: String? = null, + override val permanentId: String? = null + ) : CapturedWireframe +} + +internal sealed interface PixelResource { + data class Resolved( + val resourceId: String, + val mimeType: String? = null + ) : PixelResource + + object Unresolved : PixelResource +} + +internal data class CapturedClip( + val top: Long? = null, + val bottom: Long? = null, + val left: Long? = null, + val right: Long? = null +) + +internal data class CapturedShapeStyle( + val backgroundColor: String? = null, + val backgroundGradient: CapturedBackgroundGradient? = null, + val opacity: Number? = null, + val cornerRadius: Number? = null +) + +internal data class CapturedBackgroundGradient( + val stops: List, + val startPoint: CapturedPoint, + val endPoint: CapturedPoint +) + +internal data class CapturedGradientStop( + val color: String, + val offset: Double +) + +internal data class CapturedPoint( + val x: Double, + val y: Double +) + +internal data class CapturedShapeBorder( + val color: String, + val width: Long +) + +internal data class CapturedTextStyle( + val family: String, + val size: Long, + val color: String, + val truncationMode: CapturedTruncationMode? = null +) + +internal enum class CapturedTruncationMode { CLIP, HEAD, TAIL, MIDDLE } + +internal data class CapturedTextPosition( + val padding: CapturedPadding? = null, + val alignment: CapturedAlignment? = null +) + +internal data class CapturedPadding( + val top: Long? = null, + val bottom: Long? = null, + val left: Long? = null, + val right: Long? = null +) + +internal data class CapturedAlignment( + val horizontal: CapturedHorizontalAlignment? = null, + val vertical: CapturedVerticalAlignment? = null +) + +internal enum class CapturedHorizontalAlignment { LEFT, CENTER, RIGHT } + +internal enum class CapturedVerticalAlignment { TOP, CENTER, BOTTOM } + +internal sealed interface CapturedModifier { + data class Clip( + val path: String, + val fillRule: CapturedFillRule? = null + ) : CapturedModifier + + data class Opacity(val value: Double) : CapturedModifier + + data class ColorMatrix(val values: List) : CapturedModifier + + data class GaussianBlur(val radius: Double) : CapturedModifier + + data class Shadow( + val color: String, + val offsetX: Double, + val offsetY: Double, + val radius: Double, + val path: String? = null + ) : CapturedModifier + + data class BrightnessBias(val value: Double) : CapturedModifier + + data class Saturate(val value: Double) : CapturedModifier + + data class MaskImage(val resourceId: String) : CapturedModifier +} + +internal enum class CapturedFillRule { + NON_ZERO, + EVEN_ODD +} + +internal enum class CapturedCompositeOperation { + SOURCE_OVER, + DESTINATION_IN, + DESTINATION_OUT, + PLUS_DARKER +} + +internal data class CapturedFullSnapshot( + val timestamp: Long, + val scope: RumViewIdentityScope, + val root: CapturedLayer?, + val layers: List, + val wireframes: List +) diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CapturedTreeValidator.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CapturedTreeValidator.kt new file mode 100644 index 0000000000..cd6a7fb94f --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CapturedTreeValidator.kt @@ -0,0 +1,74 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.composition + +internal enum class CaptureValidationErrorCode { + MISSING_ROOT, + INVALID_ROOT, + DUPLICATE_IDENTITY, + WRONG_IDENTITY_SCOPE, + IDENTITY_KIND_MISMATCH, + REFERENCE_IDENTITY_MISMATCH, + DANGLING_LAYER_REFERENCE, + DANGLING_WIREFRAME_REFERENCE, + UNREFERENCED_LAYER, + UNREFERENCED_WIREFRAME, + MULTIPLE_PARENTS, + CYCLE, + INVALID_BOUNDS, + UNRESOLVED_PIXEL_RESOURCE, + INVALID_MODIFIER, + INVALID_GRADIENT, + INVALID_STYLE, + DUPLICATE_MUTATION_OPERATION, + CONTRADICTORY_MUTATION, + UNKNOWN_MUTATION_TARGET, + INVALID_ROOT_REPLACEMENT +} + +internal data class CaptureValidationFailure( + val code: CaptureValidationErrorCode, + val identity: CapturedIdentity? = null, + val detail: String? = null +) + +internal sealed interface CaptureValidationResult { + object Valid : CaptureValidationResult + + data class Invalid( + val failures: List + ) : CaptureValidationResult +} + +internal interface CapturedTreeValidator { + fun validate(snapshot: CapturedFullSnapshot): CaptureValidationResult + + fun validate( + mutation: CapturedMutationSet, + base: CapturedFullSnapshot + ): CaptureValidationResult +} + +internal class DefaultCapturedTreeValidator : CapturedTreeValidator { + + override fun validate(snapshot: CapturedFullSnapshot): CaptureValidationResult = + CapturedSnapshotValidation(snapshot).validate() + + override fun validate( + mutation: CapturedMutationSet, + base: CapturedFullSnapshot + ): CaptureValidationResult = CapturedMutationValidation(mutation, base).validate() +} + +internal fun validationFailure( + code: CaptureValidationErrorCode, + identity: CapturedIdentity? = null, + detail: String? = null +) = CaptureValidationFailure(code, identity, detail) + +internal fun List.toValidationResult(): CaptureValidationResult = + if (isEmpty()) CaptureValidationResult.Valid else CaptureValidationResult.Invalid(this) diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CapturedTreeWireMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CapturedTreeWireMapper.kt new file mode 100644 index 0000000000..59cfd752bb --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CapturedTreeWireMapper.kt @@ -0,0 +1,326 @@ +/* + * 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. + */ + +@file:Suppress("TooManyFunctions") // This file contains stateless wire-model conversion extensions. + +package com.datadog.android.sessionreplay.internal.composition + +import com.datadog.android.sessionreplay.model.MobileSegment + +internal sealed interface CaptureWireMappingResult { + data class Success(val value: T) : CaptureWireMappingResult + + data class Invalid( + val failures: List + ) : CaptureWireMappingResult +} + +internal interface CapturedTreeWireMapper { + fun mapFullSnapshot( + snapshot: CapturedFullSnapshot + ): CaptureWireMappingResult + + fun mapMutation( + mutation: CapturedMutationSet, + base: CapturedFullSnapshot + ): CaptureWireMappingResult +} + +internal class DefaultCapturedTreeWireMapper( + private val validator: CapturedTreeValidator = DefaultCapturedTreeValidator() +) : CapturedTreeWireMapper { + + @Suppress("ReturnCount") // Invalid capture data is returned at the point where it is detected. + override fun mapFullSnapshot( + snapshot: CapturedFullSnapshot + ): CaptureWireMappingResult { + val validation = validator.validate(snapshot) + if (validation is CaptureValidationResult.Invalid) { + return CaptureWireMappingResult.Invalid(validation.failures) + } + + val root = snapshot.root + ?: return invalidMapping(CaptureValidationErrorCode.MISSING_ROOT) + val wireframes = snapshot.wireframes.map { captured -> + captured.toWireframeOrNull() + ?: return invalidMapping( + CaptureValidationErrorCode.UNRESOLVED_PIXEL_RESOURCE, + captured.identity + ) + } + val tree = MobileSegment.CompositionTree( + root = root.toWireLayer(), + layers = snapshot.layers.map { it.toWireLayer() }.ifEmpty { null } + ) + return CaptureWireMappingResult.Success( + MobileSegment.MobileRecord.MobileFullSnapshotRecord( + timestamp = snapshot.timestamp, + data = MobileSegment.Data( + wireframes = wireframes, + compositionTree = tree + ) + ) + ) + } + + override fun mapMutation( + mutation: CapturedMutationSet, + base: CapturedFullSnapshot + ): CaptureWireMappingResult { + val validation = validator.validate(mutation, base) + if (validation is CaptureValidationResult.Invalid) { + return CaptureWireMappingResult.Invalid(validation.failures) + } + + val data = MobileSegment.MobileIncrementalData.CompositionTreeMutationData( + root = mutation.root.mapOrNull { it.toWireLayer() }, + adds = mutation.adds.mapOrNull { layers -> layers.map { it.toWireLayer() } }, + removes = mutation.removes.mapOrNull { identities -> identities.map(CapturedIdentity::wireId) }, + updates = mutation.updates.mapOrNull { updates -> updates.map { it.toWireUpdate() } } + ) + return CaptureWireMappingResult.Success( + MobileSegment.MobileRecord.MobileIncrementalSnapshotRecord( + timestamp = mutation.timestamp, + data = data + ) + ) + } +} + +private fun CapturedLayer.toWireLayer(): MobileSegment.CompositionLayer = + MobileSegment.CompositionLayer( + id = identity.wireId, + x = bounds.x, + y = bounds.y, + width = bounds.width, + height = bounds.height, + children = children.map { it.toWireChild() }, + modifiers = modifiers.map { it.toWireModifier() }.ifEmpty { null }, + compositeOperation = compositeOperation?.toWireCompositeOperation() + ) + +private fun CapturedLayerUpdate.toWireUpdate(): MobileSegment.CompositionLayerUpdate = + MobileSegment.CompositionLayerUpdate( + id = identity.wireId, + x = x.mapOrNull { it }, + y = y.mapOrNull { it }, + width = width.mapOrNull { it }, + height = height.mapOrNull { it }, + children = children.mapOrNull { children -> children.map { it.toWireChild() } }, + modifiers = modifiers.mapOrNull { modifiers -> modifiers.map { it.toWireModifier() } }, + compositeOperation = compositeOperation.mapOrNull { + it?.toWireCompositeOperation() ?: MobileSegment.CompositeOperation.SOURCEOVER + } + ) + +private fun CapturedWireframe.toWireframeOrNull(): MobileSegment.Wireframe? = when (this) { + is CapturedWireframe.Shape -> toWireframe() + is CapturedWireframe.Text -> toWireframe() + is CapturedWireframe.Pixel -> toWireframeOrNull() + is CapturedWireframe.PrivacyPlaceholder -> toWireframe() + is CapturedWireframe.WebView -> toWireframe() +} + +private fun CapturedWireframe.Shape.toWireframe() = MobileSegment.Wireframe.ShapeWireframe( + id = identity.wireId, + x = bounds.x, + y = bounds.y, + width = bounds.width, + height = bounds.height, + clip = clip?.toWireClip(), + shapeStyle = style?.toWireShapeStyle(), + border = border?.toWireBorder(), + permanentId = permanentId +) + +private fun CapturedWireframe.Text.toWireframe() = MobileSegment.Wireframe.TextWireframe( + id = identity.wireId, + x = bounds.x, + y = bounds.y, + width = bounds.width, + height = bounds.height, + clip = clip?.toWireClip(), + shapeStyle = style?.toWireShapeStyle(), + border = border?.toWireBorder(), + text = text, + textStyle = textStyle.toWireTextStyle(), + textPosition = textPosition?.toWireTextPosition(), + permanentId = permanentId +) + +private fun CapturedWireframe.Pixel.toWireframeOrNull(): MobileSegment.Wireframe.ImageWireframe? = + (resource as? PixelResource.Resolved) + ?.takeIf { it.resourceId.isNotBlank() } + ?.let { resolvedResource -> + MobileSegment.Wireframe.ImageWireframe( + id = identity.wireId, + x = bounds.x, + y = bounds.y, + width = bounds.width, + height = bounds.height, + clip = clip?.toWireClip(), + shapeStyle = style?.toWireShapeStyle(), + border = border?.toWireBorder(), + resourceId = resolvedResource.resourceId, + mimeType = resolvedResource.mimeType, + isEmpty = isEmpty, + permanentId = permanentId + ) + } + +private fun CapturedWireframe.PrivacyPlaceholder.toWireframe() = + MobileSegment.Wireframe.PlaceholderWireframe( + id = identity.wireId, + x = bounds.x, + y = bounds.y, + width = bounds.width, + height = bounds.height, + clip = clip?.toWireClip(), + label = label, + permanentId = permanentId + ) + +private fun CapturedWireframe.WebView.toWireframe() = MobileSegment.Wireframe.WebviewWireframe( + id = identity.wireId, + x = bounds.x, + y = bounds.y, + width = bounds.width, + height = bounds.height, + clip = clip?.toWireClip(), + shapeStyle = style?.toWireShapeStyle(), + border = border?.toWireBorder(), + slotId = identity.wireId.toString(), + isVisible = isVisible, + permanentId = permanentId +) + +private fun CapturedClip.toWireClip() = MobileSegment.WireframeClip(top, bottom, left, right) + +private fun CapturedShapeStyle.toWireShapeStyle() = MobileSegment.ShapeStyle( + backgroundColor = backgroundColor, + backgroundGradient = backgroundGradient?.toWireGradient(), + opacity = opacity, + cornerRadius = cornerRadius +) + +private fun CapturedBackgroundGradient.toWireGradient() = MobileSegment.BackgroundGradient( + stops = stops.map { MobileSegment.ShapeGradientStop(it.color, it.offset) }, + startPoint = MobileSegment.StartPoint(startPoint.x, startPoint.y), + endPoint = MobileSegment.StartPoint(endPoint.x, endPoint.y) +) + +private fun CapturedShapeBorder.toWireBorder() = MobileSegment.ShapeBorder(color, width) + +private fun CapturedTextStyle.toWireTextStyle() = MobileSegment.TextStyle( + family = family, + size = size, + color = color, + truncationMode = truncationMode?.toWireTruncationMode() +) + +private fun CapturedTruncationMode.toWireTruncationMode() = when (this) { + CapturedTruncationMode.CLIP -> MobileSegment.TruncationMode.CLIP + CapturedTruncationMode.HEAD -> MobileSegment.TruncationMode.HEAD + CapturedTruncationMode.TAIL -> MobileSegment.TruncationMode.TAIL + CapturedTruncationMode.MIDDLE -> MobileSegment.TruncationMode.MIDDLE +} + +private fun CapturedTextPosition.toWireTextPosition() = MobileSegment.TextPosition( + padding = padding?.let { MobileSegment.Padding(it.top, it.bottom, it.left, it.right) }, + alignment = alignment?.let { + MobileSegment.Alignment( + horizontal = it.horizontal?.toWireHorizontal(), + vertical = it.vertical?.toWireVertical() + ) + } +) + +private fun CapturedHorizontalAlignment.toWireHorizontal() = when (this) { + CapturedHorizontalAlignment.LEFT -> MobileSegment.Horizontal.LEFT + CapturedHorizontalAlignment.CENTER -> MobileSegment.Horizontal.CENTER + CapturedHorizontalAlignment.RIGHT -> MobileSegment.Horizontal.RIGHT +} + +private fun CapturedVerticalAlignment.toWireVertical() = when (this) { + CapturedVerticalAlignment.TOP -> MobileSegment.Vertical.TOP + CapturedVerticalAlignment.CENTER -> MobileSegment.Vertical.CENTER + CapturedVerticalAlignment.BOTTOM -> MobileSegment.Vertical.BOTTOM +} + +private fun CapturedChild.toWireChild(): MobileSegment.CompositionLayerChild = when (this) { + is CapturedChild.Layer -> MobileSegment.CompositionLayerChild( + type = MobileSegment.Type.LAYER, + id = identity.wireId + ) + + is CapturedChild.Wireframe -> MobileSegment.CompositionLayerChild( + type = MobileSegment.Type.WIREFRAME, + id = identity.wireId + ) +} + +private fun CapturedModifier.toWireModifier(): MobileSegment.CompositionLayerModifier = when (this) { + is CapturedModifier.BrightnessBias -> + MobileSegment.CompositionLayerModifier.CompositionLayerBrightnessBiasModifier(value) + + is CapturedModifier.Clip -> + MobileSegment.CompositionLayerModifier.CompositionLayerClipModifier( + path = path, + fillRule = fillRule?.toWireFillRule() + ) + + is CapturedModifier.ColorMatrix -> + MobileSegment.CompositionLayerModifier.CompositionLayerColorMatrixModifier(values) + + is CapturedModifier.GaussianBlur -> + MobileSegment.CompositionLayerModifier.CompositionLayerGaussianBlurModifier(radius) + + is CapturedModifier.MaskImage -> + MobileSegment.CompositionLayerModifier.CompositionLayerMaskImageModifier(resourceId) + + is CapturedModifier.Opacity -> + MobileSegment.CompositionLayerModifier.CompositionLayerOpacityModifier(value) + + is CapturedModifier.Saturate -> + MobileSegment.CompositionLayerModifier.CompositionLayerSaturateModifier(value) + + is CapturedModifier.Shadow -> + MobileSegment.CompositionLayerModifier.CompositionLayerShadowModifier( + color = color, + offsetX = offsetX, + offsetY = offsetY, + radius = radius, + path = path + ) +} + +private fun CapturedFillRule.toWireFillRule(): MobileSegment.FillRule = when (this) { + CapturedFillRule.EVEN_ODD -> MobileSegment.FillRule.EVENODD + CapturedFillRule.NON_ZERO -> MobileSegment.FillRule.NONZERO +} + +private fun CapturedCompositeOperation.toWireCompositeOperation(): MobileSegment.CompositeOperation = + when (this) { + CapturedCompositeOperation.DESTINATION_IN -> MobileSegment.CompositeOperation.DESTINATIONIN + CapturedCompositeOperation.DESTINATION_OUT -> MobileSegment.CompositeOperation.DESTINATIONOUT + CapturedCompositeOperation.PLUS_DARKER -> MobileSegment.CompositeOperation.PLUSDARKER + CapturedCompositeOperation.SOURCE_OVER -> MobileSegment.CompositeOperation.SOURCEOVER + } + +private fun CapturedChange.mapOrNull(transform: (T) -> R): R? = + when (this) { + is CapturedChange.Set -> transform(value) + CapturedChange.Unchanged -> null + } + +// The list contains exactly the single failure constructed here. +@Suppress("UnsafeThirdPartyFunctionCall") +private fun invalidMapping( + code: CaptureValidationErrorCode, + identity: CapturedIdentity? = null +): CaptureWireMappingResult.Invalid = CaptureWireMappingResult.Invalid( + listOf(CaptureValidationFailure(code, identity)) +) diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CompositionCapturePipeline.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CompositionCapturePipeline.kt new file mode 100644 index 0000000000..c0183581bb --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/CompositionCapturePipeline.kt @@ -0,0 +1,33 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.composition + +import com.datadog.android.sessionreplay.internal.recorder.Recorder + +internal class CapturePipelineSelector( + private val compositionEnabled: Boolean, + private val legacyFactory: () -> Recorder, + private val compositionFactory: () -> Recorder +) { + fun create(): Recorder = if (compositionEnabled) compositionFactory() else legacyFactory() +} + +/** + * Lifecycle entry point for the composition capture pipeline. Traversal and capture are added by + * later workstreams; keeping this recorder isolated prevents legacy state from crossing pipelines. + */ +internal class CompositionCapturePipeline : Recorder { + override fun registerCallbacks() = Unit + + override fun unregisterCallbacks() = Unit + + override fun stopProcessingRecords() = Unit + + override fun resumeRecorders() = Unit + + override fun stopRecorders() = Unit +} 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/recorder/mapper/RadioButtonMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/RadioButtonMapper.kt index 685b8da430..40f6555fb0 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/RadioButtonMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/RadioButtonMapper.kt @@ -38,7 +38,7 @@ internal open class RadioButtonMapper( override fun resolveNotCheckedShapeStyle(view: RadioButton, checkBoxColor: String): MobileSegment.ShapeStyle? { return MobileSegment.ShapeStyle( backgroundColor = null, - view.alpha, + opacity = view.alpha, cornerRadius = CORNER_RADIUS ) } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/SwitchCompatMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/SwitchCompatMapper.kt index c25c7ce16d..55f3219275 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/SwitchCompatMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/SwitchCompatMapper.kt @@ -224,7 +224,7 @@ internal open class SwitchCompatMapper( private fun resolveTrackShapeStyle(view: SwitchCompat, checkBoxColor: String): MobileSegment.ShapeStyle { return MobileSegment.ShapeStyle( backgroundColor = checkBoxColor, - view.alpha + opacity = view.alpha ) } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/mapper/BaseWireframeMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/mapper/BaseWireframeMapper.kt index aff15ffee3..c4b9d6575f 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/mapper/BaseWireframeMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/mapper/BaseWireframeMapper.kt @@ -52,7 +52,10 @@ abstract class BaseWireframeMapper( ): MobileSegment.ShapeStyle? { val color = drawableToColorMapper.mapDrawableToColor(drawable, internalLogger) return if (color != null) { - MobileSegment.ShapeStyle(colorStringFormatter.formatColorAsHexString(color), viewAlpha) + MobileSegment.ShapeStyle( + backgroundColor = colorStringFormatter.formatColorAsHexString(color), + opacity = viewAlpha + ) } else { null } diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/SessionReplayConfigurationBuilderTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/SessionReplayConfigurationBuilderTest.kt index 528b01fd3b..9a67579d14 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/SessionReplayConfigurationBuilderTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/SessionReplayConfigurationBuilderTest.kt @@ -76,6 +76,7 @@ internal class SessionReplayConfigurationBuilderTest { assertThat(sessionReplayConfiguration.customOptionSelectorDetectors).isEmpty() assertThat(sessionReplayConfiguration.dynamicOptimizationEnabled).isEqualTo(true) assertThat(sessionReplayConfiguration.heatmapsEnabled).isFalse() + assertThat(sessionReplayConfiguration.compositionTreeRecordingEnabled).isFalse() } @Test @@ -89,6 +90,18 @@ internal class SessionReplayConfigurationBuilderTest { assertThat(sessionReplayConfiguration.heatmapsEnabled).isTrue() } + @Test + @OptIn(ExperimentalSessionReplayApi::class) + fun `M set compositionTreeRecordingEnabled to true W setCompositionTreeRecordingEnabled()`() { + // When + val sessionReplayConfiguration = testedBuilder + .setCompositionTreeRecordingEnabled(true) + .build() + + // Then + assertThat(sessionReplayConfiguration.compositionTreeRecordingEnabled).isTrue() + } + @Test fun `M build config with custom site W useCustomEndpoint() and build()`( @StringForgery(regex = "https://[a-z]+\\.com(/[a-z]+)+") sessionReplayUrl: String diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/SessionReplayConfigurationForgeryFactory.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/SessionReplayConfigurationForgeryFactory.kt index a1d2f81af8..37a8463e6c 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/SessionReplayConfigurationForgeryFactory.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/SessionReplayConfigurationForgeryFactory.kt @@ -36,7 +36,8 @@ class SessionReplayConfigurationForgeryFactory : ForgeryFactory 0 -> forge.getForgery() 1 -> forge.getForgery() 2 -> forge.getForgery() + 3 -> forge.getForgery() else -> forge.getForgery() } } diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/DefaultRecorderProviderTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/DefaultRecorderProviderTest.kt new file mode 100644 index 0000000000..c7e673c156 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/DefaultRecorderProviderTest.kt @@ -0,0 +1,104 @@ +/* + * 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 + +import android.app.Application +import com.datadog.android.api.feature.FeatureSdkCore +import com.datadog.android.sessionreplay.ImagePrivacy +import com.datadog.android.sessionreplay.SessionReplayInternalCallback +import com.datadog.android.sessionreplay.TextAndInputPrivacy +import com.datadog.android.sessionreplay.internal.recorder.Recorder +import com.datadog.android.sessionreplay.internal.recorder.SessionReplayRecorder +import com.datadog.android.sessionreplay.internal.resources.ResourceDataStoreManager +import com.datadog.android.sessionreplay.internal.storage.RecordWriter +import com.datadog.android.sessionreplay.internal.storage.ResourcesWriter +import com.datadog.android.sessionreplay.internal.utils.RumContextProvider +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +internal class DefaultRecorderProviderTest { + + @Test + fun `M create only legacy recorder W provideSessionReplayRecorder { flag omitted }`() { + // Given + val sdkCore = mock() + whenever(sdkCore.timeProvider).thenReturn(mock()) + whenever(sdkCore.internalLogger).thenReturn(mock()) + whenever(sdkCore.createSingleThreadExecutorService(any())).thenReturn(mock()) + var compositionConstructions = 0 + val provider = DefaultRecorderProvider( + sdkCore = sdkCore, + textAndInputPrivacy = TextAndInputPrivacy.MASK_ALL, + imagePrivacy = ImagePrivacy.MASK_ALL, + touchPrivacyManager = mock(), + customMappers = emptyList(), + customOptionSelectorDetectors = emptyList(), + customDrawableMappers = emptyList(), + dynamicOptimizationEnabled = false, + internalCallback = mock(), + heatmapsEnabled = false, + compositionTreeRecordingEnabled = false, + compositionPipelineFactory = { + compositionConstructions++ + mock() + } + ) + + // When + val result = provider.provideSessionReplayRecorder( + resourceDataStoreManager = mock(), + resourceWriter = mock(), + recordWriter = mock(), + rumContextProvider = mock(), + application = mock() + ) + + // Then + assertThat(result).isInstanceOf(SessionReplayRecorder::class.java) + assertThat(compositionConstructions).isZero() + } + + @Test + fun `M create only composition recorder W provideSessionReplayRecorder { flag enabled }`() { + // Given + val compositionRecorder = mock() + var compositionConstructions = 0 + val provider = DefaultRecorderProvider( + sdkCore = mock(), + textAndInputPrivacy = TextAndInputPrivacy.MASK_ALL, + imagePrivacy = ImagePrivacy.MASK_ALL, + touchPrivacyManager = mock(), + customMappers = emptyList(), + customOptionSelectorDetectors = emptyList(), + customDrawableMappers = emptyList(), + dynamicOptimizationEnabled = false, + internalCallback = mock(), + heatmapsEnabled = false, + compositionTreeRecordingEnabled = true, + compositionPipelineFactory = { + compositionConstructions++ + compositionRecorder + } + ) + + // When + val result = provider.provideSessionReplayRecorder( + resourceDataStoreManager = mock(), + resourceWriter = mock(), + recordWriter = mock(), + rumContextProvider = mock(), + application = mock() + ) + + // Then + assertThat(result).isSameAs(compositionRecorder) + assertThat(compositionConstructions).isEqualTo(1) + } +} 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..f68013d561 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 @@ -150,7 +150,10 @@ internal class SessionReplayFeatureTest { sampleRate = fakeConfiguration.sampleRate, dynamicOptimizationEnabled = fakeConfiguration.dynamicOptimizationEnabled, internalCallback = NoOpSessionReplayInternalCallback(), - heatmapsEnabled = fakeConfiguration.heatmapsEnabled + heatmapsEnabled = fakeConfiguration.heatmapsEnabled, + // The default (legacy) recorder is only selected when composition-tree recording is + // disabled; this test asserts on that recorder type, so the flag must stay false. + compositionTreeRecordingEnabled = false ) // When @@ -179,7 +182,8 @@ internal class SessionReplayFeatureTest { startRecordingImmediately = true, dynamicOptimizationEnabled = fakeConfiguration.dynamicOptimizationEnabled, internalCallback = NoOpSessionReplayInternalCallback(), - heatmapsEnabled = fakeConfiguration.heatmapsEnabled + heatmapsEnabled = fakeConfiguration.heatmapsEnabled, + compositionTreeRecordingEnabled = fakeConfiguration.compositionTreeRecordingEnabled ) // When diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/CapturedContractsTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/CapturedContractsTest.kt new file mode 100644 index 0000000000..f2b424f54a --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/CapturedContractsTest.kt @@ -0,0 +1,72 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.composition + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +internal class CapturedContractsTest { + + @Test + fun `M preserve ordered references W construct captured layer`() { + // Given + val tree = compositionTestTree() + val secondLayer = tree.factory.layer(tree.window, "second-layer") + + // When + val layer = tree.layer.copy( + children = listOf( + CapturedChild.Wireframe(tree.wireframeIdentity), + CapturedChild.Layer(secondLayer), + CapturedChild.Layer(tree.layer.identity) + ), + modifiers = listOf( + CapturedModifier.Opacity(0.5), + CapturedModifier.GaussianBlur(2.0) + ) + ) + + // Then + assertThat(layer.children.map { it.identity }) + .containsExactly(tree.wireframeIdentity, secondLayer, tree.layer.identity) + assertThat(layer.modifiers) + .containsExactly(CapturedModifier.Opacity(0.5), CapturedModifier.GaussianBlur(2.0)) + } + + @Test + fun `M distinguish unchanged from cleared W construct mutation`() { + // Given + val tree = compositionTestTree() + + // When + val unchanged = CapturedLayerUpdate(identity = tree.layer.identity) + val cleared = CapturedLayerUpdate( + identity = tree.layer.identity, + children = CapturedChange.Set(emptyList()), + modifiers = CapturedChange.Set(emptyList()), + compositeOperation = CapturedChange.Set(null) + ) + + // Then + assertThat(unchanged.children).isEqualTo(CapturedChange.Unchanged) + assertThat(unchanged.modifiers).isEqualTo(CapturedChange.Unchanged) + assertThat(cleared.children).isEqualTo(CapturedChange.Set(emptyList())) + assertThat(cleared.modifiers).isEqualTo(CapturedChange.Set(emptyList())) + assertThat(cleared.compositeOperation).isEqualTo(CapturedChange.Set(null)) + } + + @Test + fun `M represent resolved and unresolved resources W construct pixel resource`() { + // Given + When + val resolved: PixelResource = PixelResource.Resolved("resource") + val unresolved: PixelResource = PixelResource.Unresolved + + // Then + assertThat(resolved).isEqualTo(PixelResource.Resolved("resource")) + assertThat(unresolved).isSameAs(PixelResource.Unresolved) + } +} diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/CapturedIdentityTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/CapturedIdentityTest.kt new file mode 100644 index 0000000000..bbafb66bf4 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/CapturedIdentityTest.kt @@ -0,0 +1,265 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.composition + +import com.datadog.android.api.InternalLogger +import com.datadog.android.sessionreplay.forge.ForgeConfigurator +import fr.xgouchet.elmyr.annotation.IntForgery +import fr.xgouchet.elmyr.annotation.LongForgery +import fr.xgouchet.elmyr.annotation.StringForgery +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.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.eq +import org.mockito.kotlin.isNull +import org.mockito.kotlin.verify +import org.mockito.quality.Strictness + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(ForgeConfigurator::class) +internal class CapturedIdentityTest { + + @Mock + lateinit var mockInternalLogger: InternalLogger + + @Test + fun `M return stable identity W create identity { identical inputs }`( + @StringForgery fakeWindowId: String, + @StringForgery fakeViewId: String + ) { + // Given + val factory = factory() + + // When + val first = factory.view(factory.window(fakeWindowId), fakeViewId) + val repeated = factory.view(factory.window(fakeWindowId), fakeViewId) + + // Then + assertThat(repeated).isSameAs(first) + } + + @Test + fun `M allocate new identity W create identity { separate factories }`( + @StringForgery fakeWindowId: String + ) { + // Given + val generator = AutoIncrementingCapturedReplayIdGenerator() + val scope = RumViewIdentityScope("view") + val firstFactory = DefaultCapturedIdentityFactory(scope, generator) + val secondFactory = DefaultCapturedIdentityFactory(scope, generator) + + // When + val first = firstFactory.window(fakeWindowId) + val second = secondFactory.window(fakeWindowId) + + // Then + assertThat(second).isNotEqualTo(first) + assertThat(second.wireId).isEqualTo(first.wireId + 1) + } + + @Test + fun `M namespace view identity W view { identical local IDs in different windows }`() { + // Given + val factory = factory() + + // When + val first = factory.view(factory.window("first-window"), "shared-id") + val second = factory.view(factory.window("second-window"), "shared-id") + + // Then + assertThat(first).isNotEqualTo(second) + assertThat(first.wireId).isNotEqualTo(second.wireId) + } + + @Test + fun `M namespace compose identity W composeNode { identical local IDs in different hosts }`() { + // Given + val factory = factory() + val window = factory.window("window") + + // When + val first = factory.composeNode(factory.composeHost(window, "first-host"), "shared-id") + val second = factory.composeNode(factory.composeHost(window, "second-host"), "shared-id") + + // Then + assertThat(first).isNotEqualTo(second) + assertThat(first.wireId).isNotEqualTo(second.wireId) + } + + @Test + fun `M allocate distinct identity W create identity { identical numeric IDs }`() { + // Given + val factory = factory() + val window = factory.window("42") + + // When + val view = factory.view(window, "42") + val host = factory.composeHost(window, "42") + val layer = factory.layer(window, "42") + val wireframe = factory.shapeWireframe(window) + + // Then + assertThat(listOf(view, host, layer, wireframe).map { it.wireId }.toSet()).hasSize(4) + } + + @Test + fun `M isolate identity W create identity { different RUM view scopes }`() { + // Given + val generator = AutoIncrementingCapturedReplayIdGenerator() + val firstFactory = DefaultCapturedIdentityFactory(RumViewIdentityScope("first-view"), generator) + val secondFactory = DefaultCapturedIdentityFactory(RumViewIdentityScope("second-view"), generator) + + // When + val first = firstFactory.window("window") + val second = secondFactory.window("window") + + // Then + assertThat(first).isNotEqualTo(second) + assertThat(first.wireId).isNotEqualTo(second.wireId) + } + + @Test + fun `M log warning and still create identity W create identity { owner belongs to another scope }`() { + // Given + val firstFactory = factory("first-view") + val secondFactory = factory("second-view") + + // When + val view = secondFactory.view(firstFactory.window("window"), "view") + + // Then + assertThat(view).isNotNull() + verify(mockInternalLogger).log( + level = eq(InternalLogger.Level.WARN), + target = eq(InternalLogger.Target.MAINTAINER), + messageBuilder = any(), + throwable = isNull(), + onlyOnce = eq(false), + additionalProperties = isNull() + ) + } + + @Test + fun `M create namespaced IDs W create wireframe identities`( + @LongForgery(min = 0, max = Int.MAX_VALUE.toLong()) fakeInitialId: Long + ) { + // Given + val factory = DefaultCapturedIdentityFactory( + scope = RumViewIdentityScope("view"), + replayIdGenerator = AutoIncrementingCapturedReplayIdGenerator(initialId = fakeInitialId) + ) + val layer = factory.layer(factory.window("window"), "layer") + + // When + val shape = factory.shapeWireframe(layer) + val text = factory.textWireframe(layer) + val image = factory.imageWireframe(layer) + val placeholder = factory.placeholderWireframe(layer) + + // Then + assertThat(shape.wireId).isEqualTo((1L shl NAMESPACE_SHIFT) or layer.wireId) + assertThat(text.wireId).isEqualTo((2L shl NAMESPACE_SHIFT) or layer.wireId) + assertThat(image.wireId).isEqualTo((3L shl NAMESPACE_SHIFT) or layer.wireId) + assertThat(placeholder.wireId).isEqualTo((4L shl NAMESPACE_SHIFT) or layer.wireId) + } + + @Test + fun `M preserve raw slot ID W create webViewWireframe`( + @IntForgery fakeSlotId: Int + ) { + // Given + val factory = factory() + val layer = factory.layer(factory.window("window"), "layer") + + // When + val wireframe = factory.webViewWireframe(layer, slotId = fakeSlotId.toLong()) + + // Then + assertThat(wireframe.wireId).isEqualTo(fakeSlotId.toLong()) + assertThat(wireframe.wireframeKind).isEqualTo(CapturedWireframeKind.WEB_VIEW) + } + + @Test + fun `M keep layer IDs outside slot ID range W create identity { any Int slotId }`() { + // Given + val factory = DefaultCapturedIdentityFactory( + scope = RumViewIdentityScope("view"), + replayIdGenerator = AutoIncrementingCapturedReplayIdGenerator(initialId = Int.MAX_VALUE - 1L) + ) + + // When + val layerIds = listOf( + factory.window("before-max").wireId, + factory.window("max").wireId, + factory.window("wrapped").wireId, + factory.window("min").wireId + ) + + // Then + // A web-view slotId is caller-supplied and preserved verbatim as its wire id (see + // `M preserve raw slot ID`), so it may be anywhere in the Int range. Layer ids must never + // land in that range, or a colliding slotId could be mistaken for an unrelated layer. + assertThat(layerIds).allMatch { it < Int.MIN_VALUE || it > Int.MAX_VALUE.toLong() } + } + + @Test + fun `M wrap replay IDs W create identity { maximum ID reached }`() { + // Given + val factory = DefaultCapturedIdentityFactory( + scope = RumViewIdentityScope("view"), + replayIdGenerator = AutoIncrementingCapturedReplayIdGenerator(initialId = Int.MAX_VALUE - 1L) + ) + + // When + val beforeMax = factory.window("before-max") + val max = factory.window("max") + val wrapped = factory.window("wrapped") + + // Then + assertThat(beforeMax.wireId).isEqualTo(LAYER_ID_OFFSET + (Int.MAX_VALUE - 1L)) + assertThat(max.wireId).isEqualTo(LAYER_ID_OFFSET + Int.MAX_VALUE.toLong()) + assertThat(wrapped.wireId).isEqualTo(LAYER_ID_OFFSET) + } + + @Test + fun `M create player-safe IDs W create identities`() { + // Given + val factory = factory() + val window = factory.window("window") + + // When + val identities = List(1_000) { factory.view(window, "view-$it") } + + factory.placeholderWireframe(window) + + // Then + assertThat(identities.map { it.wireId }) + .allMatch { it in 0..MAX_SAFE_JAVASCRIPT_INTEGER } + .doesNotHaveDuplicates() + } + + private fun factory(scope: String = "view") = DefaultCapturedIdentityFactory( + scope = RumViewIdentityScope(scope), + replayIdGenerator = AutoIncrementingCapturedReplayIdGenerator(), + internalLogger = mockInternalLogger + ) + + private companion object { + const val NAMESPACE_SHIFT = 32 + const val MAX_SAFE_JAVASCRIPT_INTEGER = (1L shl 53) - 1 + } +} diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/CapturedTreeValidatorTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/CapturedTreeValidatorTest.kt new file mode 100644 index 0000000000..e811114dbc --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/CapturedTreeValidatorTest.kt @@ -0,0 +1,1165 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.composition + +import com.datadog.android.sessionreplay.forge.ForgeConfigurator +import fr.xgouchet.elmyr.annotation.IntForgery +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 CapturedTreeValidatorTest { + + private val testedValidator = DefaultCapturedTreeValidator() + + @Test + fun `M return valid W validate { minimal full snapshot }`() { + // Given + val snapshot = compositionTestTree().snapshot + + // When + val result = testedValidator.validate(snapshot) + + // Then + assertThat(result).isEqualTo(CaptureValidationResult.Valid) + } + + @Test + fun `M return valid W validate { valid background gradient }`() { + // Given + val tree = compositionTestTree() + val wireframe = (tree.wireframe as CapturedWireframe.Shape).copy( + style = CapturedShapeStyle( + backgroundGradient = CapturedBackgroundGradient( + stops = listOf( + CapturedGradientStop("#000000", 0.0), + CapturedGradientStop("#FFFFFFFF", 1.0) + ), + startPoint = CapturedPoint(0.0, 0.0), + endPoint = CapturedPoint(1.0, 1.0) + ) + ) + ) + val snapshot = tree.snapshot.copy(wireframes = listOf(wireframe)) + + // When + val result = testedValidator.validate(snapshot) + + // Then + assertThat(result).isEqualTo(CaptureValidationResult.Valid) + } + + @Test + fun `M report invalid gradient W validate { fewer than two stops }`() { + // Given + val tree = compositionTestTree() + val wireframe = (tree.wireframe as CapturedWireframe.Shape).copy( + style = CapturedShapeStyle( + backgroundGradient = CapturedBackgroundGradient( + stops = listOf(CapturedGradientStop("#000000", 0.0)), + startPoint = CapturedPoint(0.0, 0.0), + endPoint = CapturedPoint(1.0, 1.0) + ) + ) + ) + val snapshot = tree.snapshot.copy(wireframes = listOf(wireframe)) + + // When + val result = testedValidator.validate(snapshot) + + // Then + assertThat(result.codes()).contains(CaptureValidationErrorCode.INVALID_GRADIENT) + } + + @Test + fun `M report invalid gradient W validate { offset out of range }`() { + // Given + val tree = compositionTestTree() + val wireframe = (tree.wireframe as CapturedWireframe.Shape).copy( + style = CapturedShapeStyle( + backgroundGradient = CapturedBackgroundGradient( + stops = listOf( + CapturedGradientStop("#000000", 0.0), + CapturedGradientStop("#FFFFFF", 5.0) + ), + startPoint = CapturedPoint(0.0, 0.0), + endPoint = CapturedPoint(1.0, 1.0) + ) + ) + ) + val snapshot = tree.snapshot.copy(wireframes = listOf(wireframe)) + + // When + val result = testedValidator.validate(snapshot) + + // Then + assertThat(result.codes()).contains(CaptureValidationErrorCode.INVALID_GRADIENT) + } + + @Test + fun `M report invalid gradient W validate { invalid color }`() { + // Given + val tree = compositionTestTree() + val wireframe = (tree.wireframe as CapturedWireframe.Shape).copy( + style = CapturedShapeStyle( + backgroundGradient = CapturedBackgroundGradient( + stops = listOf( + CapturedGradientStop("not-a-color", 0.0), + CapturedGradientStop("#FFFFFF", 1.0) + ), + startPoint = CapturedPoint(0.0, 0.0), + endPoint = CapturedPoint(1.0, 1.0) + ) + ) + ) + val snapshot = tree.snapshot.copy(wireframes = listOf(wireframe)) + + // When + val result = testedValidator.validate(snapshot) + + // Then + assertThat(result.codes()).contains(CaptureValidationErrorCode.INVALID_GRADIENT) + } + + @Test + fun `M report invalid gradient W validate { decreasing offsets }`() { + // Given + val tree = compositionTestTree() + val wireframe = (tree.wireframe as CapturedWireframe.Shape).copy( + style = CapturedShapeStyle( + backgroundGradient = CapturedBackgroundGradient( + stops = listOf( + CapturedGradientStop("#000000", 0.6), + CapturedGradientStop("#FFFFFF", 0.4) + ), + startPoint = CapturedPoint(0.0, 0.0), + endPoint = CapturedPoint(1.0, 1.0) + ) + ) + ) + val snapshot = tree.snapshot.copy(wireframes = listOf(wireframe)) + + // When + val result = testedValidator.validate(snapshot) + + // Then + assertThat(result.codes()).contains(CaptureValidationErrorCode.INVALID_GRADIENT) + } + + @Test + fun `M report invalid gradient W validate { non-finite endpoint coordinate }`() { + // Given + val tree = compositionTestTree() + val wireframe = (tree.wireframe as CapturedWireframe.Shape).copy( + style = CapturedShapeStyle( + backgroundGradient = CapturedBackgroundGradient( + stops = listOf( + CapturedGradientStop("#000000", 0.0), + CapturedGradientStop("#FFFFFF", 1.0) + ), + startPoint = CapturedPoint(Double.NaN, 0.0), + endPoint = CapturedPoint(1.0, Double.POSITIVE_INFINITY) + ) + ) + ) + val snapshot = tree.snapshot.copy(wireframes = listOf(wireframe)) + + // When + val result = testedValidator.validate(snapshot) + + // Then + assertThat(result.codes()).contains(CaptureValidationErrorCode.INVALID_GRADIENT) + } + + @Test + fun `M report invalid modifier W validate { non-finite color matrix entry }`() { + // Given + val tree = compositionTestTree() + val matrix = List(19) { 0.0 } + Double.NaN + val layer = tree.layer.copy(modifiers = listOf(CapturedModifier.ColorMatrix(matrix))) + val snapshot = tree.snapshot.copy(layers = listOf(layer)) + + // When + val result = testedValidator.validate(snapshot) + + // Then + assertThat(result.codes()).contains(CaptureValidationErrorCode.INVALID_MODIFIER) + } + + @Test + fun `M report invalid modifier W validate { non-finite shadow offset }`() { + // Given + val tree = compositionTestTree() + val shadow = CapturedModifier.Shadow( + color = "#000000FF", + offsetX = Double.NaN, + offsetY = 0.0, + radius = 4.0 + ) + val layer = tree.layer.copy(modifiers = listOf(shadow)) + val snapshot = tree.snapshot.copy(layers = listOf(layer)) + + // When + val result = testedValidator.validate(snapshot) + + // Then + assertThat(result.codes()).contains(CaptureValidationErrorCode.INVALID_MODIFIER) + } + + @Test + fun `M report invalid modifier W validate { infinite shadow radius }`() { + // Given + val tree = compositionTestTree() + val shadow = CapturedModifier.Shadow( + color = "#000000FF", + offsetX = 0.0, + offsetY = 0.0, + radius = Double.POSITIVE_INFINITY + ) + val layer = tree.layer.copy(modifiers = listOf(shadow)) + val snapshot = tree.snapshot.copy(layers = listOf(layer)) + + // When + val result = testedValidator.validate(snapshot) + + // Then + assertThat(result.codes()).contains(CaptureValidationErrorCode.INVALID_MODIFIER) + } + + @Test + fun `M report invalid modifier W validate { infinite gaussian blur radius }`() { + // Given + val tree = compositionTestTree() + val layer = tree.layer.copy( + modifiers = listOf(CapturedModifier.GaussianBlur(radius = Double.POSITIVE_INFINITY)) + ) + val snapshot = tree.snapshot.copy(layers = listOf(layer)) + + // When + val result = testedValidator.validate(snapshot) + + // Then + assertThat(result.codes()).contains(CaptureValidationErrorCode.INVALID_MODIFIER) + } + + @Test + fun `M report invalid modifier W validate { infinite saturate value }`() { + // Given + val tree = compositionTestTree() + val layer = tree.layer.copy( + modifiers = listOf(CapturedModifier.Saturate(value = Double.POSITIVE_INFINITY)) + ) + val snapshot = tree.snapshot.copy(layers = listOf(layer)) + + // When + val result = testedValidator.validate(snapshot) + + // Then + assertThat(result.codes()).contains(CaptureValidationErrorCode.INVALID_MODIFIER) + } + + @Test + fun `M report invalid style W validate { non-finite opacity }`() { + // Given + val tree = compositionTestTree() + val wireframe = (tree.wireframe as CapturedWireframe.Shape).copy( + style = CapturedShapeStyle(opacity = Double.NaN) + ) + val snapshot = tree.snapshot.copy(wireframes = listOf(wireframe)) + + // When + val result = testedValidator.validate(snapshot) + + // Then + assertThat(result.codes()).contains(CaptureValidationErrorCode.INVALID_STYLE) + } + + @Test + fun `M report invalid style W validate { out of range opacity }`() { + // Given + val tree = compositionTestTree() + val wireframe = (tree.wireframe as CapturedWireframe.Shape).copy( + style = CapturedShapeStyle(opacity = 1.5) + ) + val snapshot = tree.snapshot.copy(wireframes = listOf(wireframe)) + + // When + val result = testedValidator.validate(snapshot) + + // Then + assertThat(result.codes()).contains(CaptureValidationErrorCode.INVALID_STYLE) + } + + @Test + fun `M report invalid style W validate { infinite corner radius }`() { + // Given + val tree = compositionTestTree() + val wireframe = (tree.wireframe as CapturedWireframe.Shape).copy( + style = CapturedShapeStyle(cornerRadius = Double.POSITIVE_INFINITY) + ) + val snapshot = tree.snapshot.copy(wireframes = listOf(wireframe)) + + // When + val result = testedValidator.validate(snapshot) + + // Then + assertThat(result.codes()).contains(CaptureValidationErrorCode.INVALID_STYLE) + } + + @Test + fun `M report invalid style W validate { malformed background color }`() { + // Given + val tree = compositionTestTree() + val wireframe = (tree.wireframe as CapturedWireframe.Shape).copy( + style = CapturedShapeStyle(backgroundColor = "red") + ) + val snapshot = tree.snapshot.copy(wireframes = listOf(wireframe)) + + // When + val result = testedValidator.validate(snapshot) + + // Then + assertThat(result.codes()).contains(CaptureValidationErrorCode.INVALID_STYLE) + } + + @Test + fun `M report invalid style W validate { malformed border color }`() { + // Given + val tree = compositionTestTree() + val wireframe = (tree.wireframe as CapturedWireframe.Shape).copy( + border = CapturedShapeBorder(color = "red", width = 1) + ) + val snapshot = tree.snapshot.copy(wireframes = listOf(wireframe)) + + // When + val result = testedValidator.validate(snapshot) + + // Then + assertThat(result.codes()).contains(CaptureValidationErrorCode.INVALID_STYLE) + } + + @Test + fun `M report invalid style W validate { malformed text style color }`() { + // Given + val tree = compositionTestTree() + val textIdentity = tree.factory.textWireframe(tree.layer.identity) + val wireframe = CapturedWireframe.Text( + identity = textIdentity, + bounds = tree.wireframe.bounds, + text = "hello", + textStyle = CapturedTextStyle(family = "Roboto", size = 12, color = "red") + ) + val layer = tree.layer.copy(children = listOf(CapturedChild.Wireframe(textIdentity))) + val snapshot = tree.snapshot.copy(layers = listOf(layer), wireframes = listOf(wireframe)) + + // When + val result = testedValidator.validate(snapshot) + + // Then + assertThat(result.codes()).contains(CaptureValidationErrorCode.INVALID_STYLE) + } + + @Test + fun `M return valid W validate { multiple windows below synthetic root }`() { + // Given + val scope = RumViewIdentityScope("view") + val factory = DefaultCapturedIdentityFactory(scope) + val firstWindowIdentity = factory.window("first") + val secondWindowIdentity = factory.window("second") + val firstWireframeIdentity = factory.shapeWireframe(firstWindowIdentity) + val secondWireframeIdentity = factory.shapeWireframe(secondWindowIdentity) + val firstWindow = layer( + firstWindowIdentity, + CapturedLayerKind.WINDOW_ROOT, + listOf(CapturedChild.Wireframe(firstWireframeIdentity)) + ) + val secondWindow = layer( + secondWindowIdentity, + CapturedLayerKind.WINDOW_ROOT, + listOf(CapturedChild.Wireframe(secondWireframeIdentity)) + ) + val root = layer( + factory.screenRoot(), + CapturedLayerKind.SYNTHETIC_SCREEN_ROOT, + listOf( + CapturedChild.Layer(firstWindowIdentity), + CapturedChild.Layer(secondWindowIdentity) + ) + ) + val snapshot = CapturedFullSnapshot( + timestamp = 1, + scope = scope, + root = root, + layers = listOf(firstWindow, secondWindow), + wireframes = listOf( + semanticWireframe(firstWireframeIdentity), + semanticWireframe(secondWireframeIdentity) + ) + ) + + // When + val result = testedValidator.validate(snapshot) + + // Then + assertThat(result).isEqualTo(CaptureValidationResult.Valid) + assertThat(root.children.map { it.identity }) + .containsExactly(firstWindowIdentity, secondWindowIdentity) + } + + @Test + fun `M return valid W validate { mixed native compose and composition layers }`() { + // Given + val scope = RumViewIdentityScope("view") + val factory = DefaultCapturedIdentityFactory(scope) + val windowIdentity = factory.window("window") + val viewIdentity = factory.view(windowIdentity, "view") + val hostIdentity = factory.composeHost(windowIdentity, "host") + val nodeIdentity = factory.composeNode(hostIdentity, "node") + val compositionIdentity = factory.layer(nodeIdentity, "layer") + val wireframeIdentity = factory.shapeWireframe(compositionIdentity) + val layers = listOf( + layer(windowIdentity, CapturedLayerKind.WINDOW_ROOT, listOf(CapturedChild.Layer(viewIdentity))), + layer(viewIdentity, CapturedLayerKind.NATIVE_VIEW, listOf(CapturedChild.Layer(hostIdentity))), + layer(hostIdentity, CapturedLayerKind.COMPOSE_HOST, listOf(CapturedChild.Layer(nodeIdentity))), + layer(nodeIdentity, CapturedLayerKind.COMPOSE_NODE, listOf(CapturedChild.Layer(compositionIdentity))), + layer( + compositionIdentity, + CapturedLayerKind.COMPOSITION_LAYER, + listOf(CapturedChild.Wireframe(wireframeIdentity)) + ) + ) + val root = layer( + factory.screenRoot(), + CapturedLayerKind.SYNTHETIC_SCREEN_ROOT, + listOf(CapturedChild.Layer(windowIdentity)) + ) + + // When + val result = testedValidator.validate( + CapturedFullSnapshot( + timestamp = 1, + scope = scope, + root = root, + layers = layers, + wireframes = listOf(semanticWireframe(wireframeIdentity)) + ) + ) + + // Then + assertThat(result).isEqualTo(CaptureValidationResult.Valid) + } + + @Test + fun `M report missing root W validate`() { + // Given + val snapshot = compositionTestTree().snapshot.copy(root = null) + + // When + val result = testedValidator.validate(snapshot) + + // Then + assertThat(result.codes()).containsExactly(CaptureValidationErrorCode.MISSING_ROOT) + } + + @Test + fun `M report duplicate identity W validate`() { + // Given + val tree = compositionTestTree() + val snapshot = tree.snapshot.copy(layers = listOf(tree.layer, tree.layer)) + + // When + val result = testedValidator.validate(snapshot) + + // Then + assertThat(result.codes()).contains(CaptureValidationErrorCode.DUPLICATE_IDENTITY) + } + + @Test + fun `M report dangling references W validate`() { + // Given + val tree = compositionTestTree() + val missingLayer = tree.factory.layer(tree.window, "missing-layer") + val missingWireframe = tree.factory.textWireframe(tree.layer.identity) + val root = tree.root.copy(children = listOf(CapturedChild.Layer(missingLayer))) + val layer = tree.layer.copy(children = listOf(CapturedChild.Wireframe(missingWireframe))) + + // When + val result = testedValidator.validate(tree.snapshot.copy(root = root, layers = listOf(layer))) + + // Then + assertThat(result.codes()).contains( + CaptureValidationErrorCode.DANGLING_LAYER_REFERENCE, + CaptureValidationErrorCode.DANGLING_WIREFRAME_REFERENCE + ) + } + + @Test + fun `M report cycle W validate`() { + // Given + val tree = compositionTestTree() + val secondIdentity = tree.factory.layer(tree.window, "second-layer") + val first = tree.layer.copy(children = listOf(CapturedChild.Layer(secondIdentity))) + val second = layer( + secondIdentity, + CapturedLayerKind.COMPOSITION_LAYER, + listOf(CapturedChild.Layer(first.identity)) + ) + val snapshot = tree.snapshot.copy(layers = listOf(first, second), wireframes = emptyList()) + + // When + val result = testedValidator.validate(snapshot) + + // Then + assertThat(result.codes()).contains(CaptureValidationErrorCode.CYCLE) + } + + @Test + fun `M report cycle W validate { disconnected island not reachable from root }`() { + // Given + val tree = compositionTestTree() + val aIdentity = tree.factory.layer(tree.window, "island-a") + val bIdentity = tree.factory.layer(tree.window, "island-b") + val a = layer(aIdentity, CapturedLayerKind.COMPOSITION_LAYER, listOf(CapturedChild.Layer(bIdentity))) + val b = layer(bIdentity, CapturedLayerKind.COMPOSITION_LAYER, listOf(CapturedChild.Layer(aIdentity))) + val snapshot = tree.snapshot.copy(layers = listOf(tree.layer, a, b)) + + // When + val result = testedValidator.validate(snapshot) + + // Then + assertThat(result.codes()).contains(CaptureValidationErrorCode.CYCLE) + } + + @Test + fun `M report multiple parents W validate`() { + // Given + val tree = compositionTestTree() + val secondIdentity = tree.factory.layer(tree.window, "second-layer") + val second = layer( + secondIdentity, + CapturedLayerKind.COMPOSITION_LAYER, + listOf(CapturedChild.Wireframe(tree.wireframeIdentity)) + ) + val root = tree.root.copy( + children = listOf( + CapturedChild.Layer(tree.layer.identity), + CapturedChild.Layer(secondIdentity) + ) + ) + + // When + val result = testedValidator.validate( + tree.snapshot.copy(root = root, layers = listOf(tree.layer, second)) + ) + + // Then + assertThat(result.codes()).contains(CaptureValidationErrorCode.MULTIPLE_PARENTS) + } + + @Test + fun `M report unresolved resource W validate { pixel wireframe }`() { + // Given + val tree = compositionTestTree() + val pixelIdentity = tree.factory.imageWireframe(tree.layer.identity) + val pixel = CapturedWireframe.Pixel( + identity = pixelIdentity, + bounds = CapturedBounds(1, 2, 3, 4), + resource = PixelResource.Unresolved + ) + val layer = tree.layer.copy( + children = listOf(CapturedChild.Wireframe(pixelIdentity)) + ) + + // When + val result = testedValidator.validate( + tree.snapshot.copy(layers = listOf(layer), wireframes = listOf(pixel)) + ) + + // Then + assertThat(result.codes()).contains(CaptureValidationErrorCode.UNRESOLVED_PIXEL_RESOURCE) + } + + @Test + fun `M report identity kind mismatch W validate { wrong wireframe namespace }`() { + // Given + val tree = compositionTestTree() + val pixel = CapturedWireframe.Pixel( + identity = tree.wireframeIdentity, + bounds = CapturedBounds(1, 2, 3, 4), + resource = PixelResource.Resolved("resource") + ) + + // When + val result = testedValidator.validate(tree.snapshot.copy(wireframes = listOf(pixel))) + + // Then + assertThat(result.codes()).containsExactly(CaptureValidationErrorCode.IDENTITY_KIND_MISMATCH) + } + + @Test + fun `M return valid W validate { resolved pixel and privacy placeholder }`() { + // Given + val tree = compositionTestTree() + val pixelIdentity = tree.factory.imageWireframe(tree.layer.identity) + val placeholderIdentity = tree.factory.placeholderWireframe(tree.layer.identity) + val layer = tree.layer.copy( + children = listOf( + CapturedChild.Wireframe(pixelIdentity), + CapturedChild.Wireframe(placeholderIdentity) + ) + ) + val snapshot = tree.snapshot.copy( + layers = listOf(layer), + wireframes = listOf( + CapturedWireframe.Pixel( + identity = pixelIdentity, + bounds = CapturedBounds(0, 0, 5, 5), + resource = PixelResource.Resolved("resource", "image/webp") + ), + CapturedWireframe.PrivacyPlaceholder( + identity = placeholderIdentity, + bounds = CapturedBounds(5, 0, 5, 5), + label = "Image" + ) + ) + ) + + // When + val result = testedValidator.validate(snapshot) + + // Then + assertThat(result).isEqualTo(CaptureValidationResult.Valid) + } + + @Test + fun `M return valid W validate { unreferenced hidden slot wireframes }`( + @IntForgery fakeSlotId: Int + ) { + // Given + val tree = compositionTestTree() + val webViewIdentity = tree.factory.webViewWireframe(tree.layer.identity, fakeSlotId.toLong()) + val hiddenWebView = CapturedWireframe.WebView( + identity = webViewIdentity, + bounds = CapturedBounds(0, 0, 0, 0), + isVisible = false + ) + + // When + val result = testedValidator.validate( + tree.snapshot.copy( + wireframes = listOf(tree.wireframe, hiddenWebView) + ) + ) + + // Then + assertThat(result).isEqualTo(CaptureValidationResult.Valid) + } + + @Test + fun `M report unreferenced wireframe W validate { visible slot wireframe }`( + @IntForgery fakeSlotId: Int + ) { + // Given + val tree = compositionTestTree() + val identity = tree.factory.webViewWireframe(tree.layer.identity, fakeSlotId.toLong()) + val visibleWebView = CapturedWireframe.WebView( + identity = identity, + bounds = CapturedBounds(0, 0, 10, 10), + isVisible = true + ) + + // When + val result = testedValidator.validate( + tree.snapshot.copy(wireframes = listOf(tree.wireframe, visibleWebView)) + ) + + // Then + assertThat(result.codes()).contains(CaptureValidationErrorCode.UNREFERENCED_WIREFRAME) + } + + @Test + fun `M report mismatched reference W validate { same wire ID from another scope }`() { + // Given + val tree = compositionTestTree() + val wrongIdentity = tree.wireframeIdentity.copy(scope = RumViewIdentityScope("other-view")) + val layer = tree.layer.copy(children = listOf(CapturedChild.Wireframe(wrongIdentity))) + + // When + val result = testedValidator.validate(tree.snapshot.copy(layers = listOf(layer))) + + // Then + assertThat(result.codes()).contains( + CaptureValidationErrorCode.WRONG_IDENTITY_SCOPE, + CaptureValidationErrorCode.REFERENCE_IDENTITY_MISMATCH + ) + } + + @Test + fun `M report identity kind mismatch W validate { layer kind does not match identity }`() { + // Given + val tree = compositionTestTree() + val layer = tree.layer.copy(kind = CapturedLayerKind.NATIVE_VIEW) + + // When + val result = testedValidator.validate(tree.snapshot.copy(layers = listOf(layer))) + + // Then + assertThat(result.codes()).contains(CaptureValidationErrorCode.IDENTITY_KIND_MISMATCH) + } + + @Test + fun `M report wrong scope W validate { layer belongs to another RUM view }`() { + // Given + val tree = compositionTestTree() + val otherFactory = DefaultCapturedIdentityFactory(RumViewIdentityScope("other-view")) + val otherLayerIdentity = otherFactory.layer(otherFactory.window("window"), "layer") + val otherLayer = layer(otherLayerIdentity, CapturedLayerKind.COMPOSITION_LAYER, emptyList()) + val root = tree.root.copy(children = listOf(CapturedChild.Layer(otherLayerIdentity))) + + // When + val result = testedValidator.validate( + tree.snapshot.copy(root = root, layers = listOf(otherLayer), wireframes = emptyList()) + ) + + // Then + assertThat(result.codes()).contains(CaptureValidationErrorCode.WRONG_IDENTITY_SCOPE) + } + + @Test + fun `M return valid W validate mutation { sparse bounds update }`() { + // Given + val tree = compositionTestTree() + val mutation = CapturedMutationSet( + timestamp = 456, + scope = tree.scope, + updates = CapturedChange.Set( + listOf(CapturedLayerUpdate(identity = tree.layer.identity, x = CapturedChange.Set(42))) + ) + ) + + // When + val result = testedValidator.validate(mutation, tree.snapshot) + + // Then + assertThat(result).isEqualTo(CaptureValidationResult.Valid) + } + + @Test + fun `M return valid W validate mutation { wireframe child removed separately }`() { + // Given + val tree = compositionTestTree() + val mutation = CapturedMutationSet( + timestamp = 456, + scope = tree.scope, + updates = CapturedChange.Set( + listOf( + CapturedLayerUpdate( + identity = tree.layer.identity, + children = CapturedChange.Set(emptyList()) + ) + ) + ) + ) + + // When + val result = testedValidator.validate(mutation, tree.snapshot) + + // Then + assertThat(result).isEqualTo(CaptureValidationResult.Valid) + } + + @Test + fun `M return valid W validate mutation { wireframe child added separately }`() { + // Given + val tree = compositionTestTree() + val addedWireframe = tree.factory.textWireframe(tree.layer.identity) + val mutation = CapturedMutationSet( + timestamp = 456, + scope = tree.scope, + updates = CapturedChange.Set( + listOf( + CapturedLayerUpdate( + identity = tree.layer.identity, + children = CapturedChange.Set( + listOf(CapturedChild.Wireframe(addedWireframe)) + ) + ) + ) + ) + ) + + // When + val result = testedValidator.validate(mutation, tree.snapshot) + + // Then + assertThat(result).isEqualTo(CaptureValidationResult.Valid) + } + + @Test + fun `M report wrong scope W validate mutation { wireframe child added separately }`() { + // Given + val tree = compositionTestTree() + val wrongIdentity = tree.factory.textWireframe(tree.layer.identity) + .copy(scope = RumViewIdentityScope("other-view")) + val mutation = CapturedMutationSet( + timestamp = 456, + scope = tree.scope, + updates = CapturedChange.Set( + listOf( + CapturedLayerUpdate( + identity = tree.layer.identity, + children = CapturedChange.Set( + listOf(CapturedChild.Wireframe(wrongIdentity)) + ) + ) + ) + ) + ) + + // When + val result = testedValidator.validate(mutation, tree.snapshot) + + // Then + assertThat(result.codes()).contains(CaptureValidationErrorCode.WRONG_IDENTITY_SCOPE) + } + + @Test + fun `M report contradiction W validate mutation { same layer removed and updated }`() { + // Given + val tree = compositionTestTree() + val mutation = CapturedMutationSet( + timestamp = 456, + scope = tree.scope, + removes = CapturedChange.Set(listOf(tree.layer.identity)), + updates = CapturedChange.Set(listOf(CapturedLayerUpdate(tree.layer.identity))) + ) + + // When + val result = testedValidator.validate(mutation, tree.snapshot) + + // Then + assertThat(result.codes()).contains(CaptureValidationErrorCode.CONTRADICTORY_MUTATION) + } + + @Test + fun `M report contradiction W validate mutation { added layer collides with existing wireframe }`() { + // Given + val tree = compositionTestTree() + val addedLayer = CapturedLayer( + identity = tree.wireframeIdentity, + kind = CapturedLayerKind.COMPOSITION_LAYER, + bounds = CapturedBounds(0, 0, 1, 1), + children = emptyList() + ) + val mutation = CapturedMutationSet( + timestamp = 456, + scope = tree.scope, + adds = CapturedChange.Set(listOf(addedLayer)) + ) + + // When + val result = testedValidator.validate(mutation, tree.snapshot) + + // Then + assertThat(result.codes()).contains(CaptureValidationErrorCode.CONTRADICTORY_MUTATION) + } + + @Test + fun `M report multiple parents W validate mutation { added layer references existing wireframe }`() { + // Given + val tree = compositionTestTree() + val secondLayerIdentity = tree.factory.layer(tree.window, "second-layer") + val secondLayer = CapturedLayer( + identity = secondLayerIdentity, + kind = CapturedLayerKind.COMPOSITION_LAYER, + bounds = CapturedBounds(0, 0, 1, 1), + children = listOf(CapturedChild.Wireframe(tree.wireframeIdentity)) + ) + val mutation = CapturedMutationSet( + timestamp = 456, + scope = tree.scope, + adds = CapturedChange.Set(listOf(secondLayer)) + ) + + // When + val result = testedValidator.validate(mutation, tree.snapshot) + + // Then + assertThat(result.codes()).contains(CaptureValidationErrorCode.MULTIPLE_PARENTS) + } + + @Test + fun `M report multiple parents W validate mutation { two added layers reference same undelivered wireframe }`() { + // Given + val tree = compositionTestTree() + val undeliveredWireframeIdentity = tree.factory.textWireframe(tree.layer.identity) + val firstLayerIdentity = tree.factory.layer(tree.window, "first-layer") + val secondLayerIdentity = tree.factory.layer(tree.window, "second-layer") + val firstLayer = CapturedLayer( + identity = firstLayerIdentity, + kind = CapturedLayerKind.COMPOSITION_LAYER, + bounds = CapturedBounds(0, 0, 1, 1), + children = listOf(CapturedChild.Wireframe(undeliveredWireframeIdentity)) + ) + val secondLayer = CapturedLayer( + identity = secondLayerIdentity, + kind = CapturedLayerKind.COMPOSITION_LAYER, + bounds = CapturedBounds(0, 0, 1, 1), + children = listOf(CapturedChild.Wireframe(undeliveredWireframeIdentity)) + ) + val mutation = CapturedMutationSet( + timestamp = 456, + scope = tree.scope, + adds = CapturedChange.Set(listOf(firstLayer, secondLayer)) + ) + + // When + val result = testedValidator.validate(mutation, tree.snapshot) + + // Then + assertThat(result.codes()).contains(CaptureValidationErrorCode.MULTIPLE_PARENTS) + } + + @Test + fun `M report reference identity mismatch W validate mutation { known wireframe id under mismatched identity }`() { + // Given + val tree = compositionTestTree() + val collidingIdentity = tree.wireframeIdentity.copy(localId = "different-local-id") + val secondLayerIdentity = tree.factory.layer(tree.window, "second-layer") + val secondLayer = CapturedLayer( + identity = secondLayerIdentity, + kind = CapturedLayerKind.COMPOSITION_LAYER, + bounds = CapturedBounds(0, 0, 1, 1), + children = listOf(CapturedChild.Wireframe(collidingIdentity)) + ) + val mutation = CapturedMutationSet( + timestamp = 456, + scope = tree.scope, + adds = CapturedChange.Set(listOf(secondLayer)) + ) + + // When + val result = testedValidator.validate(mutation, tree.snapshot) + + // Then + assertThat(result.codes()).contains(CaptureValidationErrorCode.REFERENCE_IDENTITY_MISMATCH) + } + + @Test + fun `M report duplicate identity W validate mutation { undelivered wireframe id collides with existing layer }`() { + // Given + val tree = compositionTestTree() + val collidingIdentity = tree.factory.textWireframe(tree.layer.identity) + .copy(wireId = tree.layer.identity.wireId) + val secondLayerIdentity = tree.factory.layer(tree.window, "second-layer") + val secondLayer = CapturedLayer( + identity = secondLayerIdentity, + kind = CapturedLayerKind.COMPOSITION_LAYER, + bounds = CapturedBounds(0, 0, 1, 1), + children = listOf(CapturedChild.Wireframe(collidingIdentity)) + ) + val mutation = CapturedMutationSet( + timestamp = 456, + scope = tree.scope, + adds = CapturedChange.Set(listOf(secondLayer)) + ) + + // When + val result = testedValidator.validate(mutation, tree.snapshot) + + // Then + assertThat(result.codes()).contains(CaptureValidationErrorCode.DUPLICATE_IDENTITY) + } + + @Test + fun `M report duplicate identity W validate mutation { undelivered wireframe id collides with root }`() { + // Given + val tree = compositionTestTree() + val collidingIdentity = tree.factory.textWireframe(tree.layer.identity) + .copy(wireId = tree.root.identity.wireId) + val secondLayerIdentity = tree.factory.layer(tree.window, "second-layer") + val secondLayer = CapturedLayer( + identity = secondLayerIdentity, + kind = CapturedLayerKind.COMPOSITION_LAYER, + bounds = CapturedBounds(0, 0, 1, 1), + children = listOf(CapturedChild.Wireframe(collidingIdentity)) + ) + val mutation = CapturedMutationSet( + timestamp = 456, + scope = tree.scope, + adds = CapturedChange.Set(listOf(secondLayer)) + ) + + // When + val result = testedValidator.validate(mutation, tree.snapshot) + + // Then + assertThat(result.codes()).contains(CaptureValidationErrorCode.DUPLICATE_IDENTITY) + } + + @Test + fun `M report duplicate operations W validate mutation`() { + // Given + val tree = compositionTestTree() + val mutation = CapturedMutationSet( + timestamp = 456, + scope = tree.scope, + updates = CapturedChange.Set( + listOf( + CapturedLayerUpdate(tree.layer.identity), + CapturedLayerUpdate(tree.layer.identity) + ) + ) + ) + + // When + val result = testedValidator.validate(mutation, tree.snapshot) + + // Then + assertThat(result.codes()).contains(CaptureValidationErrorCode.DUPLICATE_MUTATION_OPERATION) + } + + @Test + fun `M report every pairwise contradiction W validate mutation { add remove and update }`() { + // Given + val tree = compositionTestTree() + val mutation = CapturedMutationSet( + timestamp = 456, + scope = tree.scope, + adds = CapturedChange.Set(listOf(tree.layer)), + removes = CapturedChange.Set(listOf(tree.layer.identity)), + updates = CapturedChange.Set(listOf(CapturedLayerUpdate(tree.layer.identity))) + ) + + // When + val result = testedValidator.validate(mutation, tree.snapshot) + + // Then + assertThat(result.codes().count { it == CaptureValidationErrorCode.CONTRADICTORY_MUTATION }) + .isGreaterThanOrEqualTo(3) + } + + @Test + fun `M report mismatched target W validate mutation { same wire ID from another scope }`() { + // Given + val tree = compositionTestTree() + val wrongIdentity = tree.layer.identity.copy(scope = RumViewIdentityScope("other-view")) + val mutation = CapturedMutationSet( + timestamp = 456, + scope = tree.scope, + updates = CapturedChange.Set(listOf(CapturedLayerUpdate(wrongIdentity))) + ) + + // When + val result = testedValidator.validate(mutation, tree.snapshot) + + // Then + assertThat(result.codes()).contains( + CaptureValidationErrorCode.WRONG_IDENTITY_SCOPE, + CaptureValidationErrorCode.REFERENCE_IDENTITY_MISMATCH + ) + } + + @Test + fun `M return valid W validate mutation { root replacement }`() { + // Given + val tree = compositionTestTree() + val replacement = tree.root.copy(bounds = CapturedBounds(0, 0, 200, 400)) + val mutation = CapturedMutationSet( + timestamp = 456, + scope = tree.scope, + root = CapturedChange.Set(replacement) + ) + + // When + val result = testedValidator.validate(mutation, tree.snapshot) + + // Then + assertThat(result).isEqualTo(CaptureValidationResult.Valid) + } + + @Test + fun `M report invalid root replacement W validate mutation`() { + // Given + val tree = compositionTestTree() + val mutation = CapturedMutationSet( + timestamp = 456, + scope = tree.scope, + root = CapturedChange.Set(tree.layer) + ) + + // When + val result = testedValidator.validate(mutation, tree.snapshot) + + // Then + assertThat(result.codes()).contains(CaptureValidationErrorCode.INVALID_ROOT_REPLACEMENT) + } + + @Test + fun `M report invalid root replacement W validate mutation { different identity from base root }`() { + // Given + val tree = compositionTestTree() + val replacement = tree.root.copy( + identity = tree.root.identity.copy(wireId = tree.root.identity.wireId + 1) + ) + val mutation = CapturedMutationSet( + timestamp = 456, + scope = tree.scope, + root = CapturedChange.Set(replacement) + ) + + // When + val result = testedValidator.validate(mutation, tree.snapshot) + + // Then + assertThat(result.codes()).contains(CaptureValidationErrorCode.INVALID_ROOT_REPLACEMENT) + } + + @Test + fun `M report invalid root replacement W validate mutation { new root collides with existing wireframe }`() { + // Given + val tree = compositionTestTree() + val baseWithoutRoot = tree.snapshot.copy(root = null) + val collidingRoot = tree.root.copy( + identity = tree.root.identity.copy(wireId = tree.wireframeIdentity.wireId) + ) + val mutation = CapturedMutationSet( + timestamp = 456, + scope = tree.scope, + root = CapturedChange.Set(collidingRoot) + ) + + // When + val result = testedValidator.validate(mutation, baseWithoutRoot) + + // Then + assertThat(result.codes()).contains(CaptureValidationErrorCode.INVALID_ROOT_REPLACEMENT) + } + + private fun layer( + identity: CapturedIdentity, + kind: CapturedLayerKind, + children: List + ) = CapturedLayer( + identity = identity, + kind = kind, + bounds = CapturedBounds(0, 0, 10, 10), + children = children + ) + + private fun semanticWireframe(identity: CapturedIdentity) = CapturedWireframe.Shape( + identity = identity, + bounds = CapturedBounds(0, 0, 10, 10) + ) + + private fun CaptureValidationResult.codes(): List = + (this as CaptureValidationResult.Invalid).failures.map { it.code } +} diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/CapturedTreeWireMapperTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/CapturedTreeWireMapperTest.kt new file mode 100644 index 0000000000..1eb0877ff6 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/CapturedTreeWireMapperTest.kt @@ -0,0 +1,374 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.composition + +import com.datadog.android.sessionreplay.model.MobileSegment +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +internal class CapturedTreeWireMapperTest { + + private val testedMapper = DefaultCapturedTreeWireMapper() + + @Test + fun `M preserve wireframes and tree W mapFullSnapshot`() { + // Given + val tree = compositionTestTree() + + // When + val result = testedMapper.mapFullSnapshot(tree.snapshot) + + // Then + val record = (result as CaptureWireMappingResult.Success).value + assertThat(record.timestamp).isEqualTo(tree.snapshot.timestamp) + val bounds = (tree.wireframe as CapturedWireframe.Shape).bounds + assertThat(record.data.wireframes.single()).isEqualTo( + MobileSegment.Wireframe.ShapeWireframe( + id = tree.wireframeIdentity.wireId, + x = bounds.x, + y = bounds.y, + width = bounds.width, + height = bounds.height + ) + ) + assertThat(record.data.compositionTree?.root?.id).isEqualTo(tree.root.identity.wireId) + assertThat(record.data.compositionTree?.layers?.map { it.id }) + .containsExactly(tree.layer.identity.wireId) + assertThat(record.toJson().asJsonObject.getAsJsonObject("data").has("compositionTree")).isTrue + } + + @Test + fun `M preserve modifier ordering W mapFullSnapshot`() { + // Given + val tree = compositionTestTree() + val modifiers = listOf( + CapturedModifier.Clip("M0 0", CapturedFillRule.EVEN_ODD), + CapturedModifier.Opacity(0.5), + CapturedModifier.ColorMatrix(List(20) { it.toDouble() }), + CapturedModifier.GaussianBlur(2.0), + CapturedModifier.Shadow("#AABBCC", 1.0, 2.0, 3.0), + CapturedModifier.BrightnessBias(0.25), + CapturedModifier.Saturate(1.5), + CapturedModifier.MaskImage("resource") + ) + val layer = tree.layer.copy( + modifiers = modifiers, + compositeOperation = CapturedCompositeOperation.DESTINATION_IN + ) + + // When + val result = testedMapper.mapFullSnapshot(tree.snapshot.copy(layers = listOf(layer))) + + // Then + val record = (result as CaptureWireMappingResult.Success).value + val wireLayer = record.data.compositionTree?.layers?.single() + assertThat(wireLayer?.modifiers).containsExactly( + MobileSegment.CompositionLayerModifier.CompositionLayerClipModifier( + path = "M0 0", + fillRule = MobileSegment.FillRule.EVENODD + ), + MobileSegment.CompositionLayerModifier.CompositionLayerOpacityModifier(0.5), + MobileSegment.CompositionLayerModifier.CompositionLayerColorMatrixModifier( + List(20) { it.toDouble() } + ), + MobileSegment.CompositionLayerModifier.CompositionLayerGaussianBlurModifier(2.0), + MobileSegment.CompositionLayerModifier.CompositionLayerShadowModifier( + color = "#AABBCC", + offsetX = 1.0, + offsetY = 2.0, + radius = 3.0 + ), + MobileSegment.CompositionLayerModifier.CompositionLayerBrightnessBiasModifier(0.25), + MobileSegment.CompositionLayerModifier.CompositionLayerSaturateModifier(1.5), + MobileSegment.CompositionLayerModifier.CompositionLayerMaskImageModifier("resource") + ) + assertThat(wireLayer?.compositeOperation).isEqualTo(MobileSegment.CompositeOperation.DESTINATIONIN) + } + + @Test + fun `M preserve ordered children W mapMutation`() { + // Given + val tree = compositionTestTree() + val secondIdentity = tree.factory.placeholderWireframe(tree.layer.identity) + val secondWireframe = CapturedWireframe.PrivacyPlaceholder( + identity = secondIdentity, + bounds = CapturedBounds(5, 6, 7, 8) + ) + val baseLayer = tree.layer.copy( + children = listOf( + CapturedChild.Wireframe(tree.wireframeIdentity), + CapturedChild.Wireframe(secondIdentity) + ) + ) + val base = tree.snapshot.copy( + layers = listOf(baseLayer), + wireframes = listOf(tree.wireframe, secondWireframe) + ) + val mutation = CapturedMutationSet( + timestamp = 456, + scope = tree.scope, + updates = CapturedChange.Set( + listOf( + CapturedLayerUpdate( + identity = tree.layer.identity, + children = CapturedChange.Set( + listOf( + CapturedChild.Wireframe(secondIdentity), + CapturedChild.Wireframe(tree.wireframeIdentity) + ) + ), + modifiers = CapturedChange.Set(emptyList()) + ) + ) + ) + ) + + // When + val result = testedMapper.mapMutation(mutation, base) + + // Then + val record = (result as CaptureWireMappingResult.Success).value + val data = record.data as MobileSegment.MobileIncrementalData.CompositionTreeMutationData + val update = data.updates?.single() + assertThat(update?.children?.map { it.id }) + .containsExactly(secondIdentity.wireId, tree.wireframeIdentity.wireId) + assertThat(update?.modifiers).isEmpty() + assertThat(update?.x).isNull() + assertThat(data.adds).isNull() + assertThat(data.removes).isNull() + } + + @Test + fun `M preserve absent and explicit empty fields W mapMutation`() { + // Given + val tree = compositionTestTree() + val mutation = CapturedMutationSet( + timestamp = 456, + scope = tree.scope, + adds = CapturedChange.Set(emptyList()), + removes = CapturedChange.Set(emptyList()) + ) + + // When + val result = testedMapper.mapMutation(mutation, tree.snapshot) + + // Then + val record = (result as CaptureWireMappingResult.Success).value + val data = record.data as MobileSegment.MobileIncrementalData.CompositionTreeMutationData + assertThat(data.adds).isEmpty() + assertThat(data.removes).isEmpty() + assertThat(data.updates).isNull() + assertThat(data.root).isNull() + } + + @Test + fun `M map resolved pixels and privacy placeholders W mapFullSnapshot`() { + // Given + val tree = compositionTestTree() + val pixelIdentity = tree.factory.imageWireframe(tree.layer.identity) + val placeholderIdentity = tree.factory.placeholderWireframe(tree.layer.identity) + val layer = tree.layer.copy( + children = listOf( + CapturedChild.Wireframe(pixelIdentity), + CapturedChild.Wireframe(placeholderIdentity) + ) + ) + val pixelBounds = CapturedBounds(1, 2, 3, 4) + val placeholderBounds = CapturedBounds(5, 6, 7, 8) + val snapshot = tree.snapshot.copy( + layers = listOf(layer), + wireframes = listOf( + CapturedWireframe.Pixel( + identity = pixelIdentity, + bounds = pixelBounds, + resource = PixelResource.Resolved("resource", "image/webp") + ), + CapturedWireframe.PrivacyPlaceholder( + identity = placeholderIdentity, + bounds = placeholderBounds, + label = "Image" + ) + ) + ) + + // When + val result = testedMapper.mapFullSnapshot(snapshot) + + // Then + val wireframes = (result as CaptureWireMappingResult.Success).value.data.wireframes + assertThat(wireframes).containsExactly( + MobileSegment.Wireframe.ImageWireframe( + id = pixelIdentity.wireId, + x = pixelBounds.x, + y = pixelBounds.y, + width = pixelBounds.width, + height = pixelBounds.height, + resourceId = "resource", + mimeType = "image/webp" + ), + MobileSegment.Wireframe.PlaceholderWireframe( + id = placeholderIdentity.wireId, + x = placeholderBounds.x, + y = placeholderBounds.y, + width = placeholderBounds.width, + height = placeholderBounds.height, + label = "Image" + ) + ) + } + + @Test + fun `M map cleared composite operation to source over W mapMutation`() { + // Given + val tree = compositionTestTree() + val base = tree.snapshot.copy( + layers = listOf( + tree.layer.copy(compositeOperation = CapturedCompositeOperation.DESTINATION_IN) + ) + ) + val mutation = CapturedMutationSet( + timestamp = 456, + scope = tree.scope, + updates = CapturedChange.Set( + listOf( + CapturedLayerUpdate( + identity = tree.layer.identity, + compositeOperation = CapturedChange.Set(null) + ) + ) + ) + ) + + // When + val result = testedMapper.mapMutation(mutation, base) + + // Then + val record = (result as CaptureWireMappingResult.Success).value + val data = record.data as MobileSegment.MobileIncrementalData.CompositionTreeMutationData + assertThat(data.updates?.single()?.compositeOperation) + .isEqualTo(MobileSegment.CompositeOperation.SOURCEOVER) + } + + @Test + fun `M map complete added layers W mapMutation`() { + // Given + val tree = compositionTestTree() + val addedIdentity = tree.factory.layer(tree.window, "added") + val addedLayer = tree.layer.copy(identity = addedIdentity) + val replacementRoot = tree.root.copy( + children = listOf( + CapturedChild.Layer(tree.layer.identity), + CapturedChild.Layer(addedIdentity) + ) + ) + val mutation = CapturedMutationSet( + timestamp = 456, + scope = tree.scope, + root = CapturedChange.Set(replacementRoot), + adds = CapturedChange.Set(listOf(addedLayer)), + updates = CapturedChange.Set( + listOf( + CapturedLayerUpdate( + identity = tree.layer.identity, + children = CapturedChange.Set(emptyList()) + ) + ) + ) + ) + + // When + val result = testedMapper.mapMutation(mutation, tree.snapshot) + + // Then + val record = (result as CaptureWireMappingResult.Success).value + val data = record.data as MobileSegment.MobileIncrementalData.CompositionTreeMutationData + assertThat(data.adds?.single()?.id).isEqualTo(addedIdentity.wireId) + assertThat(data.adds?.single()?.children?.single()?.id) + .isEqualTo(tree.wireframeIdentity.wireId) + } + + @Test + fun `M map removed layer identities W mapMutation`() { + // Given + val tree = compositionTestTree() + val removedIdentity = tree.factory.layer(tree.window, "removed") + val removedLayer = tree.layer.copy(identity = removedIdentity, children = emptyList()) + val base = tree.snapshot.copy( + root = tree.root.copy( + children = listOf( + CapturedChild.Layer(tree.layer.identity), + CapturedChild.Layer(removedIdentity) + ) + ), + layers = listOf(tree.layer, removedLayer) + ) + val mutation = CapturedMutationSet( + timestamp = 456, + scope = tree.scope, + root = CapturedChange.Set(tree.root), + removes = CapturedChange.Set(listOf(removedIdentity)) + ) + + // When + val result = testedMapper.mapMutation(mutation, base) + + // Then + val record = (result as CaptureWireMappingResult.Success).value + val data = record.data as MobileSegment.MobileIncrementalData.CompositionTreeMutationData + assertThat(data.removes).containsExactly(removedIdentity.wireId) + } + + @Test + fun `M return structured failures W mapFullSnapshot { invalid snapshot }`() { + // Given + val snapshot = compositionTestTree().snapshot.copy(root = null) + + // When + val result = testedMapper.mapFullSnapshot(snapshot) + + // Then + assertThat(result).isInstanceOf(CaptureWireMappingResult.Invalid::class.java) + assertThat((result as CaptureWireMappingResult.Invalid).failures.map { it.code }) + .containsExactly(CaptureValidationErrorCode.MISSING_ROOT) + } + + @Test + fun `M return structured failure W mapFullSnapshot { validator accepts unresolved pixel }`() { + // Given + val tree = compositionTestTree() + val unresolvedPixel = CapturedWireframe.Pixel( + identity = tree.wireframeIdentity, + bounds = CapturedBounds(1, 2, 3, 4), + resource = PixelResource.Unresolved + ) + val mapper = DefaultCapturedTreeWireMapper( + validator = object : CapturedTreeValidator { + override fun validate(snapshot: CapturedFullSnapshot) = CaptureValidationResult.Valid + + override fun validate( + mutation: CapturedMutationSet, + base: CapturedFullSnapshot + ) = CaptureValidationResult.Valid + } + ) + + // When + val result = mapper.mapFullSnapshot(tree.snapshot.copy(wireframes = listOf(unresolvedPixel))) + + // Then + assertThat(result).isEqualTo( + CaptureWireMappingResult.Invalid( + listOf( + CaptureValidationFailure( + CaptureValidationErrorCode.UNRESOLVED_PIXEL_RESOURCE, + tree.wireframeIdentity + ) + ) + ) + ) + } +} diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/CompositionCapturePipelineTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/CompositionCapturePipelineTest.kt new file mode 100644 index 0000000000..d3f86cba3d --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/CompositionCapturePipelineTest.kt @@ -0,0 +1,135 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.composition + +import com.datadog.android.sessionreplay.internal.recorder.Recorder +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify + +internal class CompositionCapturePipelineTest { + + @Test + fun `M select only legacy pipeline W create { flag disabled }`() { + // Given + val legacy = mock() + var legacyConstructions = 0 + var compositionConstructions = 0 + val selector = CapturePipelineSelector( + compositionEnabled = false, + legacyFactory = { + legacyConstructions++ + legacy + }, + compositionFactory = { + compositionConstructions++ + mock() + } + ) + + // When + val result = selector.create() + + // Then + assertThat(result).isSameAs(legacy) + assertThat(legacyConstructions).isEqualTo(1) + assertThat(compositionConstructions).isZero() + } + + @Test + fun `M select only composition pipeline W create { flag enabled }`() { + // Given + val composition = mock() + var legacyConstructions = 0 + var compositionConstructions = 0 + val selector = CapturePipelineSelector( + compositionEnabled = true, + legacyFactory = { + legacyConstructions++ + mock() + }, + compositionFactory = { + compositionConstructions++ + composition + } + ) + + // When + val result = selector.create() + + // Then + assertThat(result).isSameAs(composition) + assertThat(legacyConstructions).isZero() + assertThat(compositionConstructions).isEqualTo(1) + } + + @Test + fun `M keep recording cycle on selected pipeline W invoke recorder lifecycle`() { + // Given + val legacy = mock() + var compositionConstructions = 0 + val recorder = CapturePipelineSelector( + compositionEnabled = false, + legacyFactory = { legacy }, + compositionFactory = { + compositionConstructions++ + mock() + } + ).create() + + // When + recorder.registerCallbacks() + recorder.stopProcessingRecords() + recorder.resumeRecorders() + recorder.stopRecorders() + recorder.unregisterCallbacks() + + // Then + verify(legacy).registerCallbacks() + verify(legacy).stopProcessingRecords() + verify(legacy).resumeRecorders() + verify(legacy).stopRecorders() + verify(legacy).unregisterCallbacks() + assertThat(compositionConstructions).isZero() + } + + @Test + fun `M keep composition state isolated W create multiple pipelines`() { + // Given + val selector = CapturePipelineSelector( + compositionEnabled = true, + legacyFactory = { mock() }, + compositionFactory = { StatefulRecorder() } + ) + val first = selector.create() as StatefulRecorder + val second = selector.create() as StatefulRecorder + + // When + first.registerCallbacks() + + // Then + assertThat(first.callbacksRegistered).isTrue() + assertThat(second.callbacksRegistered).isFalse() + } + + private class StatefulRecorder : Recorder { + var callbacksRegistered = false + + override fun registerCallbacks() { + callbacksRegistered = true + } + + override fun unregisterCallbacks() = Unit + + override fun stopProcessingRecords() = Unit + + override fun resumeRecorders() = Unit + + override fun stopRecorders() = Unit + } +} diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/CompositionTestTree.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/CompositionTestTree.kt new file mode 100644 index 0000000000..533f78da90 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/composition/CompositionTestTree.kt @@ -0,0 +1,59 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.composition + +internal data class CompositionTestTree( + val scope: RumViewIdentityScope, + val factory: DefaultCapturedIdentityFactory, + val window: CapturedIdentity, + val root: CapturedLayer, + val layer: CapturedLayer, + val wireframeIdentity: CapturedIdentity, + val wireframe: CapturedWireframe, + val snapshot: CapturedFullSnapshot +) + +internal fun compositionTestTree(scopeValue: String = "rum-view"): CompositionTestTree { + val scope = RumViewIdentityScope(scopeValue) + val factory = DefaultCapturedIdentityFactory(scope) + val rootIdentity = factory.screenRoot() + val window = factory.window("window") + val layerIdentity = factory.layer(window, "layer") + val wireframeIdentity = factory.shapeWireframe(layerIdentity) + val wireframe = CapturedWireframe.Shape( + identity = wireframeIdentity, + bounds = CapturedBounds(1, 2, 3, 4) + ) + val layer = CapturedLayer( + identity = layerIdentity, + kind = CapturedLayerKind.COMPOSITION_LAYER, + bounds = CapturedBounds(1, 2, 3, 4), + children = listOf(CapturedChild.Wireframe(wireframeIdentity)) + ) + val root = CapturedLayer( + identity = rootIdentity, + kind = CapturedLayerKind.SYNTHETIC_SCREEN_ROOT, + bounds = CapturedBounds(0, 0, 100, 200), + children = listOf(CapturedChild.Layer(layerIdentity)) + ) + return CompositionTestTree( + scope = scope, + factory = factory, + window = window, + root = root, + layer = layer, + wireframeIdentity = wireframeIdentity, + wireframe = wireframe, + snapshot = CapturedFullSnapshot( + timestamp = 123, + scope = scope, + root = root, + layers = listOf(layer), + wireframes = listOf(wireframe) + ) + ) +} 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..a125cf58ab 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 @@ -168,8 +168,11 @@ internal class MutationResolverTest { @Test fun `M identify the removed wireframes W resolveMutations`(forge: Forge) { // Given + // WebviewWireframe is excluded: a removed WebviewWireframe is resolved as a + // visibility update rather than a Remove (see the dedicated webview tests below), + // which this test isn't exercising. val fakePrevSnapshot = forge.aList(size = forge.anInt(min = 2, max = 10)) { - forge.getForgery(MobileSegment.Wireframe::class.java) + forge.nonWebViewWireframe() } val fakeRemovedSize = forge.anInt(min = 1, max = fakePrevSnapshot.size) val fakeCurrentSnapshot = fakePrevSnapshot.drop(fakeRemovedSize) @@ -780,6 +783,14 @@ internal class MutationResolverTest { } } + private fun Forge.nonWebViewWireframe(): MobileSegment.Wireframe { + var wireframe: MobileSegment.Wireframe + do { + wireframe = getForgery(MobileSegment.Wireframe::class.java) + } while (wireframe is MobileSegment.Wireframe.WebviewWireframe) + return wireframe + } + // endregion data class MutationTestData( diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/SwitchCompatMapperTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/SwitchCompatMapperTest.kt index b93ef068a9..b2ac15fb8a 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/SwitchCompatMapperTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/SwitchCompatMapperTest.kt @@ -70,7 +70,7 @@ internal class SwitchCompatMapperTest : BaseSwitchCompatMapperTest() { border = null, shapeStyle = MobileSegment.ShapeStyle( backgroundColor = fakeCurrentTextColorString, - mockSwitch.alpha + opacity = mockSwitch.alpha ) ) @@ -143,7 +143,7 @@ internal class SwitchCompatMapperTest : BaseSwitchCompatMapperTest() { border = null, shapeStyle = MobileSegment.ShapeStyle( backgroundColor = fakeCurrentTextColorString, - mockSwitch.alpha + opacity = mockSwitch.alpha ) )