diff --git a/build.gradle b/build.gradle index efc8cd1..8920893 100644 --- a/build.gradle +++ b/build.gradle @@ -5,7 +5,7 @@ apply plugin: 'org.jetbrains.dokka' apply plugin: 'org.jlleitschuh.gradle.ktlint' buildscript { - ext.kotlin_version = '1.5.31' + ext.kotlin_version = '1.6.21' ext.dokka_version = '1.4.32' repositories { diff --git a/sdk/build.gradle b/sdk/build.gradle index c103c7d..55a3d7c 100644 --- a/sdk/build.gradle +++ b/sdk/build.gradle @@ -2,6 +2,7 @@ plugins { id 'com.android.library' id 'kotlin-android' id 'org.jetbrains.dokka' + id 'org.jetbrains.kotlin.plugin.serialization' version '1.6.0' } ext { PUBLISH_NAME = 'Experiment Android SDK' @@ -41,8 +42,8 @@ dependencies { implementation 'com.amplitude:analytics-connector:1.0.0' implementation 'com.squareup.okhttp3:okhttp:4.9.1' implementation 'com.amplitude:android-sdk:2.26.1' - implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.2.0") - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.2.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.3.3") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.3") testImplementation 'junit:junit:4.13.2' testImplementation 'org.json:json:20201115' diff --git a/sdk/src/main/java/com/amplitude/evaluation-core/src/commonMain/kotlin/EvaluationEngine.kt b/sdk/src/main/java/com/amplitude/evaluation-core/src/commonMain/kotlin/EvaluationEngine.kt new file mode 100644 index 0000000..1f138f3 --- /dev/null +++ b/sdk/src/main/java/com/amplitude/evaluation-core/src/commonMain/kotlin/EvaluationEngine.kt @@ -0,0 +1,364 @@ +package com.amplitude.experiment.evaluation + +import kotlinx.serialization.SerializationException +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.JsonArray + +interface EvaluationEngine { + fun evaluate( + context: EvaluationContext, + flags: List + ): Map +} + +class EvaluationEngineImpl(private val log: Logger? = DefaultLogger()) : EvaluationEngine { + + data class EvaluationTarget( + val context: EvaluationContext, + val result: MutableMap + ) : Selectable { + override fun select(selector: String): Any? { + return when (selector) { + "context" -> context + "result" -> result + else -> null + } + } + } + + override fun evaluate( + context: EvaluationContext, + flags: List + ): Map { + log?.debug { "Evaluating flags ${flags.map { it.key }} with context $context." } + val results: MutableMap = mutableMapOf() + val target = EvaluationTarget(context, results) + for (flag in flags) { + // Evaluate flag and update results. + val variant = evaluateFlag(target, flag) + if (variant != null) { + results[flag.key] = variant + } else { + log?.debug { "Flag ${flag.key} evaluation returned a null result." } + } + } + log?.debug { "Evaluation completed. $results" } + return results + } + + private fun evaluateFlag(target: EvaluationTarget, flag: EvaluationFlag): EvaluationVariant? { + log?.verbose { "Evaluating flag $flag with target $target." } + var result: EvaluationVariant? = null + for (segment in flag.segments) { + result = evaluateSegment(target, flag, segment) + if (result != null) { + // Merge all metadata into the result + val metadata = mergeMetadata(flag.metadata, segment.metadata, result.metadata) + result = EvaluationVariant(result.key, result.value, result.payload, metadata) + log?.verbose { "Flag evaluation returned result $result on segment $segment." } + break + } + } + return result + } + + private fun evaluateSegment( + target: EvaluationTarget, + flag: EvaluationFlag, + segment: EvaluationSegment + ): EvaluationVariant? { + log?.verbose { "Evaluating segment $segment with target $target." } + if (segment.conditions == null) { + log?.verbose { "Segment conditions are null, bucketing target." } + // Null conditions always match + val variantKey = bucket(target, segment) + return flag.variants[variantKey] + } + // Outer list logic is "or" (||) + for (conditions in segment.conditions) { + var match = true + // Inner list logic is "and" (&&) + for (condition in conditions) { + match = matchCondition(target, condition) + if (!match) { + log?.verbose { "Segment condition $condition did not match target." } + break + } else { + log?.verbose { "Segment condition $condition matched target." } + } + } + // On match bucket the user. + if (match) { + log?.verbose { "Segment conditions matched, bucketing target." } + val variantKey = bucket(target, segment) + return flag.variants[variantKey] + } + } + return null + } + + private fun matchCondition(target: EvaluationTarget, condition: EvaluationCondition): Boolean { + val propValue = target.select(condition.selector) + // We need special matching for null properties and set type prop values + // and operators. All other values are matched as strings, since the + // filter values are always strings. + if (propValue == null) { + return matchNull(condition.op, condition.values) + } else if (isSetOperator(condition.op)) { + val propValueStringList = coerceStringList(propValue) ?: return false + return matchSet(propValueStringList, condition.op, condition.values) + } else { + val propValueString = coerceString(propValue) ?: return false + return matchString(propValueString, condition.op, condition.values) + } + } + + private fun getHash(key: String): Long { + // hash32x86 returns a number that can't fit in a signed 32-bit java integer. + // Source: https://stackoverflow.com/a/24090718/2322146 + val data = key.encodeToByteArray() + val value = Murmur3.hash32x86(data, data.size, 0) + return value.toLong() and 0xffffffffL + } + + private fun bucket(target: EvaluationTarget, segment: EvaluationSegment): String? { + log?.verbose { "Bucketing segment $segment with target $target" } + if (segment.bucket == null) { + // A null bucket means the segment is fully rolled out. Select the default variant. + log?.verbose { "Segment bucket is null, returning default variant ${segment.variant}." } + return segment.variant + } + // Select the bucketing value. + val bucketingValue = coerceString(target.select(segment.bucket.selector)) + log?.verbose { "Selected bucketing value $bucketingValue from target." } + if (bucketingValue == null || bucketingValue.isEmpty()) { + // A null or empty bucketing value cannot be bucketed. Select the default variant. + log?.verbose { "Selected bucketing value is null or empty." } + return segment.variant + } + // Salt and hash the value, and compute the allocation and distribution values. + val keyToHash = "${segment.bucket.salt}/$bucketingValue" + val hash = getHash(keyToHash) + val allocationValue = hash % 100 + val distributionValue = hash.floorDiv(100) + // Iterate over allocations. If the value falls within the range, check the distribution. + for (allocation in segment.bucket.allocations) { + val allocationStart = allocation.range[0] + val allocationEnd = allocation.range[1] + if (allocationValue in allocationStart until allocationEnd) { + for (distribution in allocation.distributions) { + val distributionStart = distribution.range[0] + val distributionEnd = distribution.range[1] + if (distributionValue in distributionStart until distributionEnd) { + log?.verbose { "Bucketing hit allocation and distribution, returning variant ${distribution.variant}." } + return distribution.variant + } + } + } + } + // No allocation and distribution match. Select the default variant. + return segment.variant + } + + private fun mergeMetadata(vararg metadata: Map?): Map? { + val mergedMetadata = mutableMapOf() + for (metadataElement in metadata) { + if (metadataElement != null) { + mergedMetadata.putAll(metadataElement) + } + } + return if (mergedMetadata.isEmpty()) { + null + } else { + mergedMetadata + } + } + + private fun matchNull(op: String, filterValues: Set): Boolean { + val containsNone = containsNone(filterValues) + return when (op) { + EvaluationOperator.IS, EvaluationOperator.CONTAINS, EvaluationOperator.LESS_THAN, + EvaluationOperator.LESS_THAN_EQUALS, EvaluationOperator.GREATER_THAN, + EvaluationOperator.GREATER_THAN_EQUALS, EvaluationOperator.VERSION_LESS_THAN, + EvaluationOperator.VERSION_LESS_THAN_EQUALS, EvaluationOperator.VERSION_GREATER_THAN, + EvaluationOperator.VERSION_GREATER_THAN_EQUALS, EvaluationOperator.SET_IS, + EvaluationOperator.SET_CONTAINS, EvaluationOperator.SET_CONTAINS_ANY -> containsNone + + EvaluationOperator.IS_NOT, EvaluationOperator.DOES_NOT_CONTAIN, + EvaluationOperator.SET_DOES_NOT_CONTAIN, EvaluationOperator.SET_DOES_NOT_CONTAIN_ANY -> !containsNone + + EvaluationOperator.REGEX_MATCH -> false + EvaluationOperator.REGEX_DOES_NOT_MATCH, EvaluationOperator.SET_IS_NOT -> true + else -> false + } + } + + private fun matchSet(propValues: Set, op: String, filterValues: Set): Boolean { + return when (op) { + EvaluationOperator.SET_IS -> propValues == filterValues + EvaluationOperator.SET_IS_NOT -> propValues != filterValues + EvaluationOperator.SET_CONTAINS -> matchesSetContainsAll(propValues, filterValues) + EvaluationOperator.SET_DOES_NOT_CONTAIN -> !matchesSetContainsAll(propValues, filterValues) + EvaluationOperator.SET_CONTAINS_ANY -> matchesSetContainsAny(propValues, filterValues) + EvaluationOperator.SET_DOES_NOT_CONTAIN_ANY -> !matchesSetContainsAny(propValues, filterValues) + else -> false + } + } + + private fun matchString(propValue: String, op: String, filterValues: Set): Boolean { + return when (op) { + EvaluationOperator.IS -> matchesIs(propValue, filterValues) + EvaluationOperator.IS_NOT -> !matchesIs(propValue, filterValues) + EvaluationOperator.CONTAINS -> matchesContains(propValue, filterValues) + EvaluationOperator.DOES_NOT_CONTAIN -> !matchesContains(propValue, filterValues) + EvaluationOperator.LESS_THAN, EvaluationOperator.LESS_THAN_EQUALS, + EvaluationOperator.GREATER_THAN, EvaluationOperator.GREATER_THAN_EQUALS -> + matchesComparable(propValue, op, filterValues) { value -> parseDouble(value) } + + EvaluationOperator.VERSION_LESS_THAN, EvaluationOperator.VERSION_LESS_THAN_EQUALS, + EvaluationOperator.VERSION_GREATER_THAN, EvaluationOperator.VERSION_GREATER_THAN_EQUALS -> + matchesComparable(propValue, op, filterValues) { value -> SemanticVersion.parse(value) } + + EvaluationOperator.REGEX_MATCH -> matchesRegex(propValue, filterValues) + EvaluationOperator.REGEX_DOES_NOT_MATCH -> !matchesRegex(propValue, filterValues) + else -> false + } + } + + private fun matchesIs(propValue: String, filterValues: Set): Boolean { + if (containsBooleans(filterValues)) { + val lower: String = propValue.lowercase() + if (lower == "true" || lower == "false") { + return filterValues.any { it.lowercase() == lower } + } + } + return filterValues.contains(propValue) + } + + private fun matchesContains(propValue: String, filterValues: Set): Boolean { + for (filterValue in filterValues) { + if (propValue.lowercase().contains(filterValue.lowercase())) { + return true + } + } + return false + } + + private fun matchesSetContainsAll(propValues: Set, filterValues: Set): Boolean { + if (propValues.size < filterValues.size) { + return false + } + for (filterValue in filterValues) { + if (!matchesIs(filterValue, propValues)) { + return false + } + } + return true + } + + private fun matchesSetContainsAny(propValues: Set, filterValues: Set): Boolean { + for (filterValue in filterValues) { + if (matchesIs(filterValue, propValues)) { + return true + } + } + return false + } + + private fun > matchesComparable( + propValue: String, + op: String, + filterValues: Set, + transformer: (String) -> T?, + ): Boolean { + val propValueTransformed: T? = transformer.invoke(propValue) + val filterValuesTransformed: Set = filterValues.mapNotNull(transformer).toSet() + return if (propValueTransformed == null || filterValuesTransformed.isEmpty()) { + // If the prop value or none of the filter values transform, fall + // back on string comparison. + filterValues.any { filterValue -> + matchesComparable(propValue, op, filterValue) + } + } else { + // Match only transformed filter values. + filterValuesTransformed.any { filterValueTransformed -> + matchesComparable(propValueTransformed, op, filterValueTransformed) + } + } + } + + private fun matchesComparable(propValue: Comparable, op: String, filterValue: T): Boolean { + val compareTo = propValue.compareTo(filterValue) + return when (op) { + EvaluationOperator.LESS_THAN, EvaluationOperator.VERSION_LESS_THAN -> compareTo < 0 + EvaluationOperator.LESS_THAN_EQUALS, EvaluationOperator.VERSION_LESS_THAN_EQUALS -> compareTo <= 0 + EvaluationOperator.GREATER_THAN, EvaluationOperator.VERSION_GREATER_THAN -> compareTo > 0 + EvaluationOperator.GREATER_THAN_EQUALS, EvaluationOperator.VERSION_GREATER_THAN_EQUALS -> compareTo >= 0 + else -> throw IllegalArgumentException("Unexpected comparison operator $op") + } + } + + private fun matchesRegex(propValue: String, filterValues: Set): Boolean { + return filterValues.any { filterValue -> Regex(filterValue).matches(propValue) } + } + + private fun containsNone(filterValues: Set): Boolean { + return filterValues.contains("(none)") + } + + private fun containsBooleans(filterValues: Set): Boolean { + return filterValues.any { filterValue -> + when (filterValue.lowercase()) { + "true", "false" -> true + else -> false + } + } + } + + private fun parseDouble(value: String): Double? { + return try { + value.toDouble() + } catch (e: NumberFormatException) { + null + } + } + + private fun coerceString(value: Any?): String? { + return when (value) { + null -> null + is Map<*, *> -> json.encodeToString(value.toJsonObject()) + is Collection<*> -> json.encodeToString(value.toJsonArray()) + else -> value.toString() + } + } + + private fun coerceStringList(value: Any): Set? { + // Convert collections to a list of strings + if (value is Collection<*>) { + return value.mapNotNull { coerceString(it) }.toSet() + } + // Parse a string as json array and convert to list of strings, or + // return null if the string could not be parsed as a json array. + val stringValue = value.toString() + val jsonArray = try { + json.decodeFromString(stringValue) + } catch (e: SerializationException) { + return null + } + return jsonArray.toList().mapNotNull { coerceString(it) }.toSet() + } + + private fun isSetOperator(op: String): Boolean { + return when (op) { + EvaluationOperator.SET_IS, + EvaluationOperator.SET_IS_NOT, + EvaluationOperator.SET_CONTAINS, + EvaluationOperator.SET_DOES_NOT_CONTAIN, + EvaluationOperator.SET_CONTAINS_ANY, + EvaluationOperator.SET_DOES_NOT_CONTAIN_ANY -> true + + else -> false + } + } +} diff --git a/sdk/src/main/java/com/amplitude/evaluation-core/src/commonMain/kotlin/EvaluationFlag.kt b/sdk/src/main/java/com/amplitude/evaluation-core/src/commonMain/kotlin/EvaluationFlag.kt index 79371e3..66563e7 100644 --- a/sdk/src/main/java/com/amplitude/evaluation-core/src/commonMain/kotlin/EvaluationFlag.kt +++ b/sdk/src/main/java/com/amplitude/evaluation-core/src/commonMain/kotlin/EvaluationFlag.kt @@ -1,6 +1,9 @@ +@file:UseSerializers(AnySerializer::class) + package com.amplitude.experiment.evaluation import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers @Serializable data class EvaluationFlag( diff --git a/sdk/src/main/java/com/amplitude/evaluation-core/src/commonMain/kotlin/EvaluationSegment.kt b/sdk/src/main/java/com/amplitude/evaluation-core/src/commonMain/kotlin/EvaluationSegment.kt index 314f523..5550180 100644 --- a/sdk/src/main/java/com/amplitude/evaluation-core/src/commonMain/kotlin/EvaluationSegment.kt +++ b/sdk/src/main/java/com/amplitude/evaluation-core/src/commonMain/kotlin/EvaluationSegment.kt @@ -1,6 +1,9 @@ +@file:UseSerializers(AnySerializer::class) + package com.amplitude.experiment.evaluation import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers @Serializable data class EvaluationSegment( diff --git a/sdk/src/main/java/com/amplitude/evaluation-core/src/commonMain/kotlin/EvaluationSerialization.kt b/sdk/src/main/java/com/amplitude/evaluation-core/src/commonMain/kotlin/EvaluationSerialization.kt new file mode 100644 index 0000000..a3988c4 --- /dev/null +++ b/sdk/src/main/java/com/amplitude/evaluation-core/src/commonMain/kotlin/EvaluationSerialization.kt @@ -0,0 +1,89 @@ +package com.amplitude.experiment.evaluation + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.longOrNull +import kotlin.jvm.JvmField +import kotlin.jvm.JvmSynthetic + +@JvmSynthetic +@JvmField +internal val json = Json { + ignoreUnknownKeys = true + isLenient = true + coerceInputValues = true + explicitNulls = false +} + +@JvmSynthetic +internal fun Any?.toJsonElement(): JsonElement = when (this) { + null -> JsonNull + is Map<*, *> -> toJsonObject() + is Collection<*> -> toJsonArray() + is Boolean -> JsonPrimitive(this) + is Number -> JsonPrimitive(this) + is String -> JsonPrimitive(this) + else -> JsonPrimitive(toString()) +} + +@JvmSynthetic +internal fun Collection<*>.toJsonArray(): JsonArray = JsonArray(map { it.toJsonElement() }) + +@JvmSynthetic +internal fun Map<*, *>.toJsonObject(): JsonObject = JsonObject( + mapNotNull { + (it.key as? String ?: return@mapNotNull null) to it.value.toJsonElement() + }.toMap(), +) + +@JvmSynthetic +internal fun JsonElement.toAny(): Any? { + return when (this) { + is JsonPrimitive -> toAny() + is JsonArray -> toList() + is JsonObject -> toMap() + } +} + +@JvmSynthetic +internal fun JsonPrimitive.toAny(): Any? { + return if (isString) { + contentOrNull + } else { + booleanOrNull ?: intOrNull ?: longOrNull ?: doubleOrNull + } +} + +@JvmSynthetic +internal fun JsonArray.toList(): List = map { it.toAny() } + +@JvmSynthetic +internal fun JsonObject.toMap(): Map = mapValues { it.value.toAny() } + +internal object AnySerializer : KSerializer { + private val delegate = JsonElement.serializer() + override val descriptor: SerialDescriptor + get() = SerialDescriptor("Any", delegate.descriptor) + + override fun serialize(encoder: Encoder, value: Any?) { + val jsonElement = value.toJsonElement() + encoder.encodeSerializableValue(delegate, jsonElement) + } + + override fun deserialize(decoder: Decoder): Any? { + val jsonElement = decoder.decodeSerializableValue(delegate) + return jsonElement.toAny() + } +} diff --git a/sdk/src/main/java/com/amplitude/evaluation-core/src/commonMain/kotlin/EvaluationVariant.kt b/sdk/src/main/java/com/amplitude/evaluation-core/src/commonMain/kotlin/EvaluationVariant.kt index 1ace4db..bdd63e5 100644 --- a/sdk/src/main/java/com/amplitude/evaluation-core/src/commonMain/kotlin/EvaluationVariant.kt +++ b/sdk/src/main/java/com/amplitude/evaluation-core/src/commonMain/kotlin/EvaluationVariant.kt @@ -1,6 +1,9 @@ +@file:UseSerializers(AnySerializer::class) + package com.amplitude.experiment.evaluation import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers @Serializable data class EvaluationVariant( diff --git a/sdk/src/main/java/com/amplitude/evaluation-core/src/commonMain/kotlin/Murmur3.kt b/sdk/src/main/java/com/amplitude/evaluation-core/src/commonMain/kotlin/Murmur3.kt new file mode 100644 index 0000000..d85d111 --- /dev/null +++ b/sdk/src/main/java/com/amplitude/evaluation-core/src/commonMain/kotlin/Murmur3.kt @@ -0,0 +1,96 @@ +package com.amplitude.experiment.evaluation + +internal object Murmur3 { + + private const val C1_32 = -0x3361d2af + private const val C2_32 = 0x1b873593 + private const val R1_32 = 15 + private const val R2_32 = 13 + private const val M_32 = 5 + private const val N_32 = -0x19ab949c + + internal fun hash32x86(data: ByteArray, length: Int, seed: Int): Int { + var hash = seed + val nblocks = length shr 2 + + // body + for (i in 0 until nblocks) { + val index = (i shl 2) + val k: Int = data.readIntLe(index) + hash = mix32(k, hash) + } + + // tail + val index = (nblocks shl 2) + var k1 = 0 + + when (length - index) { + 3 -> { + k1 = k1 xor ((data[index + 2].toInt() and 0xff) shl 16) + k1 = k1 xor ((data[index + 1].toInt() and 0xff) shl 8) + k1 = k1 xor ((data[index].toInt() and 0xff)) + + // mix functions + k1 *= C1_32 + k1 = k1.rotateLeft(R1_32) + k1 *= C2_32 + hash = hash xor k1 + } + 2 -> { + k1 = k1 xor ((data[index + 1].toInt() and 0xff) shl 8) + k1 = k1 xor ((data[index].toInt() and 0xff)) + k1 *= C1_32 + k1 = k1.rotateLeft(R1_32) + k1 *= C2_32 + hash = hash xor k1 + } + 1 -> { + k1 = k1 xor ((data[index].toInt() and 0xff)) + k1 *= C1_32 + k1 = k1.rotateLeft(R1_32) + k1 *= C2_32 + hash = hash xor k1 + } + } + hash = hash xor length + return fmix32(hash) + } + + private fun mix32(k: Int, hash: Int): Int { + var kResult = k + var hashResult = hash + kResult *= C1_32 + kResult = kResult.rotateLeft(R1_32) + kResult *= C2_32 + hashResult = hashResult xor kResult + return hashResult.rotateLeft( + R2_32 + ) * M_32 + N_32 + } + + private fun fmix32(hash: Int): Int { + var hashResult = hash + hashResult = hashResult xor (hashResult ushr 16) + hashResult *= -0x7a143595 + hashResult = hashResult xor (hashResult ushr 13) + hashResult *= -0x3d4d51cb + hashResult = hashResult xor (hashResult ushr 16) + return hashResult + } + + private fun Int.reverseBytes(): Int { + return (this and -0x1000000 ushr 24) or + (this and 0x00ff0000 ushr 8) or + (this and 0x0000ff00 shl 8) or + (this and 0x000000ff shl 24) + } + + private fun ByteArray.readIntLe(index: Int = 0): Int { + return ( + this[index].toInt() and 0xff shl 24 + or (this[index + 1].toInt() and 0xff shl 16) + or (this[index + 2].toInt() and 0xff shl 8) + or (this[index + 3].toInt() and 0xff) + ).reverseBytes() + } +}