Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
d600473
Support obfuscated feature flag assignment keys
leoromanovsky Oct 1, 2026
185a1fa
fix(flags): keep capability negotiation independent of versions
leoromanovsky Oct 2, 2026
b3779f8
test(flags): keep obfuscation coverage in JVM suite
leoromanovsky Oct 6, 2026
39ae47b
Merge origin/develop into flag-key-obfuscation
leoromanovsky Oct 6, 2026
c63e2d6
fix(flags): gate encoded cache reads until source is known
leoromanovsky Oct 6, 2026
16292d0
Use the feature flags capabilities header
leoromanovsky Oct 6, 2026
751cb76
fix(flags): cache lookup hashes and strengthen obfuscation regressions
leoromanovsky Oct 6, 2026
1c1f39f
Merge develop and preserve obfuscation with first-flags callbacks
leoromanovsky Oct 7, 2026
d8a2840
FFLSDK-254: Merge develop into flag-key-obfuscation
leoromanovsky Oct 7, 2026
65fc35c
FFLSDK-254: Mark obfuscation test setup, actions, and assertions
leoromanovsky Oct 8, 2026
3c503fc
FFLSDK-254: Use Mockito annotations in obfuscation tests
leoromanovsky Oct 8, 2026
26d87d6
FFLSDK-254: Generate incidental obfuscation test fixtures
leoromanovsky Oct 8, 2026
ad7aa8d
FFLSDK-254: Limit obfuscation suppressions to specific calls
leoromanovsky Oct 8, 2026
1d236bc
FFLSDK-254: Declare obfuscation parser exceptions
leoromanovsky Oct 8, 2026
71cc4df
FFLSDK-254: Generate assignment and cache JSON models
leoromanovsky Oct 8, 2026
935ada0
FFLSDK-261: Add context to generated assignments test
leoromanovsky Oct 8, 2026
586f86a
FFLSDK-261: Explain obfuscation copy visibility
leoromanovsky Oct 8, 2026
36c7d5a
FFLSDK-261: Merge latest develop
leoromanovsky Oct 8, 2026
723409b
FFLSDK-261: Apply documented Kotlin conventions
leoromanovsky Oct 8, 2026
f4a6d1f
FFLSDK-261: Tighten obfuscation test assertions
leoromanovsky Oct 8, 2026
27ba428
FFLSDK-261: Keep evaluation tests independent of HTTP
leoromanovsky Oct 8, 2026
413d7bb
Merge remote-tracking branch 'origin/develop' into flag-key-obfuscation
leoromanovsky Oct 16, 2026
586c287
FFLSDK-261: Add obfuscation coverage to Flags FIT tests
leoromanovsky Oct 16, 2026
952b148
FFLSDK-261: Apply JSON parsing and unit test review feedback
leoromanovsky Oct 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ abstract class GenerateJsonSchemaTask : DefaultTask() {
init {
group = "datadog"
description = "Read source JSON schema files and generate the relevant Kotlin data classes"
internalModels.convention(false)
}

// region Input/Output
Expand Down Expand Up @@ -80,6 +81,10 @@ abstract class GenerateJsonSchemaTask : DefaultTask() {
@get:Input
abstract val targetPackageName: Property<String>

/** Keep generated models out of the public SDK API when requested. */
@get:Input
abstract val internalModels: Property<Boolean>

/**
* The list of schema files to be ignored.
*/
Expand Down Expand Up @@ -124,7 +129,7 @@ abstract class GenerateJsonSchemaTask : DefaultTask() {
logger.info("Found ${files.size} files in input dir: $inputDir")

val reader = JsonSchemaReader(inputNameMapping.get(), logger)
val generator = FileGenerator(outputDir, targetPackageName.get(), logger)
val generator = FileGenerator(outputDir, targetPackageName.get(), logger, internalModels.get())
files.forEach {
val type = reader.readSchema(it)
generator.generate(type)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,16 @@ 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

class FileGenerator(
private val outputDir: File,
private val packageName: String,
private val logger: Logger
private val logger: Logger,
private val internalModels: Boolean = false
) {

private val knownTypes: MutableSet<KotlinTypeWrapper> = mutableSetOf()
Expand Down Expand Up @@ -75,6 +77,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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,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
Expand All @@ -50,6 +52,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`(
Expand Down
7 changes: 7 additions & 0 deletions detekt_custom_safe_calls_third_party.yml
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,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)"
Expand Down Expand Up @@ -686,6 +687,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?)"
Expand Down Expand Up @@ -734,6 +736,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?)"
Expand Down Expand Up @@ -864,7 +867,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)"
Expand Down Expand Up @@ -982,6 +987,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)"
Expand Down Expand Up @@ -1157,6 +1163,7 @@ datadog:
- "org.json.JSONArray.length()"
- "org.json.JSONArray.put(kotlin.Any?)"
- "org.json.JSONArray.toJsonArray()"
- "org.json.JSONException.constructor(kotlin.String?)"
- "org.json.JSONObject.constructor()"
- "org.json.JSONObject.has(kotlin.String?)"
- "org.json.JSONObject.keys()"
Expand Down
3 changes: 3 additions & 0 deletions detekt_custom_unsafe_calls.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ datadog:
- "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.String):java.lang.NullPointerException"
- "androidx.collection.LruCache.remove(java.io.File):java.lang.NullPointerException"
- "androidx.metrics.performance.JankStats.createAndTrack(android.view.Window, androidx.metrics.performance.JankStats.OnFrameListener):java.lang.IllegalStateException"
- "androidx.navigation.Navigation.findNavController(android.app.Activity, kotlin.Int):java.lang.IllegalStateException"
Expand Down Expand Up @@ -191,6 +192,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"
Expand All @@ -205,6 +207,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"
Expand Down
8 changes: 8 additions & 0 deletions features/dd-sdk-android-flags/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,14 @@ plugins {
createJsonModelsGenerationTask("generateFlagsModelsFromJson") {
inputDirPath = "src/main/json/flags"
targetPackageName = "com.datadog.android.flags.model"
ignoredFiles.set(listOf("assignment.json", "assignments-response.json", "flags-state.json"))
}

createJsonModelsGenerationTask("generateFlagsInternalModelsFromJson") {
inputDirPath = "src/main/json/flags"
targetPackageName = "com.datadog.android.flags.internal.model"
ignoredFiles.set(listOf("exposure-event-schema.json", "flag-evaluation-schema.json"))
internalModels.set(true)
}

// TODO RUM-18189 Support new AGP DSL
Expand Down
Comment thread
leoromanovsky marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -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"]
}
Original file line number Diff line number Diff line change
@@ -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"]
}
Original file line number Diff line number Diff line change
@@ -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"]
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,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
Expand Down Expand Up @@ -98,7 +98,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
Expand All @@ -117,6 +117,9 @@ internal class EvaluationsManager(

sdkCore.getFeature(Feature.FLAGS_FEATURE_NAME)
?.withContext(withFeatureContexts = setOf(Feature.RUM_FEATURE_NAME)) { datadogContext ->
// React Native looks up keys in JavaScript snapshots. Native code cannot reverse the hashes.
val supportsObfuscation = datadogContext.source == "android"
Comment thread
leoromanovsky marked this conversation as resolved.
flagsRepository.setObfuscationSupported(supportsObfuscation)
executorService.executeSafe(
operationName = FETCH_AND_STORE_OPERATION_NAME,
internalLogger = internalLogger
Expand All @@ -132,9 +135,10 @@ internal class EvaluationsManager(
hadFlags && flagsRepository.getEvaluationContext() == context
)
val response = assignmentsReader.readPrecomputedFlags(context, datadogContext)
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) {
Comment thread
leoromanovsky marked this conversation as resolved.
installFlags(context, assignments, initializationCompletion, callback)
} else {
val message = if (response == null) NETWORK_REQUEST_FAILED_MESSAGE else INVALID_RESPONSE_MESSAGE
internalLogger.log(
Expand Down Expand Up @@ -164,11 +168,11 @@ internal class EvaluationsManager(

private fun installFlags(
context: EvaluationContext,
flagsMap: Map<String, PrecomputedFlag>,
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
Expand All @@ -180,7 +184,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" }
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/*
* 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<String, String>(LOOKUP_CACHE_LIMIT)
Comment thread
leoromanovsky marked this conversation as resolved.

fun encode(key: String): String? {
// Reject unpaired surrogates instead of silently replacing their UTF-8 encoding.
if (!isWellFormedUnicode(key)) return null
Comment thread
leoromanovsky marked this conversation as resolved.
@Suppress("UnsafeThirdPartyFunctionCall") // The key is non-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))
Comment thread
leoromanovsky marked this conversation as resolved.
@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 {
@Suppress("UnsafeThirdPartyFunctionCall") // The key and encoded value are non-null.
lookupKeys.put(key, it)
}
}
}

internal companion object {
const val SCHEME = "flag-key-sha256-v1"
const val CAPABILITY = "assignment-encoding-flag-key-256-v1"
Comment thread
typotter marked this conversation as resolved.
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 >= value.length || !value[index++].isLowSurrogate()) return false
} else if (char.isLowSurrogate()) {
return false
}
}
return true
}
}
}

internal data class PrecomputedAssignments(
val flags: Map<String, PrecomputedFlag>,
val obfuscation: FlagKeyObfuscation? = null
)
Comment thread
leoromanovsky marked this conversation as resolved.
Loading
Loading