diff --git a/build-logic/src/main/kotlin/com/datadog/gradle/plugin/jsonschema/GenerateJsonSchemaTask.kt b/build-logic/src/main/kotlin/com/datadog/gradle/plugin/jsonschema/GenerateJsonSchemaTask.kt index a3c75a47f5..b20131ce6b 100644 --- a/build-logic/src/main/kotlin/com/datadog/gradle/plugin/jsonschema/GenerateJsonSchemaTask.kt +++ b/build-logic/src/main/kotlin/com/datadog/gradle/plugin/jsonschema/GenerateJsonSchemaTask.kt @@ -42,6 +42,7 @@ abstract class GenerateJsonSchemaTask @Inject constructor( init { group = "datadog" description = "Read source JSON schema files and generate the relevant Kotlin data classes" + internalModels.convention(false) } // region Input/Output @@ -84,6 +85,10 @@ abstract class GenerateJsonSchemaTask @Inject constructor( @get:Input abstract val targetPackageName: Property + /** Keep generated models out of the public SDK API when requested. */ + @get:Input + abstract val internalModels: Property + /** * The list of schema files to be ignored. */ @@ -146,6 +151,7 @@ abstract class GenerateJsonSchemaTask @Inject constructor( outputDir, targetPackageName.get(), logger, + internalModels = internalModels.get(), generateConstructorDefaults = it.name !in schemasWithoutConstructorDefaults.get() ) generator.generate(type) diff --git a/build-logic/src/main/kotlin/com/datadog/gradle/plugin/jsonschema/generator/FileGenerator.kt b/build-logic/src/main/kotlin/com/datadog/gradle/plugin/jsonschema/generator/FileGenerator.kt index 4745425af8..ff752229dd 100644 --- a/build-logic/src/main/kotlin/com/datadog/gradle/plugin/jsonschema/generator/FileGenerator.kt +++ b/build-logic/src/main/kotlin/com/datadog/gradle/plugin/jsonschema/generator/FileGenerator.kt @@ -9,6 +9,7 @@ package com.datadog.gradle.plugin.jsonschema.generator import com.datadog.gradle.plugin.jsonschema.TypeDefinition import com.squareup.kotlinpoet.ClassName import com.squareup.kotlinpoet.FileSpec +import com.squareup.kotlinpoet.KModifier import com.squareup.kotlinpoet.TypeSpec import org.gradle.api.logging.Logger import java.io.File @@ -17,6 +18,7 @@ class FileGenerator( private val outputDir: File, private val packageName: String, private val logger: Logger, + private val internalModels: Boolean = false, generateConstructorDefaults: Boolean = true ) { @@ -76,6 +78,9 @@ class FileGenerator( ).apply { written = true } ) val topLevelTypeBuilder = generateTypeSpec(definition, rootTypeName) + if (internalModels) { + topLevelTypeBuilder.addModifiers(KModifier.INTERNAL) + } while (knownTypes.any(isUnwrittenClass)) { val nestedClasses = knownTypes.filter(isUnwrittenClass).toSet() diff --git a/build-logic/src/test/kotlin/com/datadog/gradle/plugin/jsonschema/generator/FileGeneratorTest.kt b/build-logic/src/test/kotlin/com/datadog/gradle/plugin/jsonschema/generator/FileGeneratorTest.kt index d413512555..a01cd1075d 100644 --- a/build-logic/src/test/kotlin/com/datadog/gradle/plugin/jsonschema/generator/FileGeneratorTest.kt +++ b/build-logic/src/test/kotlin/com/datadog/gradle/plugin/jsonschema/generator/FileGeneratorTest.kt @@ -42,6 +42,8 @@ import com.datadog.gradle.plugin.jsonschema.UserMerged import com.datadog.gradle.plugin.jsonschema.Version import com.datadog.gradle.plugin.jsonschema.Video import com.datadog.gradle.plugin.jsonschema.WeirdCombo +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.MethodSource @@ -51,6 +53,19 @@ import java.nio.file.Paths class FileGeneratorTest { + @Test + fun `M generate internal model W generate() { internal package }`(@TempDir tempDir: File) { + // Given + val testedGenerator = FileGenerator(tempDir, "com.example.internal.model", NoOpLogger(), internalModels = true) + + // When + testedGenerator.generate(Person) + + // Then + val generated = File(tempDir, "com/example/internal/model/Person.kt") + assertThat(generated).content().contains("internal data class Person(") + } + @ParameterizedTest @MethodSource("data") fun `generates a Poko file`( diff --git a/dd-sdk-android-core/api/apiSurface b/dd-sdk-android-core/api/apiSurface index f0d660c5c9..46a212bc56 100644 --- a/dd-sdk-android-core/api/apiSurface +++ b/dd-sdk-android-core/api/apiSurface @@ -241,6 +241,7 @@ interface com.datadog.android.api.storage.datastore.DataStoreWriteCallback fun onSuccess() fun onFailure() interface com.datadog.android.core.InternalSdkCore : com.datadog.android.api.feature.FeatureSdkCore + val source: String val networkInfo: com.datadog.android.api.context.NetworkInfo val trackingConsent: com.datadog.android.privacy.TrackingConsent val rootStorageDir: java.io.File? diff --git a/dd-sdk-android-core/api/dd-sdk-android-core.api b/dd-sdk-android-core/api/dd-sdk-android-core.api index a41bc794b3..1ead6e12ec 100644 --- a/dd-sdk-android-core/api/dd-sdk-android-core.api +++ b/dd-sdk-android-core/api/dd-sdk-android-core.api @@ -719,6 +719,7 @@ public abstract interface class com/datadog/android/core/InternalSdkCore : com/d public abstract fun getRemoteConfiguration ()Lcom/datadog/android/core/internal/remote/model/RemoteConfiguration; public abstract fun getRemoteConfigurationSyncMetadata ()Lcom/datadog/android/core/internal/remote/model/RemoteConfigSyncMetadata; public abstract fun getRootStorageDir ()Ljava/io/File; + public abstract fun getSource ()Ljava/lang/String; public abstract fun getTrackingConsent ()Lcom/datadog/android/privacy/TrackingConsent; public abstract fun isDeveloperModeEnabled ()Z public abstract fun writeLastFatalAnrSent (J)V diff --git a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/InternalSdkCore.kt b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/InternalSdkCore.kt index 6b47e15edd..d8f0e7f2bc 100644 --- a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/InternalSdkCore.kt +++ b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/InternalSdkCore.kt @@ -26,6 +26,10 @@ import java.util.concurrent.ExecutorService */ interface InternalSdkCore : FeatureSdkCore { + /** SDK source configured during initialization, available without scheduling a context read. */ + @InternalApi + val source: String + /** * Returns current state of network connection. */ diff --git a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/DatadogCore.kt b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/DatadogCore.kt index b68381e1be..e111bf5073 100644 --- a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/DatadogCore.kt +++ b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/DatadogCore.kt @@ -122,6 +122,10 @@ internal class DatadogCore( override val service: String get() = coreFeature.serviceName + /** @inheritDoc */ + override val source: String + get() = coreFeature.sourceName + /** @inheritDoc */ override val firstPartyHostResolver: FirstPartyHostHeaderTypeResolver get() = coreFeature.firstPartyHostHeaderTypeResolver diff --git a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/NoOpInternalSdkCore.kt b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/NoOpInternalSdkCore.kt index 671c2fa5dc..c53245e8ef 100644 --- a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/NoOpInternalSdkCore.kt +++ b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/NoOpInternalSdkCore.kt @@ -69,6 +69,8 @@ internal object NoOpInternalSdkCore : InternalSdkCore { // region InternalSdkCore + override val source: String = "" + override val networkInfo: NetworkInfo get() = NetworkInfo(NetworkInfo.Connectivity.NETWORK_OTHER) override val trackingConsent: TrackingConsent diff --git a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/DatadogCoreInitializationTest.kt b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/DatadogCoreInitializationTest.kt index 6943dd4720..abcae7361b 100644 --- a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/DatadogCoreInitializationTest.kt +++ b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/DatadogCoreInitializationTest.kt @@ -447,7 +447,7 @@ internal class DatadogCoreInitializationTest { // region AdditionalConfig @Test - fun `M apply source name W applyAdditionalConfig(config) { with source name }`( + fun `M apply source name W initialize() { with source name }`( @StringForgery(type = StringForgeryType.ALPHABETICAL) source: String ) { // When @@ -461,11 +461,11 @@ internal class DatadogCoreInitializationTest { } // Then - assertThat(testedCore.coreFeature.sourceName).isEqualTo(source) + assertThat(testedCore.source).isEqualTo(source) } @Test - fun `M use default source name W applyAdditionalConfig(config) { with empty source name }`( + fun `M use default source name W initialize() { with empty source name }`( @StringForgery(type = StringForgeryType.WHITESPACE) source: String ) { // When @@ -481,11 +481,11 @@ internal class DatadogCoreInitializationTest { } // Then - assertThat(testedCore.coreFeature.sourceName).isEqualTo(CoreFeature.DEFAULT_SOURCE_NAME) + assertThat(testedCore.source).isEqualTo(CoreFeature.DEFAULT_SOURCE_NAME) } @Test - fun `M use default source name W applyAdditionalConfig(config) { with source name !string }`( + fun `M use default source name W initialize() { with source name !string }`( @IntForgery source: Int ) { // When @@ -501,11 +501,11 @@ internal class DatadogCoreInitializationTest { } // Then - assertThat(testedCore.coreFeature.sourceName).isEqualTo(CoreFeature.DEFAULT_SOURCE_NAME) + assertThat(testedCore.source).isEqualTo(CoreFeature.DEFAULT_SOURCE_NAME) } @Test - fun `M use default source name W applyAdditionalConfig(config) { without source name }`( + fun `M use default source name W initialize() { without source name }`( @Forgery customAttributes: CustomAttributes ) { // When @@ -519,7 +519,7 @@ internal class DatadogCoreInitializationTest { } // Then - assertThat(testedCore.coreFeature.sourceName).isEqualTo(CoreFeature.DEFAULT_SOURCE_NAME) + assertThat(testedCore.source).isEqualTo(CoreFeature.DEFAULT_SOURCE_NAME) } @Test diff --git a/detekt_custom_safe_calls_android.yml b/detekt_custom_safe_calls_android.yml index 29d8b4518f..96f7ed100a 100644 --- a/detekt_custom_safe_calls_android.yml +++ b/detekt_custom_safe_calls_android.yml @@ -166,9 +166,11 @@ datadog: - "android.view.Window.peekDecorView()" - "android.view.inspector.WindowInspector.getGlobalWindowViews()" - "androidx.collection.LruCache.evictAll()" + - "androidx.collection.LruCache.get(kotlin.String)" - "androidx.collection.LruCache.maxSize()" - "androidx.collection.LruCache.put(com.datadog.android.flags.internal.ExposureEventsProcessor.CacheKey, kotlin.Boolean)" - "androidx.collection.LruCache.put(com.datadog.android.sessionreplay.internal.recorder.resources.Alpha8CacheKey, kotlin.String)" + - "androidx.collection.LruCache.put(kotlin.String, kotlin.String)" - "androidx.collection.LruCache.size()" - "androidx.collection.LruCache.hitCount()" - "androidx.collection.LruCache.missCount()" diff --git a/detekt_custom_safe_calls_third_party.yml b/detekt_custom_safe_calls_third_party.yml index 7fc6a3c29d..b2a47e3205 100644 --- a/detekt_custom_safe_calls_third_party.yml +++ b/detekt_custom_safe_calls_third_party.yml @@ -490,6 +490,7 @@ datadog: - "kotlin.collections.Iterable.toJsonArray()" - "kotlin.collections.Iterable.toList()" - "kotlin.collections.Iterable.toMap(kotlin.collections.MutableMap)" + - "kotlin.collections.Iterator.forEach(kotlin.Function1)" - "kotlin.collections.LinkedHashMap()" - "kotlin.collections.LinkedHashMap(kotlin.collections.MutableMap?)" - "kotlin.collections.List.all(kotlin.Function1)" @@ -687,6 +688,7 @@ datadog: - "kotlin.collections.MutableMap.iterator()" - "kotlin.collections.MutableMap.orEmpty()" - "kotlin.collections.MutableMap.map(kotlin.Function1)" + - "kotlin.collections.MutableMap.mapNotNull(kotlin.Function1)" - "kotlin.collections.MutableMap.mapValues(kotlin.Function1)" - "kotlin.collections.MutableMap.put(java.lang.Class, kotlin.String?)" - "kotlin.collections.MutableMap.put(kotlin.Any, kotlin.Any?)" @@ -735,6 +737,7 @@ datadog: - "kotlin.collections.MutableSet.forEach(kotlin.Function1)" - "kotlin.collections.MutableSet.elementAtOrNull(kotlin.Int)" - "kotlin.collections.MutableSet.indexOf(kotlin.String)" + - "kotlin.collections.MutableSet.iterator()" - "kotlin.collections.MutableSet.joinToString(kotlin.CharSequence, kotlin.CharSequence, kotlin.CharSequence, kotlin.Int, kotlin.CharSequence, kotlin.Function1?)" - "kotlin.collections.MutableSet.map(kotlin.Function1)" - "kotlin.collections.MutableSet.remove(android.app.Activity?)" @@ -865,7 +868,9 @@ datadog: - "kotlin.ByteArray.constructor(kotlin.Int)" - "kotlin.ByteArray.copyTo(kotlin.Int, kotlin.ByteArray, kotlin.Int, kotlin.Int, com.datadog.android.api.InternalLogger)" - "kotlin.Char.digitToIntOrNull()" + - "kotlin.Char.isHighSurrogate()" - "kotlin.Char.isLowerCase()" + - "kotlin.Char.isLowSurrogate()" - "kotlin.Char.titlecase(java.util.Locale)" - "kotlin.CharArray.constructor(kotlin.Int, kotlin.Function1)" - "kotlin.Double.coerceAtMost(kotlin.Double)" @@ -983,6 +988,7 @@ datadog: - "kotlin.String.count(kotlin.Function1)" - "kotlin.String.endsWith(kotlin.Char, kotlin.Boolean)" - "kotlin.String.endsWith(kotlin.String, kotlin.Boolean)" + - "kotlin.String.equals(kotlin.String?, kotlin.Boolean)" - "kotlin.String.filter(kotlin.Function1)" - "kotlin.String.format(java.util.Locale?, kotlin.Array)" - "kotlin.String.format(java.util.Locale?, kotlin.String, kotlin.Array)" diff --git a/detekt_custom_unsafe_calls.yml b/detekt_custom_unsafe_calls.yml index c460ed3aac..682ebbe645 100644 --- a/detekt_custom_unsafe_calls.yml +++ b/detekt_custom_unsafe_calls.yml @@ -55,7 +55,6 @@ datadog: - "android.widget.LinearLayout.addView(android.view.View):java.lang.IllegalArgumentException" - "androidx.collection.LruCache.constructor(kotlin.Int):java.lang.IllegalArgumentException" - "androidx.collection.LruCache.get(java.io.File):java.lang.NullPointerException" - - "androidx.collection.LruCache.get(kotlin.String):java.lang.NullPointerException" - "androidx.collection.LruCache.put(java.io.File, kotlin.Unit):java.lang.NullPointerException" - "androidx.collection.LruCache.put(kotlin.String, kotlin.Unit):java.lang.NullPointerException" - "androidx.collection.LruCache.remove(java.io.File):java.lang.NullPointerException" @@ -194,6 +193,7 @@ datadog: - "java.util.zip.Deflater.deflate(kotlin.ByteArray, kotlin.Int, kotlin.Int, kotlin.Int):java.lang.IllegalArgumentException" # endregion # region Kotlin primitives + - "kotlin.ByteArray.constructor(kotlin.Int, kotlin.Function1):java.lang.NegativeArraySizeException" - "kotlin.ByteArray.copyOf(kotlin.Int):java.lang.NegativeArraySizeException" - "kotlin.ByteArray.copyOfRange(kotlin.Int, kotlin.Int):java.lang.IndexOutOfBoundsException,java.lang.IllegalArgumentException" - "kotlin.ByteArray.get(kotlin.Int):java.lang.IndexOutOfBoundsException" @@ -208,6 +208,7 @@ datadog: - "kotlin.String.substring(kotlin.Int, kotlin.Int):java.lang.IndexOutOfBoundsException" - "kotlin.String.takeLast(kotlin.Int):java.lang.IllegalArgumentException" - "kotlin.String.takeLast(kotlin.Int):kotlin.IllegalArgumentException" + - "kotlin.String.toInt(kotlin.Int):java.lang.NumberFormatException,java.lang.IllegalArgumentException" - "kotlin.String.toLong():java.lang.NumberFormatException" - "kotlin.collections.MutableSet.first():java.util.NoSuchElementException" - "kotlin.String.format(kotlin.Array):java.util.IllegalFormatException" diff --git a/features/dd-sdk-android-flags/api/apiSurface b/features/dd-sdk-android-flags/api/apiSurface index 8b95feb419..7035e81a66 100644 --- a/features/dd-sdk-android-flags/api/apiSurface +++ b/features/dd-sdk-android-flags/api/apiSurface @@ -92,81 +92,3 @@ interface com.datadog.android.flags.model.UnparsedFlag val extraLogging: org.json.JSONObject val reason: String val serialId: Long? -data class com.datadog.android.flags.model.ExposureEvent - constructor(kotlin.Long, Identifier, Identifier, Identifier, Subject, kotlin.Long? = null) - fun toJson(): com.google.gson.JsonElement - companion object - fun fromJson(kotlin.String): ExposureEvent - fun fromJsonObject(com.google.gson.JsonObject): ExposureEvent - data class Identifier - constructor(kotlin.String) - fun toJson(): com.google.gson.JsonElement - companion object - fun fromJson(kotlin.String): Identifier - fun fromJsonObject(com.google.gson.JsonObject): Identifier - data class Subject - constructor(kotlin.String, Attributes) - fun toJson(): com.google.gson.JsonElement - companion object - fun fromJson(kotlin.String): Subject - fun fromJsonObject(com.google.gson.JsonObject): Subject - data class Attributes - constructor(kotlin.collections.MutableMap = mutableMapOf()) - fun toJson(): com.google.gson.JsonElement - companion object - fun fromJson(kotlin.String): Attributes - fun fromJsonObject(com.google.gson.JsonObject): Attributes -data class com.datadog.android.flags.model.FlagEvaluation - constructor(kotlin.Long, Identifier, kotlin.Long, kotlin.Long, kotlin.Long, kotlin.Boolean? = null, kotlin.String? = null, Context? = null, Identifier? = null, Identifier? = null, Identifier? = null, Error? = null) - fun toJson(): com.google.gson.JsonElement - companion object - fun fromJson(kotlin.String): FlagEvaluation - fun fromJsonObject(com.google.gson.JsonObject): FlagEvaluation - data class Identifier - constructor(kotlin.String) - fun toJson(): com.google.gson.JsonElement - companion object - fun fromJson(kotlin.String): Identifier - fun fromJsonObject(com.google.gson.JsonObject): Identifier - data class Context - constructor(Evaluation? = null, Dd? = null) - fun toJson(): com.google.gson.JsonElement - companion object - fun fromJson(kotlin.String): Context - fun fromJsonObject(com.google.gson.JsonObject): Context - data class Error - constructor(kotlin.String) - fun toJson(): com.google.gson.JsonElement - companion object - fun fromJson(kotlin.String): Error - fun fromJsonObject(com.google.gson.JsonObject): Error - data class Evaluation - constructor(kotlin.collections.MutableMap = mutableMapOf()) - fun toJson(): com.google.gson.JsonElement - companion object - fun fromJson(kotlin.String): Evaluation - fun fromJsonObject(com.google.gson.JsonObject): Evaluation - data class Dd - constructor(kotlin.String? = null, Rum? = null, kotlin.collections.MutableMap = mutableMapOf()) - fun toJson(): com.google.gson.JsonElement - companion object - fun fromJson(kotlin.String): Dd - fun fromJsonObject(com.google.gson.JsonObject): Dd - data class Rum - constructor(Application? = null, View? = null) - fun toJson(): com.google.gson.JsonElement - companion object - fun fromJson(kotlin.String): Rum - fun fromJsonObject(com.google.gson.JsonObject): Rum - data class Application - constructor(kotlin.String? = null) - fun toJson(): com.google.gson.JsonElement - companion object - fun fromJson(kotlin.String): Application - fun fromJsonObject(com.google.gson.JsonObject): Application - data class View - constructor(kotlin.String? = null) - fun toJson(): com.google.gson.JsonElement - companion object - fun fromJson(kotlin.String): View - fun fromJsonObject(com.google.gson.JsonObject): View diff --git a/features/dd-sdk-android-flags/api/dd-sdk-android-flags.api b/features/dd-sdk-android-flags/api/dd-sdk-android-flags.api index 0c62e195f7..ffdc35fa68 100644 --- a/features/dd-sdk-android-flags/api/dd-sdk-android-flags.api +++ b/features/dd-sdk-android-flags/api/dd-sdk-android-flags.api @@ -130,324 +130,6 @@ public final class com/datadog/android/flags/model/EvaluationContext$Companion { public final fun getEMPTY ()Lcom/datadog/android/flags/model/EvaluationContext; } -public final class com/datadog/android/flags/model/ExposureEvent { - public static final field Companion Lcom/datadog/android/flags/model/ExposureEvent$Companion; - public fun (JLcom/datadog/android/flags/model/ExposureEvent$Identifier;Lcom/datadog/android/flags/model/ExposureEvent$Identifier;Lcom/datadog/android/flags/model/ExposureEvent$Identifier;Lcom/datadog/android/flags/model/ExposureEvent$Subject;Ljava/lang/Long;)V - public synthetic fun (JLcom/datadog/android/flags/model/ExposureEvent$Identifier;Lcom/datadog/android/flags/model/ExposureEvent$Identifier;Lcom/datadog/android/flags/model/ExposureEvent$Identifier;Lcom/datadog/android/flags/model/ExposureEvent$Subject;Ljava/lang/Long;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()J - public final fun component2 ()Lcom/datadog/android/flags/model/ExposureEvent$Identifier; - public final fun component3 ()Lcom/datadog/android/flags/model/ExposureEvent$Identifier; - public final fun component4 ()Lcom/datadog/android/flags/model/ExposureEvent$Identifier; - public final fun component5 ()Lcom/datadog/android/flags/model/ExposureEvent$Subject; - public final fun component6 ()Ljava/lang/Long; - public final fun copy (JLcom/datadog/android/flags/model/ExposureEvent$Identifier;Lcom/datadog/android/flags/model/ExposureEvent$Identifier;Lcom/datadog/android/flags/model/ExposureEvent$Identifier;Lcom/datadog/android/flags/model/ExposureEvent$Subject;Ljava/lang/Long;)Lcom/datadog/android/flags/model/ExposureEvent; - public static synthetic fun copy$default (Lcom/datadog/android/flags/model/ExposureEvent;JLcom/datadog/android/flags/model/ExposureEvent$Identifier;Lcom/datadog/android/flags/model/ExposureEvent$Identifier;Lcom/datadog/android/flags/model/ExposureEvent$Identifier;Lcom/datadog/android/flags/model/ExposureEvent$Subject;Ljava/lang/Long;ILjava/lang/Object;)Lcom/datadog/android/flags/model/ExposureEvent; - public fun equals (Ljava/lang/Object;)Z - public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/flags/model/ExposureEvent; - public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/flags/model/ExposureEvent; - public final fun getAllocation ()Lcom/datadog/android/flags/model/ExposureEvent$Identifier; - public final fun getFlag ()Lcom/datadog/android/flags/model/ExposureEvent$Identifier; - public final fun getSerialId ()Ljava/lang/Long; - public final fun getSubject ()Lcom/datadog/android/flags/model/ExposureEvent$Subject; - public final fun getTimestamp ()J - public final fun getVariant ()Lcom/datadog/android/flags/model/ExposureEvent$Identifier; - public fun hashCode ()I - public final fun toJson ()Lcom/google/gson/JsonElement; - public fun toString ()Ljava/lang/String; -} - -public final class com/datadog/android/flags/model/ExposureEvent$Attributes { - public static final field Companion Lcom/datadog/android/flags/model/ExposureEvent$Attributes$Companion; - public fun ()V - public fun (Ljava/util/Map;)V - public synthetic fun (Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Ljava/util/Map; - public final fun copy (Ljava/util/Map;)Lcom/datadog/android/flags/model/ExposureEvent$Attributes; - public static synthetic fun copy$default (Lcom/datadog/android/flags/model/ExposureEvent$Attributes;Ljava/util/Map;ILjava/lang/Object;)Lcom/datadog/android/flags/model/ExposureEvent$Attributes; - public fun equals (Ljava/lang/Object;)Z - public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/flags/model/ExposureEvent$Attributes; - public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/flags/model/ExposureEvent$Attributes; - public final fun getAdditionalProperties ()Ljava/util/Map; - public fun hashCode ()I - public final fun toJson ()Lcom/google/gson/JsonElement; - public fun toString ()Ljava/lang/String; -} - -public final class com/datadog/android/flags/model/ExposureEvent$Attributes$Companion { - public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/flags/model/ExposureEvent$Attributes; - public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/flags/model/ExposureEvent$Attributes; -} - -public final class com/datadog/android/flags/model/ExposureEvent$Companion { - public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/flags/model/ExposureEvent; - public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/flags/model/ExposureEvent; -} - -public final class com/datadog/android/flags/model/ExposureEvent$Identifier { - public static final field Companion Lcom/datadog/android/flags/model/ExposureEvent$Identifier$Companion; - public fun (Ljava/lang/String;)V - public final fun component1 ()Ljava/lang/String; - public final fun copy (Ljava/lang/String;)Lcom/datadog/android/flags/model/ExposureEvent$Identifier; - public static synthetic fun copy$default (Lcom/datadog/android/flags/model/ExposureEvent$Identifier;Ljava/lang/String;ILjava/lang/Object;)Lcom/datadog/android/flags/model/ExposureEvent$Identifier; - public fun equals (Ljava/lang/Object;)Z - public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/flags/model/ExposureEvent$Identifier; - public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/flags/model/ExposureEvent$Identifier; - public final fun getKey ()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/flags/model/ExposureEvent$Identifier$Companion { - public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/flags/model/ExposureEvent$Identifier; - public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/flags/model/ExposureEvent$Identifier; -} - -public final class com/datadog/android/flags/model/ExposureEvent$Subject { - public static final field Companion Lcom/datadog/android/flags/model/ExposureEvent$Subject$Companion; - public fun (Ljava/lang/String;Lcom/datadog/android/flags/model/ExposureEvent$Attributes;)V - public final fun component1 ()Ljava/lang/String; - public final fun component2 ()Lcom/datadog/android/flags/model/ExposureEvent$Attributes; - public final fun copy (Ljava/lang/String;Lcom/datadog/android/flags/model/ExposureEvent$Attributes;)Lcom/datadog/android/flags/model/ExposureEvent$Subject; - public static synthetic fun copy$default (Lcom/datadog/android/flags/model/ExposureEvent$Subject;Ljava/lang/String;Lcom/datadog/android/flags/model/ExposureEvent$Attributes;ILjava/lang/Object;)Lcom/datadog/android/flags/model/ExposureEvent$Subject; - public fun equals (Ljava/lang/Object;)Z - public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/flags/model/ExposureEvent$Subject; - public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/flags/model/ExposureEvent$Subject; - public final fun getAttributes ()Lcom/datadog/android/flags/model/ExposureEvent$Attributes; - public final fun getId ()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/flags/model/ExposureEvent$Subject$Companion { - public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/flags/model/ExposureEvent$Subject; - public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/flags/model/ExposureEvent$Subject; -} - -public final class com/datadog/android/flags/model/FlagEvaluation { - public static final field Companion Lcom/datadog/android/flags/model/FlagEvaluation$Companion; - public fun (JLcom/datadog/android/flags/model/FlagEvaluation$Identifier;JJJLjava/lang/Boolean;Ljava/lang/String;Lcom/datadog/android/flags/model/FlagEvaluation$Context;Lcom/datadog/android/flags/model/FlagEvaluation$Identifier;Lcom/datadog/android/flags/model/FlagEvaluation$Identifier;Lcom/datadog/android/flags/model/FlagEvaluation$Identifier;Lcom/datadog/android/flags/model/FlagEvaluation$Error;)V - public synthetic fun (JLcom/datadog/android/flags/model/FlagEvaluation$Identifier;JJJLjava/lang/Boolean;Ljava/lang/String;Lcom/datadog/android/flags/model/FlagEvaluation$Context;Lcom/datadog/android/flags/model/FlagEvaluation$Identifier;Lcom/datadog/android/flags/model/FlagEvaluation$Identifier;Lcom/datadog/android/flags/model/FlagEvaluation$Identifier;Lcom/datadog/android/flags/model/FlagEvaluation$Error;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()J - public final fun component10 ()Lcom/datadog/android/flags/model/FlagEvaluation$Identifier; - public final fun component11 ()Lcom/datadog/android/flags/model/FlagEvaluation$Identifier; - public final fun component12 ()Lcom/datadog/android/flags/model/FlagEvaluation$Error; - public final fun component2 ()Lcom/datadog/android/flags/model/FlagEvaluation$Identifier; - public final fun component3 ()J - public final fun component4 ()J - public final fun component5 ()J - public final fun component6 ()Ljava/lang/Boolean; - public final fun component7 ()Ljava/lang/String; - public final fun component8 ()Lcom/datadog/android/flags/model/FlagEvaluation$Context; - public final fun component9 ()Lcom/datadog/android/flags/model/FlagEvaluation$Identifier; - public final fun copy (JLcom/datadog/android/flags/model/FlagEvaluation$Identifier;JJJLjava/lang/Boolean;Ljava/lang/String;Lcom/datadog/android/flags/model/FlagEvaluation$Context;Lcom/datadog/android/flags/model/FlagEvaluation$Identifier;Lcom/datadog/android/flags/model/FlagEvaluation$Identifier;Lcom/datadog/android/flags/model/FlagEvaluation$Identifier;Lcom/datadog/android/flags/model/FlagEvaluation$Error;)Lcom/datadog/android/flags/model/FlagEvaluation; - public static synthetic fun copy$default (Lcom/datadog/android/flags/model/FlagEvaluation;JLcom/datadog/android/flags/model/FlagEvaluation$Identifier;JJJLjava/lang/Boolean;Ljava/lang/String;Lcom/datadog/android/flags/model/FlagEvaluation$Context;Lcom/datadog/android/flags/model/FlagEvaluation$Identifier;Lcom/datadog/android/flags/model/FlagEvaluation$Identifier;Lcom/datadog/android/flags/model/FlagEvaluation$Identifier;Lcom/datadog/android/flags/model/FlagEvaluation$Error;ILjava/lang/Object;)Lcom/datadog/android/flags/model/FlagEvaluation; - public fun equals (Ljava/lang/Object;)Z - public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/flags/model/FlagEvaluation; - public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/flags/model/FlagEvaluation; - public final fun getAllocation ()Lcom/datadog/android/flags/model/FlagEvaluation$Identifier; - public final fun getContext ()Lcom/datadog/android/flags/model/FlagEvaluation$Context; - public final fun getError ()Lcom/datadog/android/flags/model/FlagEvaluation$Error; - public final fun getEvaluationCount ()J - public final fun getFirstEvaluation ()J - public final fun getFlag ()Lcom/datadog/android/flags/model/FlagEvaluation$Identifier; - public final fun getLastEvaluation ()J - public final fun getRuntimeDefaultUsed ()Ljava/lang/Boolean; - public final fun getTargetingKey ()Ljava/lang/String; - public final fun getTargetingRule ()Lcom/datadog/android/flags/model/FlagEvaluation$Identifier; - public final fun getTimestamp ()J - public final fun getVariant ()Lcom/datadog/android/flags/model/FlagEvaluation$Identifier; - public fun hashCode ()I - public final fun toJson ()Lcom/google/gson/JsonElement; - public fun toString ()Ljava/lang/String; -} - -public final class com/datadog/android/flags/model/FlagEvaluation$Application { - public static final field Companion Lcom/datadog/android/flags/model/FlagEvaluation$Application$Companion; - public fun ()V - public fun (Ljava/lang/String;)V - public synthetic fun (Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Ljava/lang/String; - public final fun copy (Ljava/lang/String;)Lcom/datadog/android/flags/model/FlagEvaluation$Application; - public static synthetic fun copy$default (Lcom/datadog/android/flags/model/FlagEvaluation$Application;Ljava/lang/String;ILjava/lang/Object;)Lcom/datadog/android/flags/model/FlagEvaluation$Application; - public fun equals (Ljava/lang/Object;)Z - public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/flags/model/FlagEvaluation$Application; - public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/flags/model/FlagEvaluation$Application; - public final fun getId ()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/flags/model/FlagEvaluation$Application$Companion { - public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/flags/model/FlagEvaluation$Application; - public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/flags/model/FlagEvaluation$Application; -} - -public final class com/datadog/android/flags/model/FlagEvaluation$Companion { - public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/flags/model/FlagEvaluation; - public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/flags/model/FlagEvaluation; -} - -public final class com/datadog/android/flags/model/FlagEvaluation$Context { - public static final field Companion Lcom/datadog/android/flags/model/FlagEvaluation$Context$Companion; - public fun ()V - public fun (Lcom/datadog/android/flags/model/FlagEvaluation$Evaluation;Lcom/datadog/android/flags/model/FlagEvaluation$Dd;)V - public synthetic fun (Lcom/datadog/android/flags/model/FlagEvaluation$Evaluation;Lcom/datadog/android/flags/model/FlagEvaluation$Dd;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Lcom/datadog/android/flags/model/FlagEvaluation$Evaluation; - public final fun component2 ()Lcom/datadog/android/flags/model/FlagEvaluation$Dd; - public final fun copy (Lcom/datadog/android/flags/model/FlagEvaluation$Evaluation;Lcom/datadog/android/flags/model/FlagEvaluation$Dd;)Lcom/datadog/android/flags/model/FlagEvaluation$Context; - public static synthetic fun copy$default (Lcom/datadog/android/flags/model/FlagEvaluation$Context;Lcom/datadog/android/flags/model/FlagEvaluation$Evaluation;Lcom/datadog/android/flags/model/FlagEvaluation$Dd;ILjava/lang/Object;)Lcom/datadog/android/flags/model/FlagEvaluation$Context; - public fun equals (Ljava/lang/Object;)Z - public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/flags/model/FlagEvaluation$Context; - public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/flags/model/FlagEvaluation$Context; - public final fun getDd ()Lcom/datadog/android/flags/model/FlagEvaluation$Dd; - public final fun getEvaluation ()Lcom/datadog/android/flags/model/FlagEvaluation$Evaluation; - public fun hashCode ()I - public final fun toJson ()Lcom/google/gson/JsonElement; - public fun toString ()Ljava/lang/String; -} - -public final class com/datadog/android/flags/model/FlagEvaluation$Context$Companion { - public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/flags/model/FlagEvaluation$Context; - public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/flags/model/FlagEvaluation$Context; -} - -public final class com/datadog/android/flags/model/FlagEvaluation$Dd { - public static final field Companion Lcom/datadog/android/flags/model/FlagEvaluation$Dd$Companion; - public fun ()V - public fun (Ljava/lang/String;Lcom/datadog/android/flags/model/FlagEvaluation$Rum;Ljava/util/Map;)V - public synthetic fun (Ljava/lang/String;Lcom/datadog/android/flags/model/FlagEvaluation$Rum;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Ljava/lang/String; - public final fun component2 ()Lcom/datadog/android/flags/model/FlagEvaluation$Rum; - public final fun component3 ()Ljava/util/Map; - public final fun copy (Ljava/lang/String;Lcom/datadog/android/flags/model/FlagEvaluation$Rum;Ljava/util/Map;)Lcom/datadog/android/flags/model/FlagEvaluation$Dd; - public static synthetic fun copy$default (Lcom/datadog/android/flags/model/FlagEvaluation$Dd;Ljava/lang/String;Lcom/datadog/android/flags/model/FlagEvaluation$Rum;Ljava/util/Map;ILjava/lang/Object;)Lcom/datadog/android/flags/model/FlagEvaluation$Dd; - public fun equals (Ljava/lang/Object;)Z - public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/flags/model/FlagEvaluation$Dd; - public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/flags/model/FlagEvaluation$Dd; - public final fun getAdditionalProperties ()Ljava/util/Map; - public final fun getRum ()Lcom/datadog/android/flags/model/FlagEvaluation$Rum; - public final fun getService ()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/flags/model/FlagEvaluation$Dd$Companion { - public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/flags/model/FlagEvaluation$Dd; - public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/flags/model/FlagEvaluation$Dd; -} - -public final class com/datadog/android/flags/model/FlagEvaluation$Error { - public static final field Companion Lcom/datadog/android/flags/model/FlagEvaluation$Error$Companion; - public fun (Ljava/lang/String;)V - public final fun component1 ()Ljava/lang/String; - public final fun copy (Ljava/lang/String;)Lcom/datadog/android/flags/model/FlagEvaluation$Error; - public static synthetic fun copy$default (Lcom/datadog/android/flags/model/FlagEvaluation$Error;Ljava/lang/String;ILjava/lang/Object;)Lcom/datadog/android/flags/model/FlagEvaluation$Error; - public fun equals (Ljava/lang/Object;)Z - public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/flags/model/FlagEvaluation$Error; - public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/flags/model/FlagEvaluation$Error; - public final fun getMessage ()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/flags/model/FlagEvaluation$Error$Companion { - public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/flags/model/FlagEvaluation$Error; - public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/flags/model/FlagEvaluation$Error; -} - -public final class com/datadog/android/flags/model/FlagEvaluation$Evaluation { - public static final field Companion Lcom/datadog/android/flags/model/FlagEvaluation$Evaluation$Companion; - public fun ()V - public fun (Ljava/util/Map;)V - public synthetic fun (Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Ljava/util/Map; - public final fun copy (Ljava/util/Map;)Lcom/datadog/android/flags/model/FlagEvaluation$Evaluation; - public static synthetic fun copy$default (Lcom/datadog/android/flags/model/FlagEvaluation$Evaluation;Ljava/util/Map;ILjava/lang/Object;)Lcom/datadog/android/flags/model/FlagEvaluation$Evaluation; - public fun equals (Ljava/lang/Object;)Z - public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/flags/model/FlagEvaluation$Evaluation; - public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/flags/model/FlagEvaluation$Evaluation; - public final fun getAdditionalProperties ()Ljava/util/Map; - public fun hashCode ()I - public final fun toJson ()Lcom/google/gson/JsonElement; - public fun toString ()Ljava/lang/String; -} - -public final class com/datadog/android/flags/model/FlagEvaluation$Evaluation$Companion { - public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/flags/model/FlagEvaluation$Evaluation; - public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/flags/model/FlagEvaluation$Evaluation; -} - -public final class com/datadog/android/flags/model/FlagEvaluation$Identifier { - public static final field Companion Lcom/datadog/android/flags/model/FlagEvaluation$Identifier$Companion; - public fun (Ljava/lang/String;)V - public final fun component1 ()Ljava/lang/String; - public final fun copy (Ljava/lang/String;)Lcom/datadog/android/flags/model/FlagEvaluation$Identifier; - public static synthetic fun copy$default (Lcom/datadog/android/flags/model/FlagEvaluation$Identifier;Ljava/lang/String;ILjava/lang/Object;)Lcom/datadog/android/flags/model/FlagEvaluation$Identifier; - public fun equals (Ljava/lang/Object;)Z - public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/flags/model/FlagEvaluation$Identifier; - public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/flags/model/FlagEvaluation$Identifier; - public final fun getKey ()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/flags/model/FlagEvaluation$Identifier$Companion { - public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/flags/model/FlagEvaluation$Identifier; - public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/flags/model/FlagEvaluation$Identifier; -} - -public final class com/datadog/android/flags/model/FlagEvaluation$Rum { - public static final field Companion Lcom/datadog/android/flags/model/FlagEvaluation$Rum$Companion; - public fun ()V - public fun (Lcom/datadog/android/flags/model/FlagEvaluation$Application;Lcom/datadog/android/flags/model/FlagEvaluation$View;)V - public synthetic fun (Lcom/datadog/android/flags/model/FlagEvaluation$Application;Lcom/datadog/android/flags/model/FlagEvaluation$View;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Lcom/datadog/android/flags/model/FlagEvaluation$Application; - public final fun component2 ()Lcom/datadog/android/flags/model/FlagEvaluation$View; - public final fun copy (Lcom/datadog/android/flags/model/FlagEvaluation$Application;Lcom/datadog/android/flags/model/FlagEvaluation$View;)Lcom/datadog/android/flags/model/FlagEvaluation$Rum; - public static synthetic fun copy$default (Lcom/datadog/android/flags/model/FlagEvaluation$Rum;Lcom/datadog/android/flags/model/FlagEvaluation$Application;Lcom/datadog/android/flags/model/FlagEvaluation$View;ILjava/lang/Object;)Lcom/datadog/android/flags/model/FlagEvaluation$Rum; - public fun equals (Ljava/lang/Object;)Z - public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/flags/model/FlagEvaluation$Rum; - public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/flags/model/FlagEvaluation$Rum; - public final fun getApplication ()Lcom/datadog/android/flags/model/FlagEvaluation$Application; - public final fun getView ()Lcom/datadog/android/flags/model/FlagEvaluation$View; - public fun hashCode ()I - public final fun toJson ()Lcom/google/gson/JsonElement; - public fun toString ()Ljava/lang/String; -} - -public final class com/datadog/android/flags/model/FlagEvaluation$Rum$Companion { - public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/flags/model/FlagEvaluation$Rum; - public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/flags/model/FlagEvaluation$Rum; -} - -public final class com/datadog/android/flags/model/FlagEvaluation$View { - public static final field Companion Lcom/datadog/android/flags/model/FlagEvaluation$View$Companion; - public fun ()V - public fun (Ljava/lang/String;)V - public synthetic fun (Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Ljava/lang/String; - public final fun copy (Ljava/lang/String;)Lcom/datadog/android/flags/model/FlagEvaluation$View; - public static synthetic fun copy$default (Lcom/datadog/android/flags/model/FlagEvaluation$View;Ljava/lang/String;ILjava/lang/Object;)Lcom/datadog/android/flags/model/FlagEvaluation$View; - public fun equals (Ljava/lang/Object;)Z - public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/flags/model/FlagEvaluation$View; - public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/flags/model/FlagEvaluation$View; - public final fun getUrl ()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/flags/model/FlagEvaluation$View$Companion { - public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/flags/model/FlagEvaluation$View; - public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/flags/model/FlagEvaluation$View; -} - public final class com/datadog/android/flags/model/FlagsClientEvent { public final fun getFlagsChanged ()Ljava/util/List; public final fun getType ()Lcom/datadog/android/flags/model/FlagsClientEventType; diff --git a/features/dd-sdk-android-flags/build.gradle.kts b/features/dd-sdk-android-flags/build.gradle.kts index 61cf30c5dc..69ef075cd0 100644 --- a/features/dd-sdk-android-flags/build.gradle.kts +++ b/features/dd-sdk-android-flags/build.gradle.kts @@ -39,9 +39,10 @@ plugins { id("test-pyramid-api-surface") } -createJsonModelsGenerationTask("generateFlagsModelsFromJson") { +createJsonModelsGenerationTask("generateFlagsInternalModelsFromJson") { inputDirPath = "src/main/json/flags" - targetPackageName = "com.datadog.android.flags.model" + targetPackageName = "com.datadog.android.flags.internal.model" + internalModels.set(true) } // TODO RUM-18189 Support new AGP DSL diff --git a/features/dd-sdk-android-flags/src/main/json/flags/assignment.json b/features/dd-sdk-android-flags/src/main/json/flags/assignment.json new file mode 100644 index 0000000000..c1d2d2a2aa --- /dev/null +++ b/features/dd-sdk-android-flags/src/main/json/flags/assignment.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "title": "Assignment", + "description": "An assignment after conversion of legacy JSON values to the SDK string representation.", + "type": "object", + "properties": { + "variationType": { "type": "string" }, + "variationValue": { "type": "string" }, + "doLog": { "type": "boolean" }, + "allocationKey": { "type": "string" }, + "variationKey": { "type": "string" }, + "extraLogging": { "type": "object", "additionalProperties": true }, + "reason": { "type": "string" }, + "serialId": { "type": "integer" } + }, + "required": ["variationType", "variationValue", "doLog", "allocationKey", "variationKey", "extraLogging", "reason"] +} diff --git a/features/dd-sdk-android-flags/src/main/json/flags/assignments-response.json b/features/dd-sdk-android-flags/src/main/json/flags/assignments-response.json new file mode 100644 index 0000000000..487c35c5c0 --- /dev/null +++ b/features/dd-sdk-android-flags/src/main/json/flags/assignments-response.json @@ -0,0 +1,22 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "title": "AssignmentsResponse", + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "attributes": { + "type": "object", + "properties": { + "flags": { "type": "object", "additionalProperties": true } + }, + "required": ["flags"], + "additionalProperties": true + } + }, + "required": ["attributes"] + } + }, + "required": ["data"] +} diff --git a/features/dd-sdk-android-flags/src/main/json/flags/flags-state.json b/features/dd-sdk-android-flags/src/main/json/flags/flags-state.json new file mode 100644 index 0000000000..66fbd93752 --- /dev/null +++ b/features/dd-sdk-android-flags/src/main/json/flags/flags-state.json @@ -0,0 +1,28 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "title": "FlagsState", + "type": "object", + "properties": { + "evaluationContext": { + "type": "object", + "properties": { + "targetingKey": { "type": "string" }, + "attributes": { "type": "object", "additionalProperties": true } + }, + "required": ["targetingKey"] + }, + "flags": { "type": "object", "additionalProperties": true }, + "encodedFlags": { "type": "object", "additionalProperties": true }, + "lastUpdateTimestamp": { "type": "integer" }, + "obfuscated": { "type": "boolean" }, + "obfuscation": { + "type": "object", + "properties": { + "scheme": { "type": "string" }, + "salt": { "type": "string" } + }, + "required": ["scheme", "salt"] + } + }, + "required": ["evaluationContext", "lastUpdateTimestamp"] +} diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt index 76ab698407..52a9ef6cc4 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt @@ -67,6 +67,7 @@ interface FlagsClient { /** * Invokes [listener] once with the keys from the first installed cached or downloaded flags, * including an empty configuration. Missing or invalid cache does not trigger this callback. + * Obfuscated configurations return an empty key list because the original keys are not available. * Each registration receives a separate event with a copy of the retained first keys, * even after subsequent flag updates. * Replaying an available result reads retained memory and does not perform I/O. diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/ExposureEventsProcessor.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/ExposureEventsProcessor.kt index 69b3e1bcb0..089089cd1c 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/ExposureEventsProcessor.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/ExposureEventsProcessor.kt @@ -7,9 +7,9 @@ package com.datadog.android.flags.internal import androidx.collection.LruCache +import com.datadog.android.flags.internal.model.ExposureEvent import com.datadog.android.flags.internal.storage.RecordWriter import com.datadog.android.flags.model.EvaluationContext -import com.datadog.android.flags.model.ExposureEvent import com.datadog.android.flags.model.UnparsedFlag import com.datadog.android.internal.time.TimeProvider diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/aggregation/EvaluationAggregationStats.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/aggregation/EvaluationAggregationStats.kt index 344f12410b..6945688d31 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/aggregation/EvaluationAggregationStats.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/aggregation/EvaluationAggregationStats.kt @@ -7,8 +7,8 @@ package com.datadog.android.flags.internal.aggregation import com.datadog.android.api.context.DatadogContext +import com.datadog.android.flags.internal.model.FlagEvaluation import com.datadog.android.flags.model.EvaluationContext -import com.datadog.android.flags.model.FlagEvaluation /** * Immutable aggregation statistics for evaluation logging. diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/aggregation/EvaluationAggregator.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/aggregation/EvaluationAggregator.kt index 299b2660d2..ba4246a6d4 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/aggregation/EvaluationAggregator.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/aggregation/EvaluationAggregator.kt @@ -6,8 +6,8 @@ package com.datadog.android.flags.internal.aggregation +import com.datadog.android.flags.internal.model.FlagEvaluation import com.datadog.android.flags.model.EvaluationContext -import com.datadog.android.flags.model.FlagEvaluation import java.util.concurrent.locks.ReentrantLock import kotlin.concurrent.withLock diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManager.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManager.kt index 05ff5c0b20..09ca8d9330 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManager.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManager.kt @@ -14,7 +14,7 @@ import com.datadog.android.core.internal.utils.executeSafe import com.datadog.android.flags.EvaluationContextCallback import com.datadog.android.flags.FlagsInitializationTimeoutException import com.datadog.android.flags.internal.FlagsStateManager -import com.datadog.android.flags.internal.model.PrecomputedFlag +import com.datadog.android.flags.internal.model.PrecomputedAssignments import com.datadog.android.flags.internal.net.NetworkRequestFailedException import com.datadog.android.flags.internal.net.PrecomputedAssignmentsReader import com.datadog.android.flags.internal.repository.FlagsRepository @@ -101,7 +101,7 @@ internal class EvaluationsManager( * Processes a new evaluation context by fetching flags and storing atomically. * * This method asynchronously fetches precomputed flag evaluations for the given context - * and atomically updates both the context and flag data in the repository. A failed or unreadable + * and atomically updates the context, flag data, and encoding descriptor in the repository. A failed or unreadable * response keeps the last known good flags and their context. * * The operation is performed on the configured executor service and will not block the @@ -135,6 +135,8 @@ internal class EvaluationsManager( } else { null } + // The React Native bridge requires a map keyed by original flag names, which encoded data omits. + val supportsObfuscation = datadogContext?.source == "android" internalLogger.log( InternalLogger.Level.DEBUG, InternalLogger.Target.MAINTAINER, @@ -146,17 +148,12 @@ internal class EvaluationsManager( hadFlags && flagsRepository.getEvaluationContext() == context ) val response = datadogContext?.let { assignmentsReader.readPrecomputedFlags(context, it) } - val flagsMap = response?.let { precomputeMapper.map(it) } - if (flagsMap != null) { - installFlags(context, flagsMap, initializationCompletion, callback) + val assignments = response?.let { precomputeMapper.map(it) } + ?.takeIf { it.obfuscation == null || supportsObfuscation } + if (assignments != null) { + installFlags(context, assignments, initializationCompletion, callback) } else { - val message = if (response == - null - ) { - NETWORK_REQUEST_FAILED_MESSAGE - } else { - INVALID_RESPONSE_MESSAGE - } + val message = if (response == null) NETWORK_REQUEST_FAILED_MESSAGE else INVALID_RESPONSE_MESSAGE internalLogger.log( InternalLogger.Level.WARN, InternalLogger.Target.USER, @@ -200,11 +197,11 @@ internal class EvaluationsManager( private fun installFlags( context: EvaluationContext, - flagsMap: Map, + assignments: PrecomputedAssignments, initializationCompletion: InitializationCompletion?, callback: EvaluationContextCallback? ) { - flagsRepository.setFlagsAndContext(context, flagsMap) { + flagsRepository.setFlagsAndContext(context, assignments.flags, assignments.obfuscation) { val completionCallback = synchronized(initializationTerminalLock) { val result = initializationCompletion?.take()?.callback ?: if (initializationCompletion == null) callback else null @@ -216,7 +213,7 @@ internal class EvaluationsManager( internalLogger.log( InternalLogger.Level.DEBUG, InternalLogger.Target.MAINTAINER, - { "Successfully processed context ${context.targetingKey} with ${flagsMap.size} flags" } + { "Successfully processed context ${context.targetingKey} with ${assignments.flags.size} flags" } ) } diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/FlagKeyObfuscation.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/FlagKeyObfuscation.kt new file mode 100644 index 0000000000..b2d0db7f7b --- /dev/null +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/FlagKeyObfuscation.kt @@ -0,0 +1,82 @@ +/* + * 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.flags.internal.model + +import androidx.collection.LruCache +import com.google.gson.JsonParseException +import java.security.MessageDigest +import java.util.Locale + +/** Salt validated before lookup hashes are computed. */ +// Keep copy() private so callers cannot bypass salt validation. +@ConsistentCopyVisibility +internal data class FlagKeyObfuscation private constructor(val salt: String) { + @Suppress("UnsafeThirdPartyFunctionCall") // The cache limit is a positive constant. + private val lookupKeys = LruCache(LOOKUP_CACHE_LIMIT) + + fun encode(key: String): String? { + // Reject unpaired surrogates instead of silently replacing their UTF-8 encoding. + if (!isWellFormedUnicode(key)) return null + val cachedKey = lookupKeys.get(key) + return cachedKey ?: run { + @Suppress("UnsafeThirdPartyFunctionCall") // SHA-256 is mandatory on Android. + val digest = MessageDigest.getInstance("SHA-256") + digest.update("datadog.feature-flags.flag-key.v1\u0000".toByteArray(Charsets.UTF_8)) + @Suppress("UnsafeThirdPartyFunctionCall") // The size is positive; the salt is validated hexadecimal. + val saltBytes = ByteArray(SALT_BYTES) { salt.substring(it * 2, it * 2 + 2).toInt(HEX_RADIX).toByte() } + digest.update(saltBytes) + digest.update(key.toByteArray(Charsets.UTF_8)) + digest.digest().joinToString("") { + "%02x".format(Locale.US, it) + }.also { + lookupKeys.put(key, it) + } + } + } + + internal companion object { + const val SCHEME = "flag-key-sha256-v1" + const val CAPABILITY = "assignment-encoding-flag-key-256-v1" + private const val SALT_BYTES = 16 + private const val HEX_RADIX = 16 + private const val LOOKUP_CACHE_LIMIT = 1024 + + @Throws(JsonParseException::class) + fun fromSalt(salt: String): FlagKeyObfuscation { + if (!isLowercaseHex(salt, SALT_BYTES)) { + @Suppress("ThrowingInternalException") // Caught by the response or cache parser. + throw JsonParseException("Flag-key salt must contain 32 lowercase hexadecimal characters") + } + return FlagKeyObfuscation(salt) + } + + private fun isLowercaseHex(value: String, bytes: Int): Boolean = + value.length == bytes * 2 && value.all { it in '0'..'9' || it in 'a'..'f' } + + @Suppress("ReturnCount") + private fun isWellFormedUnicode(value: String): Boolean { + var index = 0 + while (index < value.length) { + val char = value[index] + if (char.isHighSurrogate()) { + if (index + 1 >= value.length || !value[index + 1].isLowSurrogate()) return false + index += 2 + } else if (char.isLowSurrogate()) { + return false + } else { + index++ + } + } + return true + } + } +} + +internal data class PrecomputedAssignments( + val flags: Map, + val obfuscation: FlagKeyObfuscation? = null +) diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/FlagsStateEntry.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/FlagsStateEntry.kt index d61d3f383f..b2de7aa2f0 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/FlagsStateEntry.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/FlagsStateEntry.kt @@ -11,5 +11,6 @@ import com.datadog.android.flags.model.EvaluationContext internal data class FlagsStateEntry( val evaluationContext: EvaluationContext, val flags: Map, - val lastUpdateTimestamp: Long + val lastUpdateTimestamp: Long, + val obfuscation: FlagKeyObfuscation? = null ) diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/PrecomputedFlagConstants.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/PrecomputedFlagConstants.kt index 4a013b0da1..864745ff3a 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/PrecomputedFlagConstants.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/PrecomputedFlagConstants.kt @@ -6,27 +6,6 @@ package com.datadog.android.flags.internal.model -internal enum class JsonKeys(val value: String) { - // EvaluationContext keys - EVALUATION_CONTEXT("evaluationContext"), - TARGETING_KEY("targetingKey"), - ATTRIBUTES("attributes"), - - // FlagsStateEntry keys - FLAGS("flags"), - LAST_UPDATE_TIMESTAMP("lastUpdateTimestamp"), - - // PrecomputedFlag keys - VARIATION_TYPE("variationType"), - VARIATION_VALUE("variationValue"), - DO_LOG("doLog"), - ALLOCATION_KEY("allocationKey"), - VARIATION_KEY("variationKey"), - EXTRA_LOGGING("extraLogging"), - REASON("reason"), - SERIAL_ID("serialId") -} - internal enum class VariationType(val value: String) { BOOLEAN("boolean"), STRING("string"), diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsRequestFactory.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsRequestFactory.kt index e7414d824c..8452d69d52 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsRequestFactory.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsRequestFactory.kt @@ -10,6 +10,7 @@ import com.datadog.android.api.InternalLogger import com.datadog.android.api.context.DatadogContext import com.datadog.android.api.feature.Feature import com.datadog.android.flags.internal.getFlagsEndpoint +import com.datadog.android.flags.internal.model.FlagKeyObfuscation import com.datadog.android.flags.model.EvaluationContext import okhttp3.Headers import okhttp3.Request @@ -66,6 +67,14 @@ internal class PrecomputedAssignmentsRequestFactory( .add(HEADER_CLIENT_TOKEN, datadogContext.clientToken) .add(HEADER_CONTENT_TYPE, CONTENT_TYPE_VND_JSON) + // Bridge SDKs that consume raw snapshots cannot resolve encoded keys yet. + if (datadogContext.source == "android") { + headersBuilder.add( + "X-DD-FEATURE-FLAGS-CAPABILITIES", + listOf(FlagKeyObfuscation.CAPABILITY).sorted().joinToString(",") + ) + } + datadogContext.rumApplicationId?.let { headersBuilder.add(HEADER_APPLICATION_ID, it) } diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/persistence/FlagsPersistenceManager.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/persistence/FlagsPersistenceManager.kt index 9563a6eb8d..de263f0990 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/persistence/FlagsPersistenceManager.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/persistence/FlagsPersistenceManager.kt @@ -11,6 +11,7 @@ import com.datadog.android.api.storage.datastore.DataStoreHandler import com.datadog.android.api.storage.datastore.DataStoreReadCallback import com.datadog.android.api.storage.datastore.DataStoreWriteCallback import com.datadog.android.core.persistence.datastore.DataStoreContent +import com.datadog.android.flags.internal.model.FlagKeyObfuscation import com.datadog.android.flags.internal.model.FlagsStateEntry import com.datadog.android.flags.internal.model.PrecomputedFlag import com.datadog.android.flags.model.EvaluationContext @@ -33,12 +34,14 @@ internal class FlagsPersistenceManager( context: EvaluationContext, flags: Map, currentTimestamp: Long, - callback: DataStoreWriteCallback? = null + callback: DataStoreWriteCallback? = null, + obfuscation: FlagKeyObfuscation? = null ) { val entry = FlagsStateEntry( evaluationContext = context, flags = flags, - lastUpdateTimestamp = currentTimestamp + lastUpdateTimestamp = currentTimestamp, + obfuscation = obfuscation ) dataStore.setValue( diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateDeserializer.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateDeserializer.kt index c0216edeec..1cfcc63235 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateDeserializer.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateDeserializer.kt @@ -9,40 +9,49 @@ package com.datadog.android.flags.internal.persistence import com.datadog.android.api.InternalLogger import com.datadog.android.core.internal.persistence.Deserializer import com.datadog.android.flags.internal.model.FlagsStateEntry -import com.datadog.android.flags.internal.model.JsonKeys import com.datadog.android.flags.internal.model.PrecomputedFlag +import com.datadog.android.flags.internal.serialization.FlagsJson import com.datadog.android.flags.model.EvaluationContext -import org.json.JSONException -import org.json.JSONObject +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonParseException +import com.google.gson.JsonParser internal class FlagsStateDeserializer(private val internalLogger: InternalLogger) : Deserializer { override fun deserialize(model: String): FlagsStateEntry? = try { - val json = JSONObject(model) - - @Suppress("UnsafeThirdPartyFunctionCall") // JSONObject operations wrapped in try-catch - val contextJson = json.getJSONObject(JsonKeys.EVALUATION_CONTEXT.value) - val evaluationContext = deserializeEvaluationContext(contextJson) - - @Suppress("UnsafeThirdPartyFunctionCall") // JSONObject operations wrapped in try-catch - val flagsJson = json.getJSONObject(JsonKeys.FLAGS.value) - val flags = deserializeFlags(flagsJson) - - @Suppress("UnsafeThirdPartyFunctionCall") // JSONObject operations wrapped in try-catch - val timestamp = json.getLong(JsonKeys.LAST_UPDATE_TIMESTAMP.value) + val json = FlagsJson.objectValue(JsonParser.parseString(model)) + val obfuscation = FlagsJson.readObfuscation(json) + val state = FlagsJson.readState(json, obfuscation) + val assignments = (if (obfuscation == null) state.flags else state.encodedFlags)?.additionalProperties + if (assignments == null) { + @Suppress("ThrowingInternalException") // Caught below and reported as an invalid cache. + throw JsonParseException("Missing persisted assignments") + } + if (obfuscation != null) FlagsJson.validateKeys(assignments.keys.iterator()) + val flags = assignments.mapNotNull { (key, value) -> + // A malformed object can be skipped; a non-object entry invalidates the cache, as before. + deserializePrecomputedFlag(FlagsJson.objectValue(value))?.let { key to it } + }.toMap() // A nonempty cache with no readable flags is not a valid empty configuration. - if (flags.isEmpty() && flagsJson.length() > 0) { + if (flags.isEmpty() && assignments.isNotEmpty()) { null } else { FlagsStateEntry( - evaluationContext = evaluationContext, + evaluationContext = EvaluationContext( + state.evaluationContext.targetingKey, + state.evaluationContext.attributes?.additionalProperties.orEmpty().mapValues { + FlagsJson.stringValue(it.value as JsonElement) + } + ), flags = flags, - lastUpdateTimestamp = timestamp + lastUpdateTimestamp = state.lastUpdateTimestamp, + obfuscation = obfuscation ) } - } catch (e: JSONException) { + } catch (e: JsonParseException) { internalLogger.log( InternalLogger.Level.ERROR, InternalLogger.Target.MAINTAINER, @@ -61,63 +70,9 @@ internal class FlagsStateDeserializer(private val internalLogger: InternalLogger null } - @Suppress("UnsafeThirdPartyFunctionCall") // JSONObject operations wrapped in try-catch - private fun deserializeEvaluationContext(contextJson: JSONObject): EvaluationContext { - val targetingKey = contextJson.getString(JsonKeys.TARGETING_KEY.value) - - val attributes = try { - deserializeAttributes(contextJson.getJSONObject(JsonKeys.ATTRIBUTES.value)) - } catch (_: JSONException) { - emptyMap() - } - - return EvaluationContext(targetingKey, attributes) - } - - @Suppress("UnsafeThirdPartyFunctionCall") // JSONObject operations wrapped in try-catch - private fun deserializeAttributes(attributesJson: JSONObject): Map { - val attributes = mutableMapOf() - val keys = attributesJson.keys() - - while (keys.hasNext()) { - val key = keys.next() - val value = attributesJson.get(key).toString() - attributes[key] = value - } - - return attributes - } - - @Suppress("UnsafeThirdPartyFunctionCall") // JSONObject operations wrapped in try-catch - private fun deserializeFlags(flagsJson: JSONObject): Map { - val flags = mutableMapOf() - val keys = flagsJson.keys() - - while (keys.hasNext()) { - val key = keys.next() - val flagJson = flagsJson.getJSONObject(key) - val flag = deserializePrecomputedFlag(flagJson) - if (flag != null) { - flags[key] = flag - } - } - - return flags - } - - @Suppress("UnsafeThirdPartyFunctionCall") // JSONObject operations wrapped in try-catch - private fun deserializePrecomputedFlag(flagJson: JSONObject): PrecomputedFlag? = try { - PrecomputedFlag( - variationType = flagJson.getString(JsonKeys.VARIATION_TYPE.value), - variationValue = flagJson.getString(JsonKeys.VARIATION_VALUE.value), - doLog = flagJson.getBoolean(JsonKeys.DO_LOG.value), - allocationKey = flagJson.getString(JsonKeys.ALLOCATION_KEY.value), - variationKey = flagJson.getString(JsonKeys.VARIATION_KEY.value), - extraLogging = flagJson.getJSONObject(JsonKeys.EXTRA_LOGGING.value), - reason = flagJson.getString(JsonKeys.REASON.value), - serialId = (flagJson.opt(JsonKeys.SERIAL_ID.value) as? Number)?.toLong() - ) - } catch (e: JSONException) { + private fun deserializePrecomputedFlag(flagJson: JsonObject): PrecomputedFlag? = try { + FlagsJson.readAssignment(flagJson) + } catch (e: JsonParseException) { internalLogger.log( InternalLogger.Level.ERROR, InternalLogger.Target.MAINTAINER, diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateSerializer.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateSerializer.kt index e950b6bdda..c7aaa7c918 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateSerializer.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateSerializer.kt @@ -8,37 +8,34 @@ package com.datadog.android.flags.internal.persistence import com.datadog.android.api.InternalLogger import com.datadog.android.core.persistence.Serializer +import com.datadog.android.flags.internal.model.FlagKeyObfuscation +import com.datadog.android.flags.internal.model.FlagsState import com.datadog.android.flags.internal.model.FlagsStateEntry -import com.datadog.android.flags.internal.model.JsonKeys -import com.datadog.android.flags.internal.model.PrecomputedFlag -import org.json.JSONException -import org.json.JSONObject +import com.datadog.android.flags.internal.serialization.FlagsJson +import com.google.gson.JsonParseException -/** - * Serializer for converting FlagsStateEntry objects to JSON strings for datastore persistence. - */ +/** Maps evaluator state to the generated disk-cache model. */ internal class FlagsStateSerializer(private val internalLogger: InternalLogger) : Serializer { - @Suppress("TooGenericExceptionCaught") override fun serialize(model: FlagsStateEntry): String = try { - val json = JSONObject() - - val contextJson = JSONObject().apply { - put(JsonKeys.TARGETING_KEY.value, model.evaluationContext.targetingKey) - put(JsonKeys.ATTRIBUTES.value, serializeAttributes(model.evaluationContext.attributes)) - } - json.put(JsonKeys.EVALUATION_CONTEXT.value, contextJson) - - val flagsJson = JSONObject() - model.flags.forEach { (key, flag) -> - flagsJson.put(key, serializePrecomputedFlag(flag)) - } - json.put(JsonKeys.FLAGS.value, flagsJson) - - json.put(JsonKeys.LAST_UPDATE_TIMESTAMP.value, model.lastUpdateTimestamp) - - json.toString() - } catch (e: JSONException) { + val flags = FlagsState.Flags( + model.flags.mapValues { + FlagsJson.writeAssignment(it.value).toJson() + }.toMutableMap() + ) + FlagsState( + evaluationContext = FlagsState.EvaluationContext( + targetingKey = model.evaluationContext.targetingKey, + attributes = FlagsState.Flags(model.evaluationContext.attributes.toMutableMap()) + ), + // Older SDKs must reject encoded caches instead of reading digests as original keys. + flags = flags.takeIf { model.obfuscation == null }, + encodedFlags = flags.takeIf { model.obfuscation != null }, + lastUpdateTimestamp = model.lastUpdateTimestamp, + obfuscated = true.takeIf { model.obfuscation != null }, + obfuscation = model.obfuscation?.let { FlagsState.Obfuscation(FlagKeyObfuscation.SCHEME, it.salt) } + ).toJson().toString() + } catch (e: JsonParseException) { internalLogger.log( InternalLogger.Level.ERROR, InternalLogger.Target.MAINTAINER, @@ -47,25 +44,4 @@ internal class FlagsStateSerializer(private val internalLogger: InternalLogger) ) "" } - - @Suppress("UnsafeThirdPartyFunctionCall") // JSONObject operations wrapped in try-catch - private fun serializeAttributes(attributes: Map): JSONObject { - val attributesJson = JSONObject() - attributes.forEach { (key, value) -> - attributesJson.put(key, value) - } - return attributesJson - } - - @Suppress("UnsafeThirdPartyFunctionCall") // JSONObject operations wrapped in try-catch - private fun serializePrecomputedFlag(flag: PrecomputedFlag): JSONObject = JSONObject().apply { - put(JsonKeys.VARIATION_TYPE.value, flag.variationType) - put(JsonKeys.VARIATION_VALUE.value, flag.variationValue) - put(JsonKeys.DO_LOG.value, flag.doLog) - put(JsonKeys.ALLOCATION_KEY.value, flag.allocationKey) - put(JsonKeys.VARIATION_KEY.value, flag.variationKey) - put(JsonKeys.EXTRA_LOGGING.value, flag.extraLogging) - put(JsonKeys.REASON.value, flag.reason) - flag.serialId?.let { put(JsonKeys.SERIAL_ID.value, it) } - } } diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepository.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepository.kt index 5687a6f274..c7f49f0ef8 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepository.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepository.kt @@ -10,6 +10,8 @@ import com.datadog.android.api.InternalLogger import com.datadog.android.api.feature.FeatureSdkCore import com.datadog.android.api.storage.datastore.DataStoreHandler import com.datadog.android.api.storage.datastore.DataStoreWriteCallback +import com.datadog.android.core.InternalSdkCore +import com.datadog.android.flags.internal.model.FlagKeyObfuscation import com.datadog.android.flags.internal.model.PrecomputedFlag import com.datadog.android.flags.internal.persistence.FlagsPersistenceManager import com.datadog.android.flags.model.EvaluationContext @@ -26,11 +28,23 @@ internal class DefaultFlagsRepository( private val persistenceLoadTimeoutMs: Long = PERSISTENCE_LOAD_TIMEOUT_MS, private val deliverFirstFlags: (Runnable) -> Unit = Runnable::run ) : FlagsRepository { - private data class FlagsState(val context: EvaluationContext, val flags: Map) + private data class FlagsState( + val context: EvaluationContext, + val flags: Map, + val obfuscation: FlagKeyObfuscation? + ) { + operator fun get(key: String): PrecomputedFlag? = + if (obfuscation == null) flags[key] else obfuscation.encode(key)?.let { flags[it] } + } private val atomicState = AtomicReference(null) + private val obfuscationSupported = (featureSdkCore as? InternalSdkCore)?.source == "android" + + private val readableState: FlagsState? + get() = atomicState.get()?.takeIf { it.obfuscation == null || obfuscationSupported } @Suppress("UnsafeThirdPartyFunctionCall") // CountDownLatch rejects negative counts; 1 is valid. private val persistenceLoadedLatch = CountDownLatch(1) + override val firstFlags = FirstFlagsLatch() private val persistenceManager = FlagsPersistenceManager( @@ -38,30 +52,33 @@ internal class DefaultFlagsRepository( instanceName = instanceName, internalLogger = internalLogger ) { persistedState -> - val installed = try { - persistedState != null && atomicState.compareAndSet( - null, + val cachedState = try { + persistedState?.let { FlagsState( - persistedState.evaluationContext, - persistedState.flags.mapValues { (_, flag) -> flag.copy(reason = ResolutionReason.CACHED.name) } - ) - ) + it.evaluationContext, + it.flags.mapValues { (_, flag) -> flag.copy(reason = ResolutionReason.CACHED.name) }, + it.obfuscation + ).takeIf { state -> atomicState.compareAndSet(null, state) } + } } finally { persistenceLoadedLatch.countDown() } - if (installed && persistedState != null) { - firstFlags.complete(persistedState.flags.keys, deliverFirstFlags) + if (cachedState != null && persistedState != null) { + publishFirstFlags(cachedState, persistedState.flags.keys) } } override fun setFlagsAndContext( context: EvaluationContext, flags: Map, + obfuscation: FlagKeyObfuscation?, onInstalled: () -> Unit ) { - val newState = FlagsState(context, flags) + val newState = FlagsState(context, flags, obfuscation) - val firstInstallation = atomicState.getAndSet(newState) == null + val previousState = atomicState.getAndSet(newState) + val firstInstallation = previousState == null || + (previousState.obfuscation != null && !obfuscationSupported) persistenceLoadedLatch.countDown() try { @@ -69,7 +86,7 @@ internal class DefaultFlagsRepository( context = context, flags = flags, currentTimestamp = featureSdkCore.timeProvider.getDeviceTimestampMillis(), - object : DataStoreWriteCallback { + callback = object : DataStoreWriteCallback { override fun onSuccess() { } @@ -80,7 +97,8 @@ internal class DefaultFlagsRepository( messageBuilder = { ERROR_SAVING_FLAGS_STATE } ) } - } + }, + obfuscation = obfuscation ) } catch ( // Storage submission failure must not abort an accepted installation. @@ -99,16 +117,16 @@ internal class DefaultFlagsRepository( onInstalled() } finally { if (firstInstallation) { - firstFlags.complete(flags.keys, deliverFirstFlags) + publishFirstFlags(newState, flags.keys) } } } override fun getPrecomputedFlag(key: String): PrecomputedFlag? { waitForPersistenceLoad() - val state = atomicState.get() + val state = readableState if (state != null) { - return state.flags[key] + return state[key] } internalLogger.log( InternalLogger.Level.WARN, @@ -120,9 +138,10 @@ internal class DefaultFlagsRepository( override fun getFlagsSnapshot(): Map { waitForPersistenceLoad() - val state = atomicState.get() + val state = readableState if (state != null) { - return state.flags + // The React Native bridge consumes original keys and cannot decode this representation. + return if (state.obfuscation == null) state.flags else emptyMap() } internalLogger.log( InternalLogger.Level.WARN, @@ -134,22 +153,22 @@ internal class DefaultFlagsRepository( override fun getEvaluationContext(): EvaluationContext? { waitForPersistenceLoad() - return atomicState.get()?.context + return readableState?.context } override fun hasFlags(): Boolean { waitForPersistenceLoad() - return atomicState.get()?.flags?.isNotEmpty() ?: false + return readableState?.flags?.isNotEmpty() ?: false } override fun hasLoadedFlagsForContext(context: EvaluationContext): Boolean { - val state = atomicState.get() + val state = readableState return state?.context == context && state.flags.isNotEmpty() } override fun getPrecomputedFlagWithContext(key: String): Pair? { - val state = atomicState.get() ?: return null - val flag = state.flags[key] + val state = readableState ?: return null + val flag = state[key] return flag to state.context } @@ -162,6 +181,14 @@ internal class DefaultFlagsRepository( } } + private fun publishFirstFlags(state: FlagsState, installedKeys: Collection) { + if (state.obfuscation == null || obfuscationSupported) { + // Encoded responses cannot enumerate the original application keys. + val keys = if (state.obfuscation == null) installedKeys else emptySet() + firstFlags.complete(keys, deliverFirstFlags) + } + } + companion object { const val WARN_CONTEXT_NOT_SET = "You must call FlagsClientManager.get().setEvaluationContext " + "in order to have flags available" diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/FlagsRepository.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/FlagsRepository.kt index 1bcab8e477..801748a6b7 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/FlagsRepository.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/FlagsRepository.kt @@ -6,6 +6,7 @@ package com.datadog.android.flags.internal.repository +import com.datadog.android.flags.internal.model.FlagKeyObfuscation import com.datadog.android.flags.internal.model.PrecomputedFlag import com.datadog.android.flags.model.EvaluationContext @@ -20,6 +21,7 @@ internal interface FlagsRepository { fun setFlagsAndContext( context: EvaluationContext, flags: Map, + obfuscation: FlagKeyObfuscation? = null, onInstalled: () -> Unit = {} ) diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/NoOpFlagsRepository.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/NoOpFlagsRepository.kt index db8c94f9ac..c367dd145e 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/NoOpFlagsRepository.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/NoOpFlagsRepository.kt @@ -6,6 +6,7 @@ package com.datadog.android.flags.internal.repository +import com.datadog.android.flags.internal.model.FlagKeyObfuscation import com.datadog.android.flags.internal.model.PrecomputedFlag import com.datadog.android.flags.model.EvaluationContext @@ -18,6 +19,7 @@ internal class NoOpFlagsRepository : FlagsRepository { override fun setFlagsAndContext( context: EvaluationContext, flags: Map, + obfuscation: FlagKeyObfuscation?, onInstalled: () -> Unit ) { onInstalled() diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/net/PrecomputeMapper.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/net/PrecomputeMapper.kt index 13dd294baa..4e09cdc03a 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/net/PrecomputeMapper.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/net/PrecomputeMapper.kt @@ -7,52 +7,33 @@ package com.datadog.android.flags.internal.repository.net import com.datadog.android.api.InternalLogger -import com.datadog.android.flags.internal.model.JsonKeys +import com.datadog.android.flags.internal.model.AssignmentsResponse +import com.datadog.android.flags.internal.model.PrecomputedAssignments import com.datadog.android.flags.internal.model.PrecomputedFlag -import org.json.JSONException -import org.json.JSONObject +import com.datadog.android.flags.internal.serialization.FlagsJson +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonParseException /** * Responsible for parsing network response to [PrecomputedFlag] objects. */ internal class PrecomputeMapper(private val internalLogger: InternalLogger) { - // JSONObject methods accept non-null String parameters despite Detekt's incorrect nullable interpretation - // All getJsonObject calls are wrapped in try-catch for JSONException which is the actual exception thrown - @Suppress("UnsafeThirdPartyFunctionCall") - internal fun map(rawJson: String): Map? = try { - val jsonResponse = JSONObject(rawJson) - val data = jsonResponse.getJSONObject("data") - val attributes = data.getJSONObject("attributes") - val flags = attributes.getJSONObject("flags") - - val flagsMap = mutableMapOf() - - val flagKeys = flags.keys() - while (flagKeys.hasNext()) { - val flagKey = flagKeys.next() - val flagData = flags.getJSONObject(flagKey) - - val precomputedFlag = PrecomputedFlag( - variationType = flagData.getString(JsonKeys.VARIATION_TYPE.value), - variationValue = when (val value = flagData.get(JsonKeys.VARIATION_VALUE.value)) { - is Boolean -> value.toString() - is String -> value - is Number -> value.toString() - else -> value.toString() - }, - doLog = flagData.getBoolean(JsonKeys.DO_LOG.value), - allocationKey = flagData.getString(JsonKeys.ALLOCATION_KEY.value), - variationKey = flagData.getString(JsonKeys.VARIATION_KEY.value), - extraLogging = flagData.getJSONObject(JsonKeys.EXTRA_LOGGING.value), - reason = flagData.getString(JsonKeys.REASON.value), - serialId = (flagData.opt(JsonKeys.SERIAL_ID.value) as? Number)?.toLong() - ) - - flagsMap[flagKey] = precomputedFlag + internal fun map(rawJson: String): PrecomputedAssignments? = try { + val attributes = AssignmentsResponse.fromJson(rawJson).data.attributes + val metadata = JsonObject().apply { + attributes.additionalProperties.forEach { (key, value) -> + add(key, value as? JsonElement) + } } - - flagsMap - } catch (e: JSONException) { + val obfuscation = FlagsJson.readObfuscation(metadata) + val flags = attributes.flags.additionalProperties + if (obfuscation != null) FlagsJson.validateKeys(flags.keys.iterator()) + PrecomputedAssignments( + flags.mapValues { FlagsJson.readAssignment(FlagsJson.objectValue(it.value)) }, + obfuscation + ) + } catch (e: JsonParseException) { internalLogger.log( level = InternalLogger.Level.WARN, target = InternalLogger.Target.MAINTAINER, diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/serialization/FlagsJson.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/serialization/FlagsJson.kt new file mode 100644 index 0000000000..1d5653f7db --- /dev/null +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/serialization/FlagsJson.kt @@ -0,0 +1,127 @@ +/* + * 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.flags.internal.serialization + +import com.datadog.android.flags.internal.model.Assignment +import com.datadog.android.flags.internal.model.FlagKeyObfuscation +import com.datadog.android.flags.internal.model.FlagsState +import com.datadog.android.flags.internal.model.PrecomputedFlag +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonParseException +import org.json.JSONObject + +/** Generated model adapters, legacy JSON conversions, and encoding validation. */ +internal object FlagsJson { + @Throws(JsonParseException::class) + fun readAssignment(json: JsonObject): PrecomputedFlag { + // Preserve JSONObject's string conversion, including JSON variants and explicit null. + STRING_FIELDS.forEach { field -> + val value = json.get(field) ?: throw JsonParseException("Missing assignment field: $field") + json.addProperty(field, stringValue(value)) + } + val doLog = json.get("doLog") + val booleanValue = doLog?.takeIf { it.isJsonPrimitive }?.asString + if (!booleanValue.equals("true", ignoreCase = true) && !booleanValue.equals("false", ignoreCase = true)) { + throw JsonParseException("Invalid assignment doLog") + } + // Gson accepts the same boolean strings; reject other values before it coerces them to false. + // Existing readers ignore null and nonnumeric serial IDs. + val serialId = json.get("serialId") + if (serialId?.isJsonPrimitive != true || !serialId.asJsonPrimitive.isNumber) json.remove("serialId") + + val assignment = Assignment.fromJsonObject(json) + + @Suppress("UnsafeThirdPartyFunctionCall") // Generated JSON is a valid object. + val extraLogging = JSONObject(assignment.extraLogging.toJson().toString()) + return PrecomputedFlag( + variationType = assignment.variationType, + variationValue = assignment.variationValue, + doLog = assignment.doLog, + allocationKey = assignment.allocationKey, + variationKey = assignment.variationKey, + extraLogging = extraLogging, + reason = assignment.reason, + serialId = assignment.serialId + ) + } + + fun writeAssignment(flag: PrecomputedFlag): Assignment = Assignment( + variationType = flag.variationType, + variationValue = flag.variationValue, + doLog = flag.doLog, + allocationKey = flag.allocationKey, + variationKey = flag.variationKey, + extraLogging = Assignment.ExtraLogging.fromJson(flag.extraLogging.toString()), + reason = flag.reason, + serialId = flag.serialId + ) + + @Throws(JsonParseException::class) + fun readState(json: JsonObject, obfuscation: FlagKeyObfuscation?): FlagsState { + // Preserve old caches: ignore the unused map and accept JSONObject's context conversions. + json.remove(if (obfuscation == null) "encodedFlags" else "flags") + val context = objectValue(json.get("evaluationContext")) + if (context.get("attributes") !is JsonObject) context.remove("attributes") + context.get("targetingKey")?.let { context.addProperty("targetingKey", stringValue(it)) } + if (json.get("lastUpdateTimestamp")?.isJsonPrimitive != true) { + @Suppress("ThrowingInternalException") // Caught by the cache deserializer. + throw JsonParseException("Invalid persisted flag timestamp") + } + return FlagsState.fromJsonObject(json) + } + + @Throws(JsonParseException::class) + @Suppress("ThrowsCount") // Report each invalid encoding field before generated readers can coerce it. + fun readObfuscation(json: JsonObject): FlagKeyObfuscation? { + val obfuscated = json.get("obfuscated") + val isBoolean = obfuscated?.isJsonPrimitive == true && obfuscated.asJsonPrimitive.isBoolean + val isPlaintext = obfuscated == null || (isBoolean && !obfuscated.asBoolean) + if (isPlaintext && !json.has("obfuscation")) return null + if (!isBoolean || !obfuscated.asBoolean) { + @Suppress("ThrowingInternalException") // Caught by the response or cache deserializer. + throw JsonParseException("Invalid flag-key obfuscation metadata") + } + val descriptor = objectValue(json.get("obfuscation")) + // Generated string readers coerce primitives. The encoding contract requires JSON strings. + listOf("scheme", "salt").forEach { field -> + val value = descriptor.get(field) + if (value?.isJsonPrimitive != true || !value.asJsonPrimitive.isString) { + @Suppress("ThrowingInternalException") // Caught by the response or cache deserializer. + throw JsonParseException("Invalid flag-key obfuscation $field") + } + } + val encoding = FlagsState.Obfuscation.fromJsonObject(descriptor) + if (encoding.scheme != FlagKeyObfuscation.SCHEME) { + @Suppress("ThrowingInternalException") // Caught by the response or cache deserializer. + throw JsonParseException("Unsupported flag-key obfuscation scheme") + } + return FlagKeyObfuscation.fromSalt(encoding.salt) + } + + @Throws(JsonParseException::class) + fun validateKeys(keys: Iterator) { + keys.forEach { + if (it.length != DIGEST_HEX_LENGTH || + it.any { character -> character !in '0'..'9' && character !in 'a'..'f' } + ) { + @Suppress("ThrowingInternalException") // Caught by the response or cache deserializer. + throw JsonParseException("Invalid encoded flag key") + } + } + } + + fun stringValue(value: JsonElement): String = + if (value.isJsonPrimitive && value.asJsonPrimitive.isString) value.asString else value.toString() + + @Throws(JsonParseException::class) + fun objectValue(value: Any?): JsonObject = (value as? JsonObject) + ?: throw JsonParseException("Expected a JSON object") + + private val STRING_FIELDS = listOf("variationType", "variationValue", "allocationKey", "variationKey", "reason") + private const val DIGEST_HEX_LENGTH = 64 +} diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/storage/ExposureEventRecordWriter.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/storage/ExposureEventRecordWriter.kt index c8aedbd4d9..fa3b7c8a5e 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/storage/ExposureEventRecordWriter.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/storage/ExposureEventRecordWriter.kt @@ -10,7 +10,7 @@ import com.datadog.android.api.feature.Feature import com.datadog.android.api.feature.FeatureSdkCore import com.datadog.android.api.storage.EventType import com.datadog.android.api.storage.RawBatchEvent -import com.datadog.android.flags.model.ExposureEvent +import com.datadog.android.flags.internal.model.ExposureEvent internal class ExposureEventRecordWriter(private val sdkCore: FeatureSdkCore) : RecordWriter { override fun write(record: ExposureEvent) { diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/storage/NoOpRecordWriter.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/storage/NoOpRecordWriter.kt index 2b9e63bbe9..b0665828ca 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/storage/NoOpRecordWriter.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/storage/NoOpRecordWriter.kt @@ -6,7 +6,7 @@ package com.datadog.android.flags.internal.storage -import com.datadog.android.flags.model.ExposureEvent +import com.datadog.android.flags.internal.model.ExposureEvent internal class NoOpRecordWriter : RecordWriter { override fun write(record: ExposureEvent) { diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/storage/RecordWriter.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/storage/RecordWriter.kt index 175057c50e..2097fd4cdd 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/storage/RecordWriter.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/storage/RecordWriter.kt @@ -6,7 +6,7 @@ package com.datadog.android.flags.internal.storage -import com.datadog.android.flags.model.ExposureEvent +import com.datadog.android.flags.internal.model.ExposureEvent /** * Will persists the serialized [ExposureEvent] in the allocated caching location. diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/ExposureEventsProcessorTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/ExposureEventsProcessorTest.kt index a7414fce4e..2e939e25e3 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/ExposureEventsProcessorTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/ExposureEventsProcessorTest.kt @@ -6,10 +6,10 @@ package com.datadog.android.flags.internal +import com.datadog.android.flags.internal.model.ExposureEvent import com.datadog.android.flags.internal.model.PrecomputedFlag import com.datadog.android.flags.internal.storage.RecordWriter import com.datadog.android.flags.model.EvaluationContext -import com.datadog.android.flags.model.ExposureEvent import com.datadog.android.flags.model.ResolutionReason import com.datadog.android.flags.utils.forge.ForgeConfigurator import com.datadog.android.internal.time.TimeProvider diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManagerTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManagerTest.kt index bbadb39bad..5ef1964059 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManagerTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManagerTest.kt @@ -19,7 +19,9 @@ import com.datadog.android.flags.FlagsInitializationTimeoutException import com.datadog.android.flags.FlagsStateListener import com.datadog.android.flags.internal.FlagsStateManager import com.datadog.android.flags.internal.model.FlagsStateEntry +import com.datadog.android.flags.internal.model.PrecomputedAssignments import com.datadog.android.flags.internal.model.PrecomputedFlag +import com.datadog.android.flags.internal.net.NetworkRequestFailedException import com.datadog.android.flags.internal.net.PrecomputedAssignmentsReader import com.datadog.android.flags.internal.repository.DefaultFlagsRepository import com.datadog.android.flags.internal.repository.FlagsRepository @@ -33,15 +35,15 @@ import fr.xgouchet.elmyr.annotation.Forgery import fr.xgouchet.elmyr.annotation.StringForgery import fr.xgouchet.elmyr.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension -import okhttp3.mockwebserver.MockWebServer import org.assertj.core.api.Assertions.assertThat import org.json.JSONObject -import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertDoesNotThrow import org.junit.jupiter.api.Assertions.fail import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings @@ -108,7 +110,6 @@ internal class EvaluationsManagerTest { @Forgery lateinit var fakeDatadogContext: DatadogContext - private lateinit var mockWebServer: MockWebServer private lateinit var evaluationsManager: EvaluationsManager @Test @@ -122,9 +123,6 @@ internal class EvaluationsManagerTest { @BeforeEach fun setUp() { - mockWebServer = MockWebServer() - mockWebServer.start() - evaluationsManager = EvaluationsManager( sdkCore = mockSdkCore, executorService = mockExecutorService, @@ -138,17 +136,15 @@ internal class EvaluationsManagerTest { ) whenever(mockSdkCore.getFeature(Feature.FLAGS_FEATURE_NAME)) doReturn mockFlagsFeatureScope - whenever(mockSdkCore.getDatadogContext(setOf(Feature.RUM_FEATURE_NAME))) doReturn fakeDatadogContext - whenever( - mockFlagsFeatureScope.withContext(eq(setOf(Feature.RUM_FEATURE_NAME)), any()) - ) doAnswer { - it.getArgument<(DatadogContext) -> Unit>(1).invoke(fakeDatadogContext) + whenever(mockSdkCore.source) doAnswer { fakeDatadogContext.source } + whenever(mockSdkCore.getDatadogContext(setOf(Feature.RUM_FEATURE_NAME))) doAnswer { + fakeDatadogContext } doAnswer { - it.getArgument<() -> Unit>(2).invoke() + it.getArgument<() -> Unit>(3).invoke() null - }.whenever(mockFlagsRepository).setFlagsAndContext(any(), any(), any()) + }.whenever(mockFlagsRepository).setFlagsAndContext(any(), any(), anyOrNull(), any()) // Mock executor to run tasks synchronously for testing whenever(mockExecutorService.execute(any())).thenAnswer { invocation -> @@ -157,10 +153,28 @@ internal class EvaluationsManagerTest { } } + @ParameterizedTest + @ValueSource(strings = ["android", "react-native"]) + fun `M read cache before fetching assignments W updateEvaluationsForContext() {source varies}`(source: String) { + // Given + fakeDatadogContext = fakeDatadogContext.copy(source = source) + + // When + evaluationsManager.updateEvaluationsForContext(EvaluationContext(fakeTargetingKey, emptyMap())) + + // Then + inOrder(mockFlagsRepository, mockAssignmentsDownloader) { + verify(mockFlagsRepository).hasFlags() + verify(mockAssignmentsDownloader).readPrecomputedFlags( + EvaluationContext(fakeTargetingKey, emptyMap()), + fakeDatadogContext + ) + } + } + @Test fun `M release request state W updateEvaluationsForContext() {context dispatch unavailable}`() { - // Given: withContext silently drops callbacks when the core stops or dispatch is rejected. - doAnswer { null }.whenever(mockFlagsFeatureScope).withContext(any(), any()) + // Given whenever(mockSdkCore.getDatadogContext(setOf(Feature.RUM_FEATURE_NAME))) doReturn null var finished = 0 @@ -172,11 +186,6 @@ internal class EvaluationsManagerTest { verify(mockAssignmentsDownloader, never()).readPrecomputedFlags(any(), any()) } - @AfterEach - fun tearDown() { - mockWebServer.shutdown() - } - @Test fun `M complete context without first flags W no op repository`() { // Given @@ -197,7 +206,7 @@ internal class EvaluationsManagerTest { initializationTimeoutScheduler = { _, _ -> {} } ) whenever(mockAssignmentsDownloader.readPrecomputedFlags(context, fakeDatadogContext)).thenReturn("response") - whenever(mockPrecomputeMapper.map("response")).thenReturn(emptyMap()) + whenever(mockPrecomputeMapper.map("response")).thenReturn(PrecomputedAssignments(emptyMap(), null)) // When manager.updateEvaluationsForContext(context, callback) @@ -250,13 +259,13 @@ internal class EvaluationsManagerTest { whenever(mockAssignmentsDownloader.readPrecomputedFlags(context, fakeDatadogContext)) .thenReturn(mockResponse) - whenever(mockPrecomputeMapper.map(mockResponse)).thenReturn(expectedFlags) + whenever(mockPrecomputeMapper.map(mockResponse)).thenReturn(PrecomputedAssignments(expectedFlags)) // When evaluationsManager.updateEvaluationsForContext(context) // Then - verify(mockFlagsRepository).setFlagsAndContext(eq(context), eq(expectedFlags), any()) + verify(mockFlagsRepository).setFlagsAndContext(eq(context), eq(expectedFlags), anyOrNull(), any()) verify(mockInternalLogger, times(2)).log( eq(InternalLogger.Level.DEBUG), eq(InternalLogger.Target.MAINTAINER), @@ -319,7 +328,7 @@ internal class EvaluationsManagerTest { evaluationsManager.updateEvaluationsForContext(context, callback) // Then - verify(mockFlagsRepository, never()).setFlagsAndContext(any(), any(), any()) + verify(mockFlagsRepository, never()).setFlagsAndContext(any(), any(), anyOrNull(), any()) verify(mockFlagsStateManager).updateState(isA()) verify(mockFlagsStateManager, never()).updateState(FlagsClientState.Ready) verify(callback).onFailure(any()) @@ -363,7 +372,7 @@ internal class EvaluationsManagerTest { whenever(mockAssignmentsDownloader.readPrecomputedFlags(publicContext, fakeDatadogContext)) .thenReturn(mockResponse) - whenever(mockPrecomputeMapper.map(mockResponse)).thenReturn(expectedFlags) + whenever(mockPrecomputeMapper.map(mockResponse)).thenReturn(PrecomputedAssignments(expectedFlags)) // When evaluationsManager.updateEvaluationsForContext(publicContext) @@ -445,7 +454,7 @@ internal class EvaluationsManagerTest { whenever(mockAssignmentsDownloader.readPrecomputedFlags(publicContext, fakeDatadogContext)) .thenReturn(jsonResponse) - whenever(mockPrecomputeMapper.map(jsonResponse)).thenReturn(flagsMap) + whenever(mockPrecomputeMapper.map(jsonResponse)).thenReturn(PrecomputedAssignments(flagsMap)) // When evaluationsManager.updateEvaluationsForContext(publicContext, callback = mockCallback) @@ -453,7 +462,7 @@ internal class EvaluationsManagerTest { // Then verify(mockCallback).onSuccess() verify(mockCallback, never()).onFailure(any()) - verify(mockFlagsRepository).setFlagsAndContext(eq(publicContext), eq(emptyMap()), any()) + verify(mockFlagsRepository).setFlagsAndContext(eq(publicContext), eq(emptyMap()), anyOrNull(), any()) verify(mockFlagsStateManager).updateState(FlagsClientState.Ready) } @@ -506,7 +515,7 @@ internal class EvaluationsManagerTest { whenever(mockAssignmentsDownloader.readPrecomputedFlags(publicContext, fakeDatadogContext)) .thenReturn(jsonResponse) - whenever(mockPrecomputeMapper.map(jsonResponse)).thenReturn(flagsMap) + whenever(mockPrecomputeMapper.map(jsonResponse)).thenReturn(PrecomputedAssignments(flagsMap)) // When/Then - should not throw assertDoesNotThrow { evaluationsManager.updateEvaluationsForContext(publicContext, callback = null) } @@ -526,7 +535,7 @@ internal class EvaluationsManagerTest { } whenever(mockAssignmentsDownloader.readPrecomputedFlags(context, fakeDatadogContext)) .thenReturn(EMPTY_FLAGS_RESPONSE_JSON) - whenever(mockPrecomputeMapper.map(EMPTY_FLAGS_RESPONSE_JSON)).thenReturn(emptyMap()) + whenever(mockPrecomputeMapper.map(EMPTY_FLAGS_RESPONSE_JSON)).thenReturn(PrecomputedAssignments(emptyMap())) val testedManager = createManager( initializationTimeoutMs = 2_500L, scheduler = InitializationTimeoutScheduler { timeoutMs, action -> @@ -660,7 +669,7 @@ internal class EvaluationsManagerTest { } whenever(mockAssignmentsDownloader.readPrecomputedFlags(any(), eq(fakeDatadogContext))) .thenReturn(EMPTY_FLAGS_RESPONSE_JSON) - whenever(mockPrecomputeMapper.map(EMPTY_FLAGS_RESPONSE_JSON)).thenReturn(emptyMap()) + whenever(mockPrecomputeMapper.map(EMPTY_FLAGS_RESPONSE_JSON)).thenReturn(PrecomputedAssignments(emptyMap())) val stateManager = FlagsStateManager( DDCoreStateHolder.create( initialState = FlagsClientState.NotReady, @@ -809,7 +818,7 @@ internal class EvaluationsManagerTest { }.whenever(mockFlagsStateManager).updateState(any()) whenever(mockAssignmentsDownloader.readPrecomputedFlags(context, fakeDatadogContext)) .thenReturn(EMPTY_FLAGS_RESPONSE_JSON) - whenever(mockPrecomputeMapper.map(EMPTY_FLAGS_RESPONSE_JSON)).thenReturn(emptyMap()) + whenever(mockPrecomputeMapper.map(EMPTY_FLAGS_RESPONSE_JSON)).thenReturn(PrecomputedAssignments(emptyMap())) val testedManager = createManager( initializationTimeoutMs = 2_500L, scheduler = InitializationTimeoutScheduler { _, action -> @@ -852,7 +861,7 @@ internal class EvaluationsManagerTest { var cancellationCount = 0 whenever(mockAssignmentsDownloader.readPrecomputedFlags(context, fakeDatadogContext)) .thenReturn(EMPTY_FLAGS_RESPONSE_JSON) - whenever(mockPrecomputeMapper.map(EMPTY_FLAGS_RESPONSE_JSON)).thenReturn(emptyMap()) + whenever(mockPrecomputeMapper.map(EMPTY_FLAGS_RESPONSE_JSON)).thenReturn(PrecomputedAssignments(emptyMap())) val testedManager = createManager( initializationTimeoutMs = 2_500L, scheduler = InitializationTimeoutScheduler { _, action -> @@ -881,7 +890,7 @@ internal class EvaluationsManagerTest { .thenReturn(EMPTY_FLAGS_RESPONSE_JSON) whenever(mockPrecomputeMapper.map(EMPTY_FLAGS_RESPONSE_JSON)).thenAnswer { checkNotNull(timeoutAction).invoke() - emptyMap() + PrecomputedAssignments(emptyMap()) } val testedManager = createManager( initializationTimeoutMs = 2_500L, @@ -897,7 +906,7 @@ internal class EvaluationsManagerTest { // Then verify(mockCallback).onFailure(any()) verify(mockCallback, never()).onSuccess() - verify(mockFlagsRepository).setFlagsAndContext(eq(context), eq(emptyMap()), any()) + verify(mockFlagsRepository).setFlagsAndContext(eq(context), eq(emptyMap()), anyOrNull(), any()) verify(mockFlagsStateManager).updateState(FlagsClientState.Ready) } @@ -908,7 +917,7 @@ internal class EvaluationsManagerTest { var scheduleCount = 0 whenever(mockAssignmentsDownloader.readPrecomputedFlags(any(), eq(fakeDatadogContext))) .thenReturn(EMPTY_FLAGS_RESPONSE_JSON) - whenever(mockPrecomputeMapper.map(EMPTY_FLAGS_RESPONSE_JSON)).thenReturn(emptyMap()) + whenever(mockPrecomputeMapper.map(EMPTY_FLAGS_RESPONSE_JSON)).thenReturn(PrecomputedAssignments(emptyMap())) val testedManager = createManager( initializationTimeoutMs = 2_500L, scheduler = InitializationTimeoutScheduler { _, _ -> @@ -933,7 +942,7 @@ internal class EvaluationsManagerTest { var scheduleCount = 0 whenever(mockAssignmentsDownloader.readPrecomputedFlags(any(), eq(fakeDatadogContext))) .thenReturn(EMPTY_FLAGS_RESPONSE_JSON) - whenever(mockPrecomputeMapper.map(EMPTY_FLAGS_RESPONSE_JSON)).thenReturn(emptyMap()) + whenever(mockPrecomputeMapper.map(EMPTY_FLAGS_RESPONSE_JSON)).thenReturn(PrecomputedAssignments(emptyMap())) val testedManager = createManager( initializationTimeoutMs = null, scheduler = InitializationTimeoutScheduler { _, _ -> @@ -988,7 +997,7 @@ internal class EvaluationsManagerTest { var timeoutAction: (() -> Unit)? = null whenever(mockAssignmentsDownloader.readPrecomputedFlags(context, fakeDatadogContext)) .thenReturn(EMPTY_FLAGS_RESPONSE_JSON) - whenever(mockPrecomputeMapper.map(EMPTY_FLAGS_RESPONSE_JSON)).thenReturn(emptyMap()) + whenever(mockPrecomputeMapper.map(EMPTY_FLAGS_RESPONSE_JSON)).thenReturn(PrecomputedAssignments(emptyMap())) val stateManager = FlagsStateManager( DDCoreStateHolder.create( initialState = FlagsClientState.NotReady, @@ -1112,7 +1121,7 @@ internal class EvaluationsManagerTest { whenever(mockAssignmentsDownloader.readPrecomputedFlags(publicContext, fakeDatadogContext)) .thenReturn(jsonResponse) - whenever(mockPrecomputeMapper.map(jsonResponse)).thenReturn(flagsMap) + whenever(mockPrecomputeMapper.map(jsonResponse)).thenReturn(PrecomputedAssignments(flagsMap)) // When evaluationsManagerWithRealState.updateEvaluationsForContext(publicContext, callback = callback) @@ -1149,7 +1158,8 @@ internal class EvaluationsManagerTest { val firstKeys = mutableListOf>() repository.firstFlags.whenComplete { firstKeys.add(it) } whenever(mockAssignmentsDownloader.readPrecomputedFlags(context, fakeDatadogContext)).thenReturn("response") - whenever(mockPrecomputeMapper.map("response")).thenReturn(mapOf("enabled" to mock())) + whenever(mockPrecomputeMapper.map("response")) + .thenReturn(PrecomputedAssignments(mapOf("enabled" to mock()))) // When manager.updateEvaluationsForContext(context, callback) @@ -1207,7 +1217,7 @@ internal class EvaluationsManagerTest { manager.updateEvaluationsForContext(EvaluationContext("newer")) } whenever(mockAssignmentsDownloader.readPrecomputedFlags(context, fakeDatadogContext)).thenReturn("response") - whenever(mockPrecomputeMapper.map("response")).thenReturn(emptyMap()) + whenever(mockPrecomputeMapper.map("response")).thenReturn(PrecomputedAssignments(emptyMap())) // When manager.updateEvaluationsForContext(context, callback) @@ -1225,6 +1235,79 @@ internal class EvaluationsManagerTest { // region Cold-start integration + @Test + fun `M retain matching assignments on invalid encoding W updateEvaluationsForContext() { invalid refresh }`( + @Mock mockCallback: EvaluationContextCallback + ) { + // Given + fakeDatadogContext = fakeDatadogContext.copy(source = "android") + val context = EvaluationContext(fakeTargetingKey, emptyMap()) + val testedRepository = createRepository() + val testedManager = createManagerWithRepository(testedRepository) + whenever(mockAssignmentsDownloader.readPrecomputedFlags(context, fakeDatadogContext)) + .thenReturn(ENCODED_RESPONSE_JSON) + + // When + testedManager.updateEvaluationsForContext(context, mockCallback) + + // Then + assertThat(checkNotNull(testedRepository.getPrecomputedFlag("flag")).variationValue).isEqualTo("true") + verify(mockCallback).onSuccess() + + // Given + val invalidResponse = ENCODED_RESPONSE_JSON.replace("flag-key-sha256-v1", "unknown") + whenever( + mockAssignmentsDownloader.readPrecomputedFlags(context, fakeDatadogContext) + ).thenReturn(invalidResponse) + + // When + testedManager.updateEvaluationsForContext(context, mockCallback) + + // Then + verify(mockFlagsStateManager).updateState(FlagsClientState.Stale) + val failure = argumentCaptor() + verify(mockCallback).onFailure(failure.capture()) + assertThat(failure.firstValue.message).isEqualTo("Unable to read the feature flags response.") + assertThat(checkNotNull(testedRepository.getPrecomputedFlag("flag")).variationValue).isEqualTo("true") + + // Given + val otherContext = EvaluationContext("$fakeTargetingKey-other", emptyMap()) + whenever(mockAssignmentsDownloader.readPrecomputedFlags(otherContext, fakeDatadogContext)) + .thenReturn(invalidResponse) + + // When + testedManager.updateEvaluationsForContext(otherContext, mockCallback) + + // Then + val states = argumentCaptor() + verify(mockFlagsStateManager, times(6)).updateState(states.capture()) + assertThat(states.lastValue).isInstanceOf(FlagsClientState.Error::class.java) + assertThat(testedRepository.getEvaluationContext()).isEqualTo(context) + } + + @Test + fun `M reject unsolicited encoding W updateEvaluationsForContext() { React Native source }`( + @Mock mockCallback: EvaluationContextCallback + ) { + // Given + fakeDatadogContext = fakeDatadogContext.copy(source = "react-native") + val context = EvaluationContext(fakeTargetingKey, emptyMap()) + val testedRepository = createRepository() + val testedManager = createManagerWithRepository(testedRepository) + whenever(mockAssignmentsDownloader.readPrecomputedFlags(context, fakeDatadogContext)) + .thenReturn(ENCODED_RESPONSE_JSON) + + // When + testedManager.updateEvaluationsForContext(context, mockCallback) + + // Then + val failure = argumentCaptor() + verify(mockCallback).onFailure(failure.capture()) + assertThat(failure.firstValue.message).isEqualTo("Unable to read the feature flags response.") + assertThat(testedRepository.hasFlags()).isFalse() + assertThat(testedRepository.getFlagsSnapshot()).isEmpty() + } + @Test fun `M notify STALE W updateEvaluationsForContext() { cold start network failure, cached flags match context }`() { // Given @@ -1261,6 +1344,8 @@ internal class EvaluationsManagerTest { } whenever(mockSdkCore.internalLogger) doReturn mockInternalLogger + whenever(mockSdkCore.timeProvider) doReturn mock() + val realRepository = DefaultFlagsRepository( featureSdkCore = mockSdkCore, dataStore = mockDataStore, @@ -1295,6 +1380,28 @@ internal class EvaluationsManagerTest { // endregion + private fun createRepository(): DefaultFlagsRepository { + val mockDataStore = mock() + whenever(mockSdkCore.timeProvider) doReturn mock() + whenever(mockSdkCore.internalLogger) doReturn mockInternalLogger + doAnswer { + it.getArgument>(2).onFailure() + }.whenever(mockDataStore).value(any(), anyOrNull(), any(), any()) + return DefaultFlagsRepository(mockSdkCore, fakeTargetingKey, mockDataStore) + } + + private fun createManagerWithRepository(repository: FlagsRepository): EvaluationsManager = EvaluationsManager( + sdkCore = mockSdkCore, + executorService = mockExecutorService, + internalLogger = mockInternalLogger, + flagsRepository = repository, + assignmentsReader = mockAssignmentsDownloader, + precomputeMapper = PrecomputeMapper(mockInternalLogger), + flagStateManager = mockFlagsStateManager, + initializationTimeoutMs = null, + initializationTimeoutScheduler = { _, _ -> {} } + ) + private fun createManager( flagStateManager: FlagsStateManager = mockFlagsStateManager, initializationTimeoutMs: Long? = null, @@ -1312,6 +1419,17 @@ internal class EvaluationsManagerTest { ) companion object { + private val ENCODED_RESPONSE_JSON = """ + {"data":{"attributes":{ + "obfuscated":true, + "obfuscation":{"scheme":"flag-key-sha256-v1","salt":"000102030405060708090a0b0c0d0e0f"}, + "flags":{"9817872c144b018abd77e3915bd77e2c27f4f534dccff8ffaca27361e3a5e1ee":{ + "variationType":"boolean","variationValue":true,"doLog":true, + "allocationKey":"allocation-123","variationKey":"variation-456", + "reason":"TARGETING_MATCH","serialId":123,"extraLogging":{} + }} + }}} + """.trimIndent() private const val EMPTY_FLAGS_RESPONSE_JSON = "{\"data\": {\"attributes\": {\"flags\": {}}}}" } } diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/model/FlagKeyObfuscationTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/model/FlagKeyObfuscationTest.kt new file mode 100644 index 0000000000..cf48458cc7 --- /dev/null +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/model/FlagKeyObfuscationTest.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.flags.internal.model + +import com.google.gson.JsonParseException +import fr.xgouchet.elmyr.annotation.IntForgery +import fr.xgouchet.elmyr.annotation.StringForgery +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.catchThrowable +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource + +@ExtendWith(ForgeExtension::class) +internal class FlagKeyObfuscationTest { + @StringForgery(regex = "[0-9a-f]{32}") + lateinit var fakeSalt: String + + @ParameterizedTest + @MethodSource("vectors") + fun `M match backend hashes W encode() {shared protocol vectors}`(vector: Pair) { + // Given + val (key, digest) = vector + val encoding = FlagKeyObfuscation.fromSalt(SALT) + + // When + val actual = encoding.encode(key) + + // Then + assertThat(actual).isEqualTo(digest) + } + + @Test + fun `M cache bounded lookup hashes W encode() { descriptors keep separate caches }`( + @StringForgery fakeKey: String + ) { + // Given + val encoding = FlagKeyObfuscation.fromSalt(fakeSalt) + val otherSalt = (if (fakeSalt[0] == '0') '1' else '0') + fakeSalt.drop(1) + val other = FlagKeyObfuscation.fromSalt(otherSalt) + + // When + val first = encoding.encode(fakeKey) + + // Then + checkNotNull(first) + assertThat(encoding.encode(fakeKey)).isSameAs(first) + assertThat(other.encode(fakeKey)).isNotEqualTo(first) + assertThat(encoding.encode(fakeKey)).isSameAs(first) + + // When + repeat(1024) { encoding.encode("$fakeKey-$it") } + + // Then + assertThat(encoding.encode(fakeKey)).isEqualTo(first).isNotSameAs(first) + } + + @Test + fun `M reject invalid Unicode W encode() {unpaired high surrogate}`( + @IntForgery(min = 0xd800, max = 0xdc00) fakeSurrogate: Int, + @StringForgery(regex = "[a-z]+") fakePrefix: String + ) { + // Given + val encoding = FlagKeyObfuscation.fromSalt(fakeSalt) + val fakeKey = fakePrefix + fakeSurrogate.toChar() + + // When + val actual = encoding.encode(fakeKey) + + // Then + assertThat(actual).isNull() + } + + @Test + fun `M reject invalid Unicode W encode() {unpaired low surrogate}`( + @IntForgery(min = 0xdc00, max = 0xe000) fakeSurrogate: Int, + @StringForgery(regex = "[a-z]+") fakeSuffix: String + ) { + // Given + val encoding = FlagKeyObfuscation.fromSalt(fakeSalt) + val fakeKey = fakeSurrogate.toChar() + fakeSuffix + + // When + val actual = encoding.encode(fakeKey) + + // Then + assertThat(actual).isNull() + } + + @Test + fun `M reject invalid Unicode W encode() {high surrogate followed by nonsurrogate}`( + @IntForgery(min = 0xd800, max = 0xdc00) fakeSurrogate: Int, + @StringForgery(regex = "[a-z]+") fakeSuffix: String + ) { + // Given + val encoding = FlagKeyObfuscation.fromSalt(fakeSalt) + val fakeKey = fakeSurrogate.toChar() + fakeSuffix + + // When + val actual = encoding.encode(fakeKey) + + // Then + assertThat(actual).isNull() + } + + @Test + fun `M consume both surrogates W encode() {adjacent pairs followed by invalid Unicode}`( + @IntForgery(min = 0xd800, max = 0xdc00) fakeHighSurrogate: Int, + @IntForgery(min = 0xdc00, max = 0xe000) fakeLowSurrogate: Int + ) { + // Given + val encoding = FlagKeyObfuscation.fromSalt(fakeSalt) + val fakePair = "${fakeHighSurrogate.toChar()}${fakeLowSurrogate.toChar()}" + val fakeKey = fakePair + fakePair + + // When + val valid = encoding.encode(fakeKey) + val invalid = encoding.encode(fakeKey + fakeLowSurrogate.toChar()) + + // Then + assertThat(valid).matches("[0-9a-f]{64}") + assertThat(invalid).isNull() + } + + @Test + fun `M reject invalid salt W fromSalt() {invalid length}`( + @IntForgery(min = 0, max = 32) fakeLength: Int + ) { + // Given + val fakeInvalidSalt = fakeSalt.take(fakeLength) + + // When + val exception = catchThrowable { FlagKeyObfuscation.fromSalt(fakeInvalidSalt) } + + // Then + assertThat(exception).isInstanceOf(JsonParseException::class.java) + } + + @Test + fun `M reject invalid salt W fromSalt() {invalid alphabet}`( + @IntForgery(min = 0, max = 32) fakeIndex: Int, + @StringForgery(regex = "[A-Fg-z]") fakeInvalidCharacter: String + ) { + // Given + val fakeInvalidSalt = fakeSalt.replaceRange(fakeIndex, fakeIndex + 1, fakeInvalidCharacter) + + // When + val exception = catchThrowable { FlagKeyObfuscation.fromSalt(fakeInvalidSalt) } + + // Then + assertThat(exception).isInstanceOf(JsonParseException::class.java) + } + + companion object { + // Fixed inputs and expected digests verify the shared backend/SDK protocol, not incidental test data. + private const val SALT = "000102030405060708090a0b0c0d0e0f" + private val VECTORS = listOf( + "new-route-planner" to "a60479237ef2f69175bbe0bd581966d1583766941815dc1d414c883767795190", + "Flag" to "adca75d2141c51b0c0f084c1058edfb8e6e763f91aaf54ca06326d9847bd9586", + "flag" to "9817872c144b018abd77e3915bd77e2c27f4f534dccff8ffaca27361e3a5e1ee", + " flag " to "b1a5f851cc82a3fdf03a461d72a2241584dcf455df1d384e02a937901b3bc80b", + "café" to "3bfa8c3c17c1b61035b98ecf14007cdf5cba5ce61a48e8cfb65f8106541892d3", + "cafe\u0301" to "1e8b7ec5e8028a1ec96b38dd37f6ccea041c0a90358904af40ac5810c23c8765", + "🚲/旗" to "94b611e0d3b26b52f6ad66013d1c72f8a92ea109390049759e75d3c8d3aa4dab", + "a\u0000b" to "bac134d201be5e7f28fc7019248f0809c2a013b137e866446ee71add2bc344c7", + "" to "072d985b427f536ad0a11b2d4c5e0f7e6f0ff6d23e779e082f75599ce3fe3eba" + ) + + @JvmStatic + fun vectors(): List> = VECTORS + } +} diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsRequestFactoryTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsRequestFactoryTest.kt index 3a427c85f7..b357b3ac3c 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsRequestFactoryTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsRequestFactoryTest.kt @@ -64,6 +64,46 @@ internal class PrecomputedAssignmentsRequestFactoryTest { // region create() - Success cases + @Test + fun `M advertise encoding capability W create() { native source }`( + @Forgery fakeEvaluationContext: EvaluationContext + ) { + // Given + val context = fakeDatadogContext.copy(source = "android") + + // When + val request = checkNotNull(testedFactory.create(fakeEvaluationContext, context)) + val buffer = Buffer() + checkNotNull(request.body).writeTo(buffer) + val attributes = JSONObject(buffer.readUtf8()).getJSONObject("data").getJSONObject("attributes") + + // Then + assertThat(request.header("X-DD-FEATURE-FLAGS-CAPABILITIES")) + .isEqualTo("assignment-encoding-flag-key-256-v1") + assertThat(attributes.has("supported_capabilities")).isFalse() + assertThat(attributes.getJSONObject("source").getString("sdk_version")).isEqualTo(context.sdkVersion) + } + + @Test + fun `M omit encoding capability W create() { bridge sources }`( + @Forgery fakeEvaluationContext: EvaluationContext + ) { + for (source in listOf("react-native", "flutter", "unknown")) { + // Given + val context = fakeDatadogContext.copy(source = source) + + // When + val request = checkNotNull(testedFactory.create(fakeEvaluationContext, context)) + val buffer = Buffer() + checkNotNull(request.body).writeTo(buffer) + val attributes = JSONObject(buffer.readUtf8()).getJSONObject("data").getJSONObject("attributes") + + // Then + assertThat(attributes.has("supported_capabilities")).isFalse() + assertThat(request.header("X-DD-FEATURE-FLAGS-CAPABILITIES")).isNull() + } + } + @Test fun `M create valid request W create() { all fields present }`( @StringForgery fakeTargetingKey: String diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateDeserializerTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateDeserializerTest.kt index f31f70f6f1..7c39cebf7e 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateDeserializerTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateDeserializerTest.kt @@ -7,14 +7,18 @@ package com.datadog.android.flags.internal.persistence import com.datadog.android.api.InternalLogger +import com.google.gson.JsonParseException +import com.google.gson.JsonParser import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.junit5.ForgeExtension import org.assertj.core.api.Assertions.assertThat -import org.json.JSONException import org.json.JSONObject import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource +import org.junit.jupiter.params.provider.ValueSource import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings @@ -38,6 +42,41 @@ internal class FlagsStateDeserializerTest { testedDeserializer = FlagsStateDeserializer(mockInternalLogger) } + @Test + fun `M reject malformed encoded keys W deserialize() {valid descriptor}`(forge: Forge) { + // Given + val json = JSONObject(buildStateJson(forge.anAlphabeticalString(), JSONObject())) + .put("obfuscated", true) + .put( + "obfuscation", + JSONObject().put("scheme", "flag-key-sha256-v1") + .put("salt", "000102030405060708090a0b0c0d0e0f") + ) + .put("encodedFlags", JSONObject().put("plaintext-key", buildFlagJson())) + + // When + val actual = testedDeserializer.deserialize(json.toString()) + + // Then + assertThat(actual).isNull() + } + + @ParameterizedTest + @MethodSource("com.datadog.android.flags.internal.serialization.FlagsJsonTest#invalidMetadata") + fun `M reject invalid metadata W deserialize() {encoded cache}`(metadata: String, forge: Forge) { + // Given + val json = JSONObject(buildStateJson(forge.anAlphabeticalString(), JSONObject())) + .put("encodedFlags", JSONObject().put("a".repeat(64), buildFlagJson())) + val descriptor = JSONObject(metadata) + descriptor.keys().forEach { json.put(it, descriptor.get(it)) } + + // When + val actual = testedDeserializer.deserialize(json.toString()) + + // Then + assertThat(actual).isNull() + } + @Test fun `M deserialize flags state W deserialize() { valid JSON }`(forge: Forge) { // Given @@ -171,7 +210,7 @@ internal class FlagsStateDeserializerTest { eq(InternalLogger.Level.ERROR), eq(InternalLogger.Target.MAINTAINER), maintainerMessageCaptor.capture(), - isA(), + isA(), eq(false), eq(null) ) @@ -183,7 +222,7 @@ internal class FlagsStateDeserializerTest { eq(InternalLogger.Level.ERROR), eq(InternalLogger.Target.TELEMETRY), telemetryMessageCaptor.capture(), - isA(), + isA(), eq(true), eq(null) ) @@ -272,7 +311,7 @@ internal class FlagsStateDeserializerTest { eq(InternalLogger.Level.ERROR), eq(InternalLogger.Target.MAINTAINER), isA<() -> String>(), - isA(), + isA(), eq(false), eq(null) ) @@ -319,6 +358,83 @@ internal class FlagsStateDeserializerTest { assertThat(result.evaluationContext.attributes["valid_boolean"]).isEqualTo(validBoolean) } + @ParameterizedTest + @ValueSource(strings = ["null", "[]", "true", "0", "\"text\""]) + fun `M reject nonobject cache W deserialize() { generated model }`(json: String) { + // Given + // When + val result = testedDeserializer.deserialize(json) + + // Then + assertThat(result).isNull() + } + + @ParameterizedTest + @ValueSource(strings = ["null", "[]", "true", "\"invalid\""]) + fun `M preserve legacy attributes fallback W deserialize() { invalid attributes }`( + attributes: String, + forge: Forge + ) { + // Given + val targetingKey = forge.anAlphabeticalString() + val json = JSONObject(buildStateJson(targetingKey, JSONObject().put("flag", buildFlagJson()))) + json.getJSONObject("evaluationContext").put("attributes", JSONObject("{\"value\":$attributes}").get("value")) + json.put("encodedFlags", JSONObject.NULL) + + // When + val result = checkNotNull(testedDeserializer.deserialize(json.toString())) + + // Then + assertThat(result.evaluationContext.targetingKey).isEqualTo(targetingKey) + assertThat(result.evaluationContext.attributes).isEmpty() + assertThat(result.flags.getValue("flag").variationValue).isEqualTo("true") + } + + @ParameterizedTest + @ValueSource(strings = ["null", "[]", "{}", "true", "\"invalid\""]) + fun `M reject invalid cache timestamp W deserialize() { generated model }`(timestamp: String, forge: Forge) { + // Given + val json = JSONObject(buildStateJson(forge.anAlphabeticalString(), JSONObject())) + json.put("lastUpdateTimestamp", JSONObject("{\"value\":$timestamp}").get("value")) + + // When + val result = testedDeserializer.deserialize(json.toString()) + + // Then + assertThat(result).isNull() + } + + @Test + fun `M preserve legacy values W serialize() and deserialize() { generated models }`(forge: Forge) { + // Given + val targetingKey = forge.anAlphabeticalString() + val value = forge.anAlphabeticalString() + val flags = JSONObject() + val variants = listOf("true", forge.anInt().toString(), value, "{\"nested\":[true,null,1]}", "null") + variants.forEachIndexed { index, variant -> + flags.put( + "flag-$index", + buildFlagJson().apply { + put("variationValue", variant) + put("extraLogging", JSONObject("{\"nested\":{\"array\":[true,null,1]},\"text\":\"<>&\"}")) + } + ) + } + val legacy = buildStateJson(targetingKey, flags) + + // When + val loaded = checkNotNull(testedDeserializer.deserialize(legacy)) + val rewritten = FlagsStateSerializer(mockInternalLogger).serialize(loaded) + val restored = checkNotNull(testedDeserializer.deserialize(rewritten)) + + // Then + assertThat(JsonParser.parseString(rewritten)).isEqualTo(JsonParser.parseString(legacy)) + assertThat(restored.evaluationContext).isEqualTo(loaded.evaluationContext) + variants.forEachIndexed { index, variant -> + assertThat(restored.flags.getValue("flag-$index").variationValue).isEqualTo(variant) + } + } + private fun buildFlagJson(): JSONObject = JSONObject().apply { put("variationType", "boolean") put("variationValue", "true") diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateSerializerTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateSerializerTest.kt index 0173a21741..f774526dd0 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateSerializerTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateSerializerTest.kt @@ -7,6 +7,7 @@ package com.datadog.android.flags.internal.persistence import com.datadog.android.api.InternalLogger +import com.datadog.android.flags.internal.model.FlagKeyObfuscation import com.datadog.android.flags.internal.model.FlagsStateEntry import com.datadog.android.flags.internal.model.PrecomputedFlag import com.datadog.android.flags.model.EvaluationContext @@ -37,6 +38,39 @@ internal class FlagsStateSerializerTest { testedSerializer = FlagsStateSerializer(mockInternalLogger) } + @Test + fun `M separate encoded flags from legacy cache W serialize() {obfuscated state}`(forge: Forge) { + // Given + val encoding = FlagKeyObfuscation.fromSalt("000102030405060708090a0b0c0d0e0f") + val key = forge.anAlphabeticalString() + val encodedKey = checkNotNull(encoding.encode(key)) + val flag = PrecomputedFlag( + "boolean", + "true", + true, + forge.anAlphabeticalString(), + forge.anAlphabeticalString(), + JSONObject(), + "TARGETING_MATCH" + ) + val state = FlagsStateEntry( + EvaluationContext(forge.anAlphabeticalString()), + mapOf(encodedKey to flag), + fakeTimestamp, + encoding + ) + + // When + val json = JSONObject(testedSerializer.serialize(state)) + + // Then + assertThat(json.has("flags")).isFalse() + assertThat(json.getJSONObject("encodedFlags").keys().asSequence().toList()).containsExactly(encodedKey) + assertThat(json.getBoolean("obfuscated")).isTrue() + assertThat(json.getJSONObject("obfuscation").getString("salt")).isEqualTo(encoding.salt) + assertThat(json.getJSONObject("obfuscation").getString("scheme")).isEqualTo(FlagKeyObfuscation.SCHEME) + } + @Test fun `M serialize complete flags state W serialize()`(forge: Forge) { // Given diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepositoryObfuscationTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepositoryObfuscationTest.kt new file mode 100644 index 0000000000..d8255cb4fe --- /dev/null +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepositoryObfuscationTest.kt @@ -0,0 +1,204 @@ +/* + * 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.flags.internal.repository + +import com.datadog.android.api.InternalLogger +import com.datadog.android.api.feature.Feature +import com.datadog.android.api.storage.datastore.DataStoreHandler +import com.datadog.android.api.storage.datastore.DataStoreReadCallback +import com.datadog.android.core.InternalSdkCore +import com.datadog.android.core.persistence.datastore.DataStoreContent +import com.datadog.android.flags.internal.model.FlagKeyObfuscation +import com.datadog.android.flags.internal.model.FlagsStateEntry +import com.datadog.android.flags.internal.model.PrecomputedFlag +import com.datadog.android.flags.model.EvaluationContext +import com.datadog.android.flags.model.ResolutionReason +import com.datadog.android.flags.utils.forge.ForgeConfigurator +import com.datadog.android.internal.time.TimeProvider +import fr.xgouchet.elmyr.annotation.Forgery +import fr.xgouchet.elmyr.annotation.LongForgery +import fr.xgouchet.elmyr.annotation.StringForgery +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +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.anyOrNull +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness + +@ExtendWith(MockitoExtension::class, ForgeExtension::class) +@ForgeConfiguration(ForgeConfigurator::class) +@MockitoSettings(strictness = Strictness.LENIENT) +internal class DefaultFlagsRepositoryObfuscationTest { + private lateinit var testedRepository: DefaultFlagsRepository + private lateinit var encoding: FlagKeyObfuscation + + @StringForgery(regex = "[0-9a-f]{32}") + lateinit var fakeSalt: String + private lateinit var encodedKey: String + + @Mock + lateinit var mockInternalLogger: InternalLogger + + @Mock + lateinit var mockSdkCore: InternalSdkCore + + @Mock + lateinit var mockDataStore: DataStoreHandler + + @Mock + lateinit var mockTimeProvider: TimeProvider + + @Forgery + lateinit var fakeContext: EvaluationContext + + @Forgery + lateinit var fakeFlag: PrecomputedFlag + + @StringForgery + lateinit var fakeInstanceName: String + + @StringForgery + lateinit var fakeFlagKey: String + + @LongForgery(min = 0L) + var fakeTimestamp = 0L + + @BeforeEach + fun setUp() { + encoding = FlagKeyObfuscation.fromSalt(fakeSalt) + encodedKey = checkNotNull(encoding.encode(fakeFlagKey)) + whenever(mockSdkCore.internalLogger) doReturn mockInternalLogger + whenever(mockSdkCore.timeProvider) doReturn mockTimeProvider + whenever(mockSdkCore.source) doReturn "android" + doAnswer { it.getArgument>(2).onFailure() } + .whenever(mockDataStore).value(any(), anyOrNull(), any(), any()) + testedRepository = DefaultFlagsRepository(mockSdkCore, fakeInstanceName, mockDataStore) + } + + @Test + fun `M restore encoded cache W DefaultFlagsRepository() { native consumer }`() { + // Given + val entry = cachedEntry(encoded = true) + doAnswer { it.getArgument>(2).onSuccess(DataStoreContent(0, entry)) } + .whenever(mockDataStore).value(any(), anyOrNull(), any(), any()) + + // When + val testedRepository = DefaultFlagsRepository(mockSdkCore, fakeInstanceName, mockDataStore) + + // Then + val (flag, context) = checkNotNull(testedRepository.getPrecomputedFlagWithContext(fakeFlagKey)) + assertThat(context).isEqualTo(fakeContext) + checkNotNull(flag) + assertThat(flag.variationValue).isEqualTo(fakeFlag.variationValue) + assertThat(flag.reason).isEqualTo(ResolutionReason.CACHED.name) + assertThat(testedRepository.getPrecomputedFlag(encodedKey)).isNull() + } + + @ParameterizedTest + @MethodSource("cacheInitializations") + fun `M gate cached assignments W getPrecomputedFlag() {synchronous source and delayed disk}`( + source: String, + encoded: Boolean + ) { + // Given + var diskCallback: DataStoreReadCallback? = null + doAnswer { diskCallback = it.getArgument(2) } + .whenever(mockDataStore).value(any(), anyOrNull(), any(), any()) + whenever(mockSdkCore.source) doReturn source + val entry = cachedEntry(encoded) + testedRepository = DefaultFlagsRepository(mockSdkCore, fakeInstanceName, mockDataStore) + val firstFlags = mutableListOf>() + testedRepository.firstFlags.whenComplete { firstFlags.add(it) } + + // When + checkNotNull(diskCallback).onSuccess(DataStoreContent(0, entry)) + val result = testedRepository.getPrecomputedFlag(fakeFlagKey) + + // Then + val readable = !encoded || source == "android" + assertThat(result?.variationValue).isEqualTo(if (readable) fakeFlag.variationValue else null) + assertThat(testedRepository.hasFlags()).isEqualTo(readable) + assertThat(testedRepository.hasLoadedFlagsForContext(fakeContext)).isEqualTo(readable) + assertThat(testedRepository.getEvaluationContext()).isEqualTo(if (readable) fakeContext else null) + assertThat(testedRepository.getPrecomputedFlagWithContext(fakeFlagKey) != null).isEqualTo(readable) + assertThat(testedRepository.getFlagsSnapshot().isEmpty()).isEqualTo(encoded) + assertThat(firstFlags).hasSize(if (readable) 1 else 0) + if (readable) { + assertThat(firstFlags.single()).isEqualTo(if (encoded) emptyList() else listOf(fakeFlagKey)) + } + verify(mockSdkCore, never()).getFeature(Feature.FLAGS_FEATURE_NAME) + } + + @Test + fun `M notify first flags W setFlagsAndContext() { encoded network response }`() { + // Given + val firstFlags = mutableListOf>() + testedRepository.firstFlags.whenComplete { firstFlags.add(it) } + + // When + install(encoded = true) + install(encoded = true) + + // Then + assertThat(firstFlags).containsExactly(emptyList()) + assertThat(testedRepository.getPrecomputedFlag(fakeFlagKey) != null).isTrue() + } + + @Test + fun `M notify first readable flags W setFlagsAndContext() { wrapper replaces encoded cache }`() { + // Given + val entry = cachedEntry(encoded = true) + whenever(mockSdkCore.source) doReturn "react-native" + doAnswer { it.getArgument>(2).onSuccess(DataStoreContent(0, entry)) } + .whenever(mockDataStore).value(any(), anyOrNull(), any(), any()) + testedRepository = DefaultFlagsRepository(mockSdkCore, fakeInstanceName, mockDataStore) + val firstFlags = mutableListOf>() + testedRepository.firstFlags.whenComplete { firstFlags.add(it) } + assertThat(firstFlags).isEmpty() + + // When + install(encoded = false) + + // Then + assertThat(firstFlags).containsExactly(listOf(fakeFlagKey)) + assertThat(testedRepository.getFlagsSnapshot()).containsOnlyKeys(fakeFlagKey) + } + + private fun cachedEntry(encoded: Boolean): FlagsStateEntry = FlagsStateEntry( + fakeContext, + mapOf((if (encoded) encodedKey else fakeFlagKey) to fakeFlag), + fakeTimestamp, + encoding.takeIf { encoded } + ) + + private fun install(encoded: Boolean) { + val entry = cachedEntry(encoded) + testedRepository.setFlagsAndContext(entry.evaluationContext, entry.flags, entry.obfuscation) + } + + companion object { + @JvmStatic + fun cacheInitializations(): List = + listOf("android", "react-native", "future-bridge").flatMap { source -> + listOf(true, false).map { encoded -> Arguments.of(source, encoded) } + } + } +} diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepositoryTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepositoryTest.kt index 6ccdfbe060..38903f61b2 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepositoryTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepositoryTest.kt @@ -12,6 +12,7 @@ import com.datadog.android.api.storage.datastore.DataStoreHandler import com.datadog.android.api.storage.datastore.DataStoreReadCallback import com.datadog.android.core.persistence.datastore.DataStoreContent import com.datadog.android.flags.internal.model.FlagsStateEntry +import com.datadog.android.flags.internal.model.PrecomputedAssignments import com.datadog.android.flags.internal.model.PrecomputedFlag import com.datadog.android.flags.model.EvaluationContext import com.datadog.android.flags.model.ResolutionReason @@ -128,6 +129,18 @@ internal class DefaultFlagsRepositoryTest { ) } + @Test + fun `M store generated assignments W setFlagsAndContext() { forged assignments }`(forge: Forge) { + // Given + val assignments = forge.getForgery() + + // When + testedRepository.setFlagsAndContext(testContext, assignments.flags, assignments.obfuscation) + + // Then + assertThat(testedRepository.getFlagsSnapshot()).isEqualTo(assignments.flags) + } + @Test fun `M return snapshot without waiting W getPrecomputedFlagWithContext() {persistence pending}`(forge: Forge) { // Given diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/net/PrecomputeMapperTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/net/PrecomputeMapperTest.kt index 8b127ccfac..82000ab113 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/net/PrecomputeMapperTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/net/PrecomputeMapperTest.kt @@ -22,6 +22,9 @@ import org.json.JSONObject import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource +import org.junit.jupiter.params.provider.ValueSource import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.kotlin.any @@ -70,6 +73,84 @@ internal class PrecomputeMapperTest { // region Valid JSON Parsing + @ParameterizedTest + @MethodSource("com.datadog.android.flags.internal.serialization.FlagsJsonTest#invalidMetadata") + fun `M reject invalid metadata W map() {encoded response}`(metadata: String) { + // Given + val attributes = JSONObject(metadata).put("flags", JSONObject()) + val response = JSONObject().put("data", JSONObject().put("attributes", attributes)).toString() + + // When + val actual = testedMapper.map(response) + + // Then + assertThat(actual).isNull() + } + + @ParameterizedTest + @ValueSource( + strings = [ + "null", "[]", "true", "0", "\"text\"", + "{\"data\":null}", "{\"data\":[]}", + "{\"data\":{\"attributes\":null}}", "{\"data\":{\"attributes\":[]}}", + "{\"data\":{\"attributes\":{\"flags\":null}}}", + "{\"data\":{\"attributes\":{\"flags\":[]}}}" + ] + ) + fun `M reject malformed structure W map() { generated model }`(json: String) { + // Given + // When + val result = testedMapper.map(json) + + // Then + assertThat(result).isNull() + } + + @ParameterizedTest + @ValueSource(strings = ["null", "[]", "{}", "0", "\"invalid\""]) + fun `M reject invalid logging control W map() { generated model }`(value: String) { + // Given + val flag = buildFlagJson(VariationType.BOOLEAN.value, true) + flag.put("doLog", JSONObject("{\"value\":$value}").get("value")) + val json = buildValidJson(mapOf(fakeFlagKey to flag)) + + // When + val result = testedMapper.map(json) + + // Then + assertThat(result).isNull() + } + + @ParameterizedTest + @ValueSource(strings = ["true", "TRUE", "false", "FALSE"]) + fun `M preserve boolean string logging control W map() {legacy response}`(value: String) { + // Given + val flag = buildFlagJson(VariationType.BOOLEAN.value, true).put("doLog", value) + val json = buildValidJson(mapOf(fakeFlagKey to flag)) + + // When + val result = checkNotNull(testedMapper.map(json)).flags.getValue(fakeFlagKey) + + // Then + assertThat(result.doLog).isEqualTo(value.equals("true", ignoreCase = true)) + } + + @ParameterizedTest + @ValueSource(strings = ["null", "[]", "{}", "true", "\"123\""]) + fun `M ignore nonnumeric serial id W map() { generated model }`(value: String) { + // Given + val flag = buildFlagJson(VariationType.BOOLEAN.value, true) + flag.put("serialId", JSONObject("{\"value\":$value}").get("value")) + val json = buildValidJson(mapOf(fakeFlagKey to flag)) + + // When + val result = checkNotNull(testedMapper.map(json)).flags.getValue(fakeFlagKey) + + // Then + assertThat(result.serialId).isNull() + assertThat(result.variationValue).isEqualTo("true") + } + @Test fun `M parse single flag with string variation W map() { valid JSON }`() { // Given @@ -84,7 +165,7 @@ internal class PrecomputeMapperTest { ) // When - val result = checkNotNull(testedMapper.map(json)) + val result = checkNotNull(testedMapper.map(json)).flags // Then assertThat(result).hasSize(1) @@ -113,7 +194,7 @@ internal class PrecomputeMapperTest { ) // When - val result = checkNotNull(testedMapper.map(json)) + val result = checkNotNull(testedMapper.map(json)).flags // Then assertThat(result).hasSize(1) @@ -137,7 +218,7 @@ internal class PrecomputeMapperTest { ) // When - val result = checkNotNull(testedMapper.map(json)) + val result = checkNotNull(testedMapper.map(json)).flags // Then assertThat(result).hasSize(1) @@ -161,7 +242,7 @@ internal class PrecomputeMapperTest { ) // When - val result = checkNotNull(testedMapper.map(json)) + val result = checkNotNull(testedMapper.map(json)).flags // Then assertThat(result).hasSize(1) @@ -185,7 +266,7 @@ internal class PrecomputeMapperTest { ) // When - val result = checkNotNull(testedMapper.map(json)) + val result = checkNotNull(testedMapper.map(json)).flags // Then assertThat(result).hasSize(1) @@ -213,7 +294,7 @@ internal class PrecomputeMapperTest { ) // When - val result = checkNotNull(testedMapper.map(json)) + val result = checkNotNull(testedMapper.map(json)).flags // Then assertThat(result).hasSize(1) @@ -242,7 +323,7 @@ internal class PrecomputeMapperTest { ) // When - val result = checkNotNull(testedMapper.map(json)) + val result = checkNotNull(testedMapper.map(json)).flags // Then assertThat(result).hasSize(1) @@ -277,7 +358,7 @@ internal class PrecomputeMapperTest { ) // When - val result = checkNotNull(testedMapper.map(json)) + val result = checkNotNull(testedMapper.map(json)).flags // Then assertThat(result).hasSize(3) @@ -304,7 +385,7 @@ internal class PrecomputeMapperTest { val json = buildValidJson(emptyMap()) // When - val result = checkNotNull(testedMapper.map(json)) + val result = checkNotNull(testedMapper.map(json)).flags // Then assertThat(result).isEmpty() @@ -325,7 +406,7 @@ internal class PrecomputeMapperTest { val json = buildValidJson(mapOf(fakeFlagKey to flagJson)) // When - val result = checkNotNull(testedMapper.map(json)) + val result = checkNotNull(testedMapper.map(json)).flags // Then val flag = result[fakeFlagKey] @@ -344,7 +425,7 @@ internal class PrecomputeMapperTest { val json = buildValidJson(mapOf(fakeFlagKey to flagJson)) // When - val result = checkNotNull(testedMapper.map(json)) + val result = checkNotNull(testedMapper.map(json)).flags // Then val flag = result[fakeFlagKey] @@ -363,7 +444,7 @@ internal class PrecomputeMapperTest { val json = buildValidJson(mapOf(fakeFlagKey to flagJson)) // When - val result = checkNotNull(testedMapper.map(json)) + val result = checkNotNull(testedMapper.map(json)).flags // Then val flag = result[fakeFlagKey] @@ -385,7 +466,7 @@ internal class PrecomputeMapperTest { ) // When - val result = checkNotNull(testedMapper.map(json)) + val result = checkNotNull(testedMapper.map(json)).flags // Then val flag = result[fakeFlagKey] @@ -573,7 +654,7 @@ internal class PrecomputeMapperTest { }.toString() // When - val result = checkNotNull(testedMapper.map(json)) + val result = checkNotNull(testedMapper.map(json)).flags // Then assertThat(result).hasSize(1) diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/serialization/FlagsJsonTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/serialization/FlagsJsonTest.kt new file mode 100644 index 0000000000..4782c0e00a --- /dev/null +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/serialization/FlagsJsonTest.kt @@ -0,0 +1,93 @@ +/* + * 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.flags.internal.serialization + +import com.google.gson.JsonParseException +import com.google.gson.JsonParser +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.catchThrowable +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource +import org.junit.jupiter.params.provider.ValueSource + +internal class FlagsJsonTest { + @ParameterizedTest + @MethodSource("invalidMetadata") + fun `M reject invalid descriptor W readObfuscation() {strict encoding contract}`(json: String) { + // Given + val metadata = JsonParser.parseString(json).asJsonObject + + // When + val exception = catchThrowable { FlagsJson.readObfuscation(metadata) } + + // Then + assertThat(exception).isInstanceOf(JsonParseException::class.java) + } + + @ParameterizedTest + @ValueSource(strings = ["{}", "{\"obfuscated\":false}", "{\"unrecognized\":true}"]) + fun `M accept plaintext W readObfuscation() {legacy metadata}`(json: String) { + // Given + val metadata = JsonParser.parseString(json).asJsonObject + + // When + val actual = FlagsJson.readObfuscation(metadata) + + // Then + assertThat(actual).isNull() + } + + @ParameterizedTest + @ValueSource(strings = ["plaintext", "", "UPPERCASE"]) + fun `M reject malformed flag key W validateKeys() {encoded assignments}`(key: String) { + // Given + val keys = listOf(key).iterator() + + // When + val exception = catchThrowable { FlagsJson.validateKeys(keys) } + + // Then + assertThat(exception).isInstanceOf(JsonParseException::class.java) + } + + @ParameterizedTest + @ValueSource(ints = [63, 65]) + fun `M reject incorrect digest length W validateKeys() {encoded assignments}`(length: Int) { + // Given + val keys = listOf("a".repeat(length)).iterator() + + // When + val exception = catchThrowable { FlagsJson.validateKeys(keys) } + + // Then + assertThat(exception).isInstanceOf(JsonParseException::class.java) + } + + companion object { + @JvmStatic + fun invalidMetadata(): List { + val valid = """{"obfuscated":true,"obfuscation":""" + + """{"scheme":"flag-key-sha256-v1","salt":"000102030405060708090a0b0c0d0e0f"}}""" + return listOf( + """{"obfuscated":true}""", + """{"obfuscated":null}""", + """{"obfuscated":true,"obfuscation":null}""", + """{"obfuscated":true,"obfuscation":[]}""", + valid.replace("true", "\"true\""), + valid.replace("true", "false"), + valid.replace("\"obfuscated\":true,", ""), + valid.replace("flag-key-sha256-v1", "unknown"), + valid.replace("\"flag-key-sha256-v1\"", "1"), + valid.replace("\"flag-key-sha256-v1\"", "null") + ) + listOf( + "\"\"", "\"${"0".repeat(30)}\"", "\"${"0".repeat(34)}\"", + "\"${"G".repeat(32)}\"", "\"${"A".repeat(32)}\"", "\"${"0".repeat(31)}\\n\"", + "42", "true", "null", "[]", "{}" + ).map { valid.replace("\"000102030405060708090a0b0c0d0e0f\"", it) } + } + } +} diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/storage/ExposureEventRecordWriterTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/storage/ExposureEventRecordWriterTest.kt index ec8b941ae4..4d51373008 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/storage/ExposureEventRecordWriterTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/storage/ExposureEventRecordWriterTest.kt @@ -14,7 +14,7 @@ import com.datadog.android.api.feature.FeatureSdkCore import com.datadog.android.api.storage.EventBatchWriter import com.datadog.android.api.storage.EventType import com.datadog.android.api.storage.RawBatchEvent -import com.datadog.android.flags.model.ExposureEvent +import com.datadog.android.flags.internal.model.ExposureEvent import com.datadog.android.flags.utils.forge.ForgeConfigurator import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.annotation.LongForgery diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/utils/forge/FlagEvaluationForgeryFactory.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/utils/forge/FlagEvaluationForgeryFactory.kt index 16863c5e9e..01f4f21c21 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/utils/forge/FlagEvaluationForgeryFactory.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/utils/forge/FlagEvaluationForgeryFactory.kt @@ -6,7 +6,7 @@ package com.datadog.android.flags.utils.forge -import com.datadog.android.flags.model.FlagEvaluation +import com.datadog.android.flags.internal.model.FlagEvaluation import com.datadog.android.flags.model.ResolutionReason import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.ForgeryFactory diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/utils/forge/ForgeConfigurator.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/utils/forge/ForgeConfigurator.kt index 312c3ea65b..1029791a41 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/utils/forge/ForgeConfigurator.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/utils/forge/ForgeConfigurator.kt @@ -20,6 +20,7 @@ internal class ForgeConfigurator : BaseConfigurator() { forge.addFactory(FlagsConfigurationForgeryFactory()) forge.addFactory(PrecomputedFlagForgeryFactory()) + forge.addFactory(PrecomputedAssignmentsForgeryFactory()) forge.addFactory(FlagEvaluationForgeryFactory()) forge.addFactory(EvaluationContextForgeryFactory()) forge.addFactory(EvaluationAggregationStatsForgeryFactory()) diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/utils/forge/PrecomputedAssignmentsForgeryFactory.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/utils/forge/PrecomputedAssignmentsForgeryFactory.kt new file mode 100644 index 0000000000..e54bdf06a9 --- /dev/null +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/utils/forge/PrecomputedAssignmentsForgeryFactory.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.flags.utils.forge + +import com.datadog.android.flags.internal.model.PrecomputedAssignments +import com.datadog.android.flags.internal.model.PrecomputedFlag +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.ForgeryFactory + +internal class PrecomputedAssignmentsForgeryFactory : ForgeryFactory { + override fun getForgery(forge: Forge): PrecomputedAssignments = PrecomputedAssignments( + flags = forge.aMap { anAlphabeticalString() to getForgery() } + ) +} diff --git a/reliability/single-fit/flags/src/test/kotlin/com/datadog/android/flags/integration/EvaluationTelemetryTest.kt b/reliability/single-fit/flags/src/test/kotlin/com/datadog/android/flags/integration/EvaluationTelemetryTest.kt index d40b1c28aa..e53ed4aaeb 100644 --- a/reliability/single-fit/flags/src/test/kotlin/com/datadog/android/flags/integration/EvaluationTelemetryTest.kt +++ b/reliability/single-fit/flags/src/test/kotlin/com/datadog/android/flags/integration/EvaluationTelemetryTest.kt @@ -9,11 +9,13 @@ package com.datadog.android.flags.integration import com.datadog.android.api.feature.Feature import com.datadog.android.flags.FlagsClient import com.datadog.android.flags._FlagsInternalProxy +import com.datadog.android.flags.integration.FlagsObfuscationTest.Companion.readFixture import com.datadog.android.flags.integration.tests.elmyr.FlagsIntegrationForgeConfigurator import com.datadog.android.flags.integration.tests.utils.FlagsTelemetryTestFixture import com.datadog.android.flags.integration.tests.utils.FlagsTelemetryTestFixture.Companion.CONTROL import com.datadog.android.flags.integration.tests.utils.MainLooperTestConfiguration import com.datadog.android.flags.integration.tests.utils.bodyAsJson +import com.datadog.android.flags.integration.tests.utils.httpResponse import com.datadog.android.flags.model.ErrorCode import com.datadog.android.flags.model.EvaluationContext import com.datadog.android.flags.model.ResolutionReason @@ -26,6 +28,8 @@ import com.datadog.tools.unit.extensions.config.TestConfiguration import dev.openfeature.kotlin.sdk.ImmutableContext import dev.openfeature.kotlin.sdk.Value import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.annotation.BoolForgery +import fr.xgouchet.elmyr.annotation.StringForgery import fr.xgouchet.elmyr.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension import kotlinx.coroutines.CoroutineStart @@ -41,6 +45,8 @@ import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.MethodSource import org.junit.jupiter.params.provider.ValueSource +import java.security.MessageDigest +import java.util.Locale import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import dev.openfeature.kotlin.sdk.EvaluationContext as OpenFeatureEvaluationContext @@ -60,6 +66,37 @@ internal class EvaluationTelemetryTest { fixture.close() } + @ParameterizedTest + @ValueSource(booleans = [true, false]) + fun `M preserve telemetry policy W resolve() {encoded assignments and missing flag}`( + optedOut: Boolean, + @StringForgery fakeTargetingKey: String, + @StringForgery fakeFlagKey: String, + @StringForgery(regex = "[0-9a-f]{32}") fakeSalt: String, + @BoolForgery fakeValue: Boolean + ) { + // Given + val response = encodedResponse(fakeFlagKey, fakeValue, fakeSalt) + fixture.stubCore.stubNetworkResponses { httpResponse(it, body = response) } + val client = newClient() + client.setEvaluationContext(EvaluationContext(fakeTargetingKey)) + client.setEvaluationTelemetryOptOut(optedOut) + + // When + val assigned = client.resolve(fakeFlagKey, !fakeValue) + val missing = client.resolve("$fakeFlagKey-missing", !fakeValue) + fixture.flush() + + // Then + assertThat(assigned.value).isEqualTo(fakeValue) + assertThat(missing.errorCode).isEqualTo(ErrorCode.FLAG_NOT_FOUND) + assertThat(fixture.evaluationCount()).isEqualTo(if (optedOut) 0 else 2) + assertThat(fixture.stubCore.eventsReceived(Feature.RUM_FEATURE_NAME)).hasSize(if (optedOut) 0 else 1) + val exposure = fixture.events(Feature.FLAGS_FEATURE_NAME).single() + assertThat(exposure["flag"].asJsonObject["key"].asString).isEqualTo(fakeFlagKey) + assertThat(exposure["subject"].asJsonObject["id"].asString).isEqualTo(fakeTargetingKey) + } + @ParameterizedTest @MethodSource("interleavedContextUpdates") fun `M bind policy to submitted context W context update() {concurrent conversion}`( @@ -594,6 +631,20 @@ internal class EvaluationTelemetryTest { assertThat(fixture.evaluationCount()).isEqualTo(1) } + private fun encodedResponse(key: String, value: Boolean, salt: String): String { + val response = JSONObject(readFixture("plaintext")) + val attributes = response.getJSONObject("data").getJSONObject("attributes") + val flag = attributes.getJSONObject("flags").getJSONObject("flag").put("variationValue", value) + val saltBytes = salt.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + val input = "datadog.feature-flags.flag-key.v1\u0000".toByteArray() + saltBytes + key.toByteArray() + val encodedKey = MessageDigest.getInstance("SHA-256").digest(input) + .joinToString("") { "%02x".format(Locale.US, it) } + attributes.put("flags", JSONObject().put(encodedKey, flag)) + attributes.put("obfuscated", true) + attributes.put("obfuscation", JSONObject().put("scheme", "flag-key-sha256-v1").put("salt", salt)) + return response.toString() + } + private fun newClient(activated: Boolean = true, name: String = "client"): FlagsClient = FlagsClient.Builder(name, fixture.core).enableEvaluationTelemetryOptOutParameter(activated).build() diff --git a/reliability/single-fit/flags/src/test/kotlin/com/datadog/android/flags/integration/FlagsObfuscationTest.kt b/reliability/single-fit/flags/src/test/kotlin/com/datadog/android/flags/integration/FlagsObfuscationTest.kt new file mode 100644 index 0000000000..3c152bc2e1 --- /dev/null +++ b/reliability/single-fit/flags/src/test/kotlin/com/datadog/android/flags/integration/FlagsObfuscationTest.kt @@ -0,0 +1,219 @@ +/* + * 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.flags.integration + +import com.datadog.android.api.feature.Feature +import com.datadog.android.core.stub.StubSDKCore +import com.datadog.android.flags.Flags +import com.datadog.android.flags.FlagsClient +import com.datadog.android.flags.FlagsConfiguration +import com.datadog.android.flags._FlagsInternalProxy +import com.datadog.android.flags.integration.FlagsClientTest.Companion.createStubSdkCore +import com.datadog.android.flags.integration.tests.elmyr.FlagsIntegrationForgeConfigurator +import com.datadog.android.flags.integration.tests.utils.httpResponse +import com.datadog.android.flags.model.ErrorCode +import com.datadog.android.flags.model.EvaluationContext +import com.datadog.android.flags.model.FlagsClientState +import com.datadog.android.flags.model.ResolutionReason +import fr.xgouchet.elmyr.Forge +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.json.JSONObject +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.quality.Strictness + +@ExtendWith(MockitoExtension::class, ForgeExtension::class) +@ForgeConfiguration(FlagsIntegrationForgeConfigurator::class) +@MockitoSettings(strictness = Strictness.LENIENT) +class FlagsObfuscationTest { + private lateinit var stubSdkCore: StubSDKCore + + @StringForgery + lateinit var fakeTargetingKey: String + + @BeforeEach + fun setUp(forge: Forge) { + stubSdkCore = createStubSdkCore(forge) + stubSdkCore.stubDataStore(Feature.FLAGS_FEATURE_NAME) + } + + @ParameterizedTest + @ValueSource(strings = ["plaintext", "encoded"]) + fun `M preserve values and errors W resolve() {plaintext and obfuscated responses}`(fixture: String) { + // Given + stubAssignments(fixture) + val client = createClient() + + // When + client.setEvaluationContext(EvaluationContext(fakeTargetingKey)) + + // Then + assertThat(client.state.getCurrentState()).isEqualTo(FlagsClientState.Ready) + assertThat(stubSdkCore.requestsSent().single().header("X-DD-FEATURE-FLAGS-CAPABILITIES")) + .isEqualTo("assignment-encoding-flag-key-256-v1") + assertThat(client.resolveBooleanValue("flag", false)).isTrue() + assertThat(client.resolveStringValue("string", "default")).isEqualTo("visible") + assertThat(client.resolveIntValue("integer", -1)).isEqualTo(42) + assertThat(client.resolveDoubleValue("number", -1.0)).isEqualTo(12.5) + assertThat(client.resolveStructureValue("object", emptyMap())).containsKey("nested") + assertThat(client.resolve("integer", "default").errorCode).isEqualTo(ErrorCode.TYPE_MISMATCH) + assertThat(client.resolve("bad", false).errorCode).isEqualTo(ErrorCode.PARSE_ERROR) + assertThat(client.resolve("missing", false).errorCode).isEqualTo(ErrorCode.FLAG_NOT_FOUND) + assertThat(client.resolveBooleanValue("new-flag", false)).isTrue() + assertThat(client.resolve("future", false).value).isFalse() + assertThat(client.resolve("flag", false).flagMetadata) + .containsEntry("allocationKey", "allocation").containsEntry("test", "metadata") + assertThat(client.resolve("\ud800", false).errorCode).isEqualTo(ErrorCode.FLAG_NOT_FOUND) + } + + @ParameterizedTest + @ValueSource(booleans = [true, false]) + fun `M keep original telemetry keys W resolveBooleanValue() {salt changes}`(doLog: Boolean) { + // Given + val client = createClient() + val context = EvaluationContext(fakeTargetingKey) + + // When + listOf("encoded", "refreshed", "encoded").forEach { fixture -> + val response = JSONObject(readFixture(fixture)) + val flags = response.getJSONObject("data").getJSONObject("attributes").getJSONObject("flags") + flags.keys().forEach { flags.getJSONObject(it).put("doLog", doLog) } + stubSdkCore.stubNetworkResponses { httpResponse(it, body = response.toString()) } + client.setEvaluationContext(context) + client.resolveBooleanValue("flag", false) + } + stubSdkCore.stopFeatures() + + // Then + val exposures = stubSdkCore.eventsWritten(Feature.FLAGS_FEATURE_NAME) + assertThat(exposures).hasSize(if (doLog) 1 else 0) + if (doLog) { + val exposure = JSONObject(exposures.single().eventData) + assertThat(exposure.getJSONObject("flag").getString("key")).isEqualTo("flag") + assertThat(exposure.getJSONObject("subject").getString("id")).isEqualTo(fakeTargetingKey) + assertThat(exposure.getLong("serial_id")).isEqualTo(123) + } + val evaluation = + JSONObject(stubSdkCore.eventsWritten(Feature.FLAGS_EVALUATIONS_FEATURE_NAME).single().eventData) + assertThat(evaluation.getJSONObject("flag").getString("key")).isEqualTo("flag") + assertThat(evaluation.getInt("evaluation_count")).isEqualTo(3) + } + + @Test + fun `M restore latest salt and value W build() {restart without network}`(forge: Forge) { + // Given + val store = stubSdkCore.stubDataStore(Feature.FLAGS_FEATURE_NAME) + val client = createClient() + stubAssignments("encoded") + client.setEvaluationContext(EvaluationContext(fakeTargetingKey)) + assertThat(client.resolveBooleanValue("flag", false)).isTrue() + stubAssignments("refreshed") + client.setEvaluationContext(EvaluationContext(fakeTargetingKey)) + assertThat(client.resolveBooleanValue("flag", true)).isFalse() + val restartedCore = createStubSdkCore(forge) + restartedCore.stubDataStore(Feature.FLAGS_FEATURE_NAME, store) + restartedCore.stubNetworkResponses { null } + + // When + val restoredClient = createClient(restartedCore) + val details = restoredClient.resolve("flag", true) + + // Then + assertThat(details.value).isFalse() + assertThat(details.reason).isEqualTo(ResolutionReason.CACHED) + assertThat(restartedCore.requestsSent()).isEmpty() + assertThat(_FlagsInternalProxy(restoredClient).getFlagAssignmentsSnapshot()).isEmpty() + val exposure = JSONObject(restartedCore.eventsWritten(Feature.FLAGS_FEATURE_NAME).single().eventData) + assertThat(exposure.getJSONObject("flag").getString("key")).isEqualTo("flag") + assertThat(exposure.getJSONObject("subject").getString("id")).isEqualTo(fakeTargetingKey) + } + + @ParameterizedTest + @ValueSource(strings = ["unknown-scheme", "invalid-salt", "plaintext-key"]) + fun `M retain valid assignments W setEvaluationContext() {invalid encoded response}`( + failure: String, + forge: Forge + ) { + // Given + val store = stubSdkCore.stubDataStore(Feature.FLAGS_FEATURE_NAME) + stubAssignments("encoded") + val client = createClient() + val context = EvaluationContext(fakeTargetingKey) + client.setEvaluationContext(context) + val response = JSONObject(readFixture("refreshed")) + val attributes = response.getJSONObject("data").getJSONObject("attributes") + when (failure) { + "unknown-scheme" -> attributes.getJSONObject("obfuscation").put("scheme", "unsupported") + "invalid-salt" -> attributes.getJSONObject("obfuscation").put("salt", "bad-salt") + "plaintext-key" -> { + val flags = attributes.getJSONObject("flags") + flags.put("plaintext", flags.get(flags.keys().next())) + } + } + stubSdkCore.stubNetworkResponses { httpResponse(it, body = response.toString()) } + + // When + client.setEvaluationContext(context) + + // Then + assertThat(client.state.getCurrentState()).isInstanceOf(FlagsClientState.Stale::class.java) + assertThat(client.resolveBooleanValue("flag", false)).isTrue() + val restartedCore = createStubSdkCore(forge) + restartedCore.stubDataStore(Feature.FLAGS_FEATURE_NAME, store) + restartedCore.stubNetworkResponses { null } + assertThat(createClient(restartedCore).resolveBooleanValue("flag", false)).isTrue() + } + + @Test + fun `M hide hashed snapshot keys W getFlagAssignmentsSnapshot() {encoded response}`() { + // Given + val client = createClient() + stubAssignments("encoded") + client.setEvaluationContext(EvaluationContext(fakeTargetingKey)) + + // When + val encodedSnapshot = _FlagsInternalProxy(client).getFlagAssignmentsSnapshot() + + // Then + assertThat(encodedSnapshot).isEmpty() + + // When + stubAssignments("plaintext") + client.setEvaluationContext(EvaluationContext(fakeTargetingKey)) + + // Then + assertThat(_FlagsInternalProxy(client).getFlagAssignmentsSnapshot()).containsKey("flag") + } + + private fun stubAssignments(fixture: String) { + val response = readFixture(fixture) + stubSdkCore.stubNetworkResponses { httpResponse(it, body = response) } + } + + private fun createClient(sdkCore: StubSDKCore = stubSdkCore): FlagsClient { + Flags.enable( + FlagsConfiguration.Builder() + .useCustomFlagEndpoint("https://flags.example.com/precompute-assignments") + .build(), + sdkCore + ) + return FlagsClient.Builder(sdkCore = sdkCore).build() + } + + companion object { + internal fun readFixture(name: String): String = + checkNotNull(FlagsObfuscationTest::class.java.getResource("/obfuscation/$name.json")).readText() + } +} diff --git a/reliability/single-fit/flags/src/test/kotlin/com/datadog/android/flags/integration/FlagsRumIntegrationTest.kt b/reliability/single-fit/flags/src/test/kotlin/com/datadog/android/flags/integration/FlagsRumIntegrationTest.kt index d634cd280e..a5d6a158bf 100644 --- a/reliability/single-fit/flags/src/test/kotlin/com/datadog/android/flags/integration/FlagsRumIntegrationTest.kt +++ b/reliability/single-fit/flags/src/test/kotlin/com/datadog/android/flags/integration/FlagsRumIntegrationTest.kt @@ -35,6 +35,8 @@ import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.Extensions +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings import org.mockito.quality.Strictness @@ -126,6 +128,28 @@ class FlagsRumIntegrationTest { assertThat(featureFlags?.has(fakeFlagKey) ?: false).isFalse() } + @ParameterizedTest + @ValueSource(strings = ["encoded", "refreshed"]) + fun `M report original flag key W resolveBooleanValue() {obfuscated RUM evaluation}`( + fixture: String, + @StringForgery fakeViewKey: String, + @StringForgery fakeViewName: String + ) { + // Given + stubSdkCore.stubNetworkResponses { httpResponse(it, body = FlagsObfuscationTest.readFixture(fixture)) } + val client = enableRumAndFlags(rumIntegrationEnabled = true) + GlobalRumMonitor.get(stubSdkCore).startView(fakeViewKey, fakeViewName) + + // When + client.resolveBooleanValue("flag", false) + GlobalRumMonitor.get(stubSdkCore).stopView(fakeViewKey) + + // Then + val flags = lastViewEvent().getJSONObject("feature_flags") + assertThat(flags.keys().asSequence().toList()).containsExactly("flag") + assertThat(flags.getString("flag")).isEqualTo("variant") + } + // region Internal private fun enableRumAndFlags(rumIntegrationEnabled: Boolean): FlagsClient { diff --git a/reliability/single-fit/flags/src/test/resources/obfuscation/encoded.json b/reliability/single-fit/flags/src/test/resources/obfuscation/encoded.json new file mode 100644 index 0000000000..23fa3195b0 --- /dev/null +++ b/reliability/single-fit/flags/src/test/resources/obfuscation/encoded.json @@ -0,0 +1,123 @@ +{ + "data": { + "type": "precomputed-assignments", + "attributes": { + "flags": { + "9817872c144b018abd77e3915bd77e2c27f4f534dccff8ffaca27361e3a5e1ee": { + "variationType": "boolean", + "variationValue": true, + "allocationKey": "allocation", + "variationKey": "variant", + "doLog": true, + "serialId": 123, + "reason": "TARGETING_MATCH", + "extraLogging": { + "test": "metadata" + }, + "future-field": true + }, + "52ec7beb276f261559c1e3ff9e7f1da21a03e4780448ee99ddbefa6eecc02108": { + "variationType": "string", + "variationValue": "visible", + "allocationKey": "allocation", + "variationKey": "variant", + "doLog": true, + "serialId": 123, + "reason": "TARGETING_MATCH", + "extraLogging": { + "test": "metadata" + }, + "future-field": true + }, + "1f9a34e207d7baa1f34a3a0852b7148f01fa97c264c4c1aa0a3d0081abe04129": { + "variationType": "integer", + "variationValue": 42, + "allocationKey": "allocation", + "variationKey": "variant", + "doLog": true, + "serialId": 123, + "reason": "TARGETING_MATCH", + "extraLogging": { + "test": "metadata" + }, + "future-field": true + }, + "5f06f5ba2645c1024782ff7d86331138a0e5fc3dcd1994f9b2be12ccd46c9e41": { + "variationType": "number", + "variationValue": 12.5, + "allocationKey": "allocation", + "variationKey": "variant", + "doLog": true, + "serialId": 123, + "reason": "TARGETING_MATCH", + "extraLogging": { + "test": "metadata" + }, + "future-field": true + }, + "8e8a569cd924ae7f51b1ce242f0581b07a7830028741b2d733f7f979af93a816": { + "variationType": "object", + "variationValue": { + "nested": [ + 42 + ] + }, + "allocationKey": "allocation", + "variationKey": "variant", + "doLog": true, + "serialId": 123, + "reason": "TARGETING_MATCH", + "extraLogging": { + "test": "metadata" + }, + "future-field": true + }, + "d7d9172551b0d0b4884754a473bde6203e52c79b702c7960c5db50e947d0f3ba": { + "variationType": "boolean", + "variationValue": "invalid", + "allocationKey": "allocation", + "variationKey": "variant", + "doLog": true, + "serialId": 123, + "reason": "TARGETING_MATCH", + "extraLogging": { + "test": "metadata" + }, + "future-field": true + }, + "765503cde18f0dda850b355f94ddca16cd8c28d75c9cf3853e4e8f44e44dff9e": { + "variationType": "boolean", + "variationValue": true, + "allocationKey": "allocation", + "variationKey": "variant", + "doLog": true, + "serialId": 123, + "reason": "TARGETING_MATCH", + "extraLogging": { + "test": "metadata" + }, + "future-field": true + }, + "20a8a5f82ad21facabdd8bacf5250d8b05639dbb3dc8f0e2490d70791efbc4da": { + "variationType": "future-type", + "variationValue": true, + "allocationKey": "allocation", + "variationKey": "variant", + "doLog": true, + "serialId": 123, + "reason": "TARGETING_MATCH", + "extraLogging": { + "test": "metadata" + }, + "future-field": true + } + }, + "future-field": true, + "obfuscated": true, + "obfuscation": { + "scheme": "flag-key-sha256-v1", + "salt": "000102030405060708090a0b0c0d0e0f" + } + } + } +} diff --git a/reliability/single-fit/flags/src/test/resources/obfuscation/plaintext.json b/reliability/single-fit/flags/src/test/resources/obfuscation/plaintext.json new file mode 100644 index 0000000000..731dcd9501 --- /dev/null +++ b/reliability/single-fit/flags/src/test/resources/obfuscation/plaintext.json @@ -0,0 +1,118 @@ +{ + "data": { + "type": "precomputed-assignments", + "attributes": { + "flags": { + "flag": { + "variationType": "boolean", + "variationValue": true, + "allocationKey": "allocation", + "variationKey": "variant", + "doLog": true, + "serialId": 123, + "reason": "TARGETING_MATCH", + "extraLogging": { + "test": "metadata" + }, + "future-field": true + }, + "string": { + "variationType": "string", + "variationValue": "visible", + "allocationKey": "allocation", + "variationKey": "variant", + "doLog": true, + "serialId": 123, + "reason": "TARGETING_MATCH", + "extraLogging": { + "test": "metadata" + }, + "future-field": true + }, + "integer": { + "variationType": "integer", + "variationValue": 42, + "allocationKey": "allocation", + "variationKey": "variant", + "doLog": true, + "serialId": 123, + "reason": "TARGETING_MATCH", + "extraLogging": { + "test": "metadata" + }, + "future-field": true + }, + "number": { + "variationType": "number", + "variationValue": 12.5, + "allocationKey": "allocation", + "variationKey": "variant", + "doLog": true, + "serialId": 123, + "reason": "TARGETING_MATCH", + "extraLogging": { + "test": "metadata" + }, + "future-field": true + }, + "object": { + "variationType": "object", + "variationValue": { + "nested": [ + 42 + ] + }, + "allocationKey": "allocation", + "variationKey": "variant", + "doLog": true, + "serialId": 123, + "reason": "TARGETING_MATCH", + "extraLogging": { + "test": "metadata" + }, + "future-field": true + }, + "bad": { + "variationType": "boolean", + "variationValue": "invalid", + "allocationKey": "allocation", + "variationKey": "variant", + "doLog": true, + "serialId": 123, + "reason": "TARGETING_MATCH", + "extraLogging": { + "test": "metadata" + }, + "future-field": true + }, + "new-flag": { + "variationType": "boolean", + "variationValue": true, + "allocationKey": "allocation", + "variationKey": "variant", + "doLog": true, + "serialId": 123, + "reason": "TARGETING_MATCH", + "extraLogging": { + "test": "metadata" + }, + "future-field": true + }, + "future": { + "variationType": "future-type", + "variationValue": true, + "allocationKey": "allocation", + "variationKey": "variant", + "doLog": true, + "serialId": 123, + "reason": "TARGETING_MATCH", + "extraLogging": { + "test": "metadata" + }, + "future-field": true + } + }, + "future-field": true + } + } +} diff --git a/reliability/single-fit/flags/src/test/resources/obfuscation/refreshed.json b/reliability/single-fit/flags/src/test/resources/obfuscation/refreshed.json new file mode 100644 index 0000000000..cd9ba211e0 --- /dev/null +++ b/reliability/single-fit/flags/src/test/resources/obfuscation/refreshed.json @@ -0,0 +1,123 @@ +{ + "data": { + "type": "precomputed-assignments", + "attributes": { + "flags": { + "e9da87d1d051b4e9ae47e00e97c87a11d939a8746280eed92f475a9fb495ae18": { + "variationType": "boolean", + "variationValue": false, + "allocationKey": "allocation", + "variationKey": "variant", + "doLog": true, + "serialId": 123, + "reason": "TARGETING_MATCH", + "extraLogging": { + "test": "metadata" + }, + "future-field": true + }, + "4abdc372f2736843d88aab09234961645d4c033e006b834766cc46e37347b872": { + "variationType": "string", + "variationValue": "visible", + "allocationKey": "allocation", + "variationKey": "variant", + "doLog": true, + "serialId": 123, + "reason": "TARGETING_MATCH", + "extraLogging": { + "test": "metadata" + }, + "future-field": true + }, + "8346778adfa902f906c69d4ed59870c99d56efff9399db8c584cf6074b3e5011": { + "variationType": "integer", + "variationValue": 42, + "allocationKey": "allocation", + "variationKey": "variant", + "doLog": true, + "serialId": 123, + "reason": "TARGETING_MATCH", + "extraLogging": { + "test": "metadata" + }, + "future-field": true + }, + "1b7113b78114045d1763d16316e9dee141ac1edb43c04f680f5ffeaf8e887786": { + "variationType": "number", + "variationValue": 12.5, + "allocationKey": "allocation", + "variationKey": "variant", + "doLog": true, + "serialId": 123, + "reason": "TARGETING_MATCH", + "extraLogging": { + "test": "metadata" + }, + "future-field": true + }, + "8a9965eba87255f32438eb47410ef1be76be3387369f6c69df72c8d55a754539": { + "variationType": "object", + "variationValue": { + "nested": [ + 42 + ] + }, + "allocationKey": "allocation", + "variationKey": "variant", + "doLog": true, + "serialId": 123, + "reason": "TARGETING_MATCH", + "extraLogging": { + "test": "metadata" + }, + "future-field": true + }, + "cdd6deebb0103ad58c9adeb13a59139c1231beb4a77a30d84c70fdf4a86bbe47": { + "variationType": "boolean", + "variationValue": "invalid", + "allocationKey": "allocation", + "variationKey": "variant", + "doLog": true, + "serialId": 123, + "reason": "TARGETING_MATCH", + "extraLogging": { + "test": "metadata" + }, + "future-field": true + }, + "d4b49a53705bac06a2ebd5fbb2110f3b3a7214f4b020406ebf536b24e6c19c83": { + "variationType": "boolean", + "variationValue": true, + "allocationKey": "allocation", + "variationKey": "variant", + "doLog": true, + "serialId": 123, + "reason": "TARGETING_MATCH", + "extraLogging": { + "test": "metadata" + }, + "future-field": true + }, + "7cb43857a178cd33bfcff5eba1d73a68ca8c8de170ac3c6f63a585fcf5adfdec": { + "variationType": "future-type", + "variationValue": true, + "allocationKey": "allocation", + "variationKey": "variant", + "doLog": true, + "serialId": 123, + "reason": "TARGETING_MATCH", + "extraLogging": { + "test": "metadata" + }, + "future-field": true + } + }, + "future-field": true, + "obfuscated": true, + "obfuscation": { + "scheme": "flag-key-sha256-v1", + "salt": "ffffffffffffffffffffffffffffffff" + } + } + } +} diff --git a/reliability/stub-core/src/main/kotlin/com/datadog/android/core/stub/StubSDKCore.kt b/reliability/stub-core/src/main/kotlin/com/datadog/android/core/stub/StubSDKCore.kt index 0c195bc4b8..e261d06d41 100644 --- a/reliability/stub-core/src/main/kotlin/com/datadog/android/core/stub/StubSDKCore.kt +++ b/reliability/stub-core/src/main/kotlin/com/datadog/android/core/stub/StubSDKCore.kt @@ -53,6 +53,9 @@ class StubSDKCore( private var datadogContext: DatadogContext = forge.getForgery().copy(source = "android") ) : InternalSdkCore by mockSdkCore { + override val source: String + get() = datadogContext.source + private val featureScopes = mutableMapOf() private val dataStores = mutableMapOf()