Skip to content
5 changes: 5 additions & 0 deletions detekt_custom_safe_calls_third_party.yml
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,10 @@ datadog:
- "java.lang.StringBuilder.append(kotlin.String?)"
- "java.lang.StringBuilder.clear()"
- "java.lang.StringBuilder.constructor()"
- "java.math.BigDecimal.constructor(kotlin.Int)"
- "java.math.BigDecimal.scale()"
- "java.math.BigDecimal.signum()"
- "java.math.BigDecimal.toInt()"
- "java.math.BigInteger.toHexString()"
- "java.math.BigInteger.toLong()"
- "java.nio.ByteBuffer.duplicate()"
Expand Down Expand Up @@ -952,6 +956,7 @@ datadog:
- "kotlin.String.removePrefix(kotlin.CharSequence)"
- "kotlin.String.removeSuffix(kotlin.CharSequence)"
- "kotlin.String.takeIf(kotlin.Function1)"
- "kotlin.String.toBigDecimalOrNull()"
- "kotlin.String.toBigIntegerOrNull()"
- "kotlin.String.toBoolean()"
- "kotlin.String.toBooleanStrictOrNull()"
Expand Down
1 change: 1 addition & 0 deletions detekt_custom_unsafe_calls.yml
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ datadog:
- "java.lang.System.loadLibrary(kotlin.String?):java.lang.SecurityException,java.lang.UnsatisfiedLinkError,java.lang.NullPointerException"
- "java.lang.reflect.Field.get(kotlin.Any):java.lang.IllegalAccessException,java.lang.IllegalArgumentException,java.lang.NullPointerException,java.lang.ExceptionInInitializerError"
- "java.lang.reflect.Field.get(kotlin.Any?):java.lang.IllegalAccessException,java.lang.IllegalArgumentException,java.lang.NullPointerException,java.lang.ExceptionInInitializerError"
- "java.math.BigDecimal.stripTrailingZeros():java.lang.ArithmeticException"
- "java.math.BigInteger.and(java.math.BigInteger?):java.lang.NumberFormatException,java.lang.ArithmeticException"
- "java.math.BigInteger.constructor(kotlin.String?, kotlin.Int):java.lang.NumberFormatException,java.lang.ArithmeticException"
- "java.math.BigInteger.shiftRight(kotlin.Int):java.lang.NumberFormatException,java.lang.ArithmeticException"
Expand Down
4 changes: 3 additions & 1 deletion features/dd-sdk-android-flags/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,8 @@ val maxRetries = client.resolveIntValue("max-retry-count", 3)
val discountPercentage = client.resolveDoubleValue("discount-rate", 0.0)
```

Any numeric flag can be read with either method. `resolveIntValue` returns the value only when it is a whole number within `Int` range, such as `5` or `5.0`. Otherwise, it returns the default value: `resolve()` reports `TYPE_MISMATCH` when the value is fractional, such as `1.2`, or outside `Int` range, such as `2147483648`, and `PARSE_ERROR` when the value can't be parsed. To bound parsing work, a value longer than 64 characters is not parsed unless it is a plain integer within `Int` range, so it reports `PARSE_ERROR`. `resolveDoubleValue` returns a fractional value as the nearest `Double`, such as `1.2`. It returns a whole value only when the `Double` has exactly that value, or prints as that value, such as `1e300`. For other whole values, such as `9007199254740993`, `resolve()` reports `TYPE_MISMATCH`. `NaN` and `Infinity` report `PARSE_ERROR`. The same applies to a value longer than 64 characters whose `Double` is 2^53 or more in magnitude.

**Structured flags**
```kotlin
val defaultConfig = JSONObject("""{"timeout": 30, "retries": 3}""")
Expand Down Expand Up @@ -347,7 +349,7 @@ if (result.errorCode != null) {
##### Error codes
- `FLAG_NOT_FOUND` - The flag could not be found
- `PARSE_ERROR` - Error parsing the flag value
- `TYPE_MISMATCH` - The flag type doesn't match the expected type
- `TYPE_MISMATCH` - The flag value's type doesn't match the expected type, such as a fractional value read with `resolveIntValue`
- `TARGETING_KEY_MISSING` - No targeting key was provided
- `INVALID_CONTEXT` - The evaluation context is invalid
- `PROVIDER_NOT_READY` - The provider is not yet ready
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,11 @@ interface FlagsClient {
/**
* Resolves a numeric flag value.
*
* Any numeric flag type is accepted. A fractional value, such as `1.2`, resolves to the nearest [Double]. A
* whole value resolves only when the [Double] has exactly that value, or prints as that value, such as `1e300`.
* A value such as `9007199254740993` returns the default value. So does a value that can't be parsed, such as
* `NaN`, and a value longer than 64 characters whose [Double] is 2^53 or more in magnitude.
*
* @param flagKey The unique identifier of the flag to resolve.
* @param defaultValue The value to return if the flag cannot be retrieved or parsed.
* @return The numeric value of the flag as a double, or the default value if unavailable.
Expand All @@ -139,6 +144,10 @@ interface FlagsClient {
/**
* Resolves an integer flag value.
*
* Any numeric flag resolves when its value is a whole number within [Int] range, such as `5` or `5.0`. A fractional
* value, such as `1.2`, or a value outside [Int] range returns the default value. So does a value longer than
* 64 characters, unless it is a plain integer within [Int] range.
*
* @param flagKey The unique identifier of the flag to resolve.
* @param defaultValue The value to return if the flag cannot be retrieved or parsed.
* @return The integer value of the flag, or the default value if unavailable.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ package com.datadog.android.flags.internal

import com.datadog.android.flags.internal.model.VariationType
import org.json.JSONObject
import java.math.BigDecimal
import java.util.Locale
import kotlin.reflect.KClass

Expand All @@ -28,6 +29,16 @@ internal class TypeMismatchException(message: String) : Exception(message)
*/
internal object FlagValueConverter {

private val INT_MIN = BigDecimal(Int.MIN_VALUE)
private val INT_MAX = BigDecimal(Int.MAX_VALUE)

// The longest decimal value that Int reads, and Double reads at or above 2^53, parse with BigDecimal.
// This bounds the BigDecimal work on each read.
private const val MAX_DECIMAL_LENGTH = 64

// 2^53. Below this magnitude, Double holds every whole value exactly.
private const val EXACT_DOUBLE_LIMIT = 9_007_199_254_740_992.0

/**
* Converts a flag's string value to the expected type.
*
Expand All @@ -36,7 +47,9 @@ internal object FlagValueConverter {
* @param variationType The type specified in the flag metadata
* @param targetType The target type class to convert to
* @return Result containing the converted value on success,
* or a Failure with TypeMismatchException if types are incompatible,
* or a Failure with TypeMismatchException if types are incompatible
* or a numeric value can't be read as Int without loss,
* or a whole value matches neither the Double nor its printed form,
* or a Failure with the parse exception if conversion failed
*/
@Suppress("UNCHECKED_CAST")
Expand All @@ -53,8 +66,8 @@ internal object FlagValueConverter {
val result: T? = when (targetType) {
Boolean::class -> variationValue.lowercase(Locale.US).toBooleanStrictOrNull() as? T
String::class -> variationValue as T
Int::class -> variationValue.toIntOrNull() as? T
Double::class -> variationValue.toDoubleOrNull() as? T
Int::class -> parseLosslessInt(variationValue, variationType) as? T

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Context from Claude: The Int conversion now runs inside runCatching. If the decimal path throws an unexpected exception, the client returns the default value with PARSE_ERROR. The exception does not reach the host app.

Double::class -> parseLosslessDouble(variationValue, variationType) as? T
Comment thread
aarsilv marked this conversation as resolved.
Resolved
Map::class -> variationValue.toMap() as? T
JSONObject::class -> {
@Suppress("UnsafeThirdPartyFunctionCall") // Safe: wrapped in runCatching
Expand Down Expand Up @@ -88,10 +101,7 @@ internal object FlagValueConverter {
fun isTypeCompatible(variationType: String, targetType: KClass<*>): Boolean = when (targetType) {
Boolean::class -> variationType == VariationType.BOOLEAN.value
String::class -> variationType == VariationType.STRING.value
Int::class -> variationType == VariationType.INTEGER.value || variationType == VariationType.NUMBER.value
Double::class ->
variationType == VariationType.NUMBER.value || variationType == VariationType.FLOAT.value ||
variationType == VariationType.INTEGER.value
Int::class, Double::class -> isNumeric(variationType)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Context from Claude: Int reads now accept all three numeric types. A "number" value can hold a whole or a fractional number. OpenFeature defines one Number type. So the served type no longer decides an Int read. The parsed value does: a whole value in Int range succeeds. Double reads already accepted all three types.

JSONObject::class -> variationType == VariationType.OBJECT.value
Map::class -> variationType == VariationType.OBJECT.value
else -> {
Expand All @@ -115,4 +125,90 @@ internal object FlagValueConverter {
Map::class -> "Map"
else -> targetType.simpleName ?: "Unknown"
}

private fun isNumeric(variationType: String): Boolean = variationType == VariationType.INTEGER.value ||
variationType == VariationType.NUMBER.value ||
variationType == VariationType.FLOAT.value

/**
* Int reads are lossless: a whole value within Int range, such as `5` or `5.0`, reads as Int whatever the
* numeric type. A fractional or out-of-range value is a type mismatch. A value that can't be parsed returns null,
* which [convert] reports as a parse error. So does a value that is not a plain Int and is longer than
* [MAX_DECIMAL_LENGTH] characters.
*/
@Throws(TypeMismatchException::class)
private fun parseLosslessInt(variationValue: String, variationType: String): Int? = variationValue.toIntOrNull()
?: variationValue.takeIf { it.length <= MAX_DECIMAL_LENGTH }

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Context from Claude: Plain integers take the fast toIntOrNull() path. Other values longer than 64 characters skip the BigDecimal parse and report PARSE_ERROR. Without this limit, one long number string can take seconds on each read. On the JVM, a value of 1 followed by 200,000 zeros and .0 took about 10 s in stripTrailingZeros().

?.toBigDecimalOrNull()
?.let { requireLosslessInt(it, variationValue, variationType) }

// Compare with the Int bounds instead of calling BigDecimal.intValueExact(): on Android 9, intValueExact()
// expands the power of ten, so a short value such as `1e10000000` takes seconds.
@Throws(TypeMismatchException::class)
private fun requireLosslessInt(decimal: BigDecimal, variationValue: String, variationType: String): Int {
val problem = when {
isFractional(decimal) -> "fractional value '$variationValue'"
decimal < INT_MIN || decimal > INT_MAX -> "value '$variationValue' outside Int range"
else -> null
}
if (problem != null) {
@Suppress("ThrowingInternalException") // Caught by runCatching in convert()
throw TypeMismatchException("Flag has type '$variationType' with $problem but Int was requested")

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Context from Claude: Parsing succeeded before this branch. A fractional value, or a whole value outside Int range, cannot be an Int without loss. So it is a type mismatch, not a parse error. runCatching in convert() catches the exception. The client then reports TYPE_MISMATCH.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

worth to mark with method with @throws annotation to no forget to wrap this method in a future changes. Then suppressing wouldn't be needed

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Reply from Claude, working with Aaron:

Good idea. 7e5ab28 adds @Throws(TypeMismatchException::class) to parseLosslessInt, requireLosslessInt, parseLosslessDouble and requireLosslessDouble, as FlagsJson does.

The @Suppress has to stay. The ThrowingInternalException rule reports every throw in internal code and does not read @Throws (tools/detekt/src/main/kotlin/com/datadog/tools/detekt/rules/sdk/ThrowingInternalException.kt). We checked: with @Throws and without the suppression, detekt reports both throw lines.

}
return decimal.toInt()
}

/**
* A whole value reads as Double only when the Double equals it, or when the Double prints as the same value,
* such as `1e300`. So `9007199254740993` is a type mismatch. A fractional value reads as the nearest Double,
* such as `1.2`. A value that can't be parsed returns null, which [convert] reports as a parse error. So does
* a value whose parsed Double is 2^53 or more in magnitude, when the value is longer than [MAX_DECIMAL_LENGTH]
* characters or [BigDecimal] can't parse it.
*/
@Throws(TypeMismatchException::class)
private fun parseLosslessDouble(variationValue: String, variationType: String): Double? =

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Context from Claude: This makes Double reads match the shared fixtures for large whole values. 9007199254740993 now gives TYPE_MISMATCH instead of a rounded value. Below 2^53 in magnitude, Double holds every whole value exactly, so the parsed value returns at once. At 2^53 or more, an input of at most 64 characters goes to BigDecimal. A longer input gives PARSE_ERROR. A whole value passes if it equals the Double or its printed form (see isExactDouble).

variationValue.toDoubleOrNull()?.let { double ->
if (double > -EXACT_DOUBLE_LIMIT && double < EXACT_DOUBLE_LIMIT) {
double
} else {
variationValue.takeIf { it.length <= MAX_DECIMAL_LENGTH }
?.toBigDecimalOrNull()
?.let { requireLosslessDouble(it, double, variationValue, variationType) }
}
}

@Throws(TypeMismatchException::class)
private fun requireLosslessDouble(
decimal: BigDecimal,
double: Double,
variationValue: String,
variationType: String
): Double {
if (!isFractional(decimal) && !isExactDouble(decimal, double)) {
@Suppress("ThrowingInternalException") // Caught by runCatching in convert()
throw TypeMismatchException(
"Flag has type '$variationType' with whole value '$variationValue' that Double can't hold exactly " +
"but Double was requested"
)
}
return double
}

@Suppress("UnsafeThirdPartyFunctionCall") // Safe: BigDecimal(Double) and valueOf() only run for a finite Double
private fun isExactDouble(decimal: BigDecimal, double: Double): Boolean =

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Context from Claude: A whole value also passes when the Double prints as that value, such as 1e300. So a double written in its shortest printed form reads back as itself. Without this check, a test that reads random doubles fails.

double != Double.POSITIVE_INFINITY && double != Double.NEGATIVE_INFINITY &&
(decimal.compareTo(BigDecimal(double)) == 0 || decimal.compareTo(BigDecimal.valueOf(double)) == 0)

/**
* A value with a scale of 0 or less is whole. Check the scale first: it also prevents
* [BigDecimal.stripTrailingZeros] from overflowing the scale for a value such as `100e2147483647`.
* Zero is whole at any scale: some older Android versions return a zero such as `0.0` unchanged from
* [BigDecimal.stripTrailingZeros].
*/
private fun isFractional(decimal: BigDecimal): Boolean {
if (decimal.scale() <= 0 || decimal.signum() == 0) return false

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Context from Claude: BigDecimal tells whole values from fractional ones exactly, with no rounding. The scale check comes first: stripTrailingZeros() throws ArithmeticException on scale overflow for input such as 100e2147483647. The zero check is for older Android libcore, which returns a zero such as 0.0 unchanged from stripTrailingZeros(). The detekt config now lists stripTrailingZeros() as a throwing call.

@Suppress("UnsafeThirdPartyFunctionCall") // Safe: the scale is positive, so it can't overflow
val stripped = decimal.stripTrailingZeros()
return stripped.scale() > 0
}
}
Loading
Loading