diff --git a/sdk/src/main/java/com/qonversion/android/sdk/Qonversion.kt b/sdk/src/main/java/com/qonversion/android/sdk/Qonversion.kt index 703566402..1f0787450 100644 --- a/sdk/src/main/java/com/qonversion/android/sdk/Qonversion.kt +++ b/sdk/src/main/java/com/qonversion/android/sdk/Qonversion.kt @@ -1,15 +1,18 @@ package com.qonversion.android.sdk import android.app.Activity +import android.content.Context import android.net.Uri import android.util.Log import com.qonversion.android.sdk.dto.QAttributionProvider import com.qonversion.android.sdk.dto.QPurchaseOptions import com.qonversion.android.sdk.dto.QPurchaseResult +import com.qonversion.android.sdk.dto.QRemoteConfigFallbackValue import com.qonversion.android.sdk.dto.products.QProduct import com.qonversion.android.sdk.dto.properties.QUserPropertyKey import com.qonversion.android.sdk.internal.InternalConfig import com.qonversion.android.sdk.internal.QonversionInternal +import com.qonversion.android.sdk.internal.services.BundledRemoteConfigDefaults import com.qonversion.android.sdk.listeners.QonversionEmptyCallback import com.qonversion.android.sdk.listeners.QonversionExperimentAttachCallback import com.qonversion.android.sdk.listeners.QDeferredPurchasesListener @@ -49,6 +52,24 @@ interface Qonversion { "the initialize method before accessing the shared instance of Qonversion." ) + /** + * Reads a Remote Config default directly from the generated asset bundled with the app. + * + * This synchronous API is independent of SDK initialization, networking, identity and + * caches. Put the generated `qonversion_remote_config_defaults.json` file in the app's + * `assets` directory and call this method with its logical [contextKey]. A non-null + * wrapper whose [QRemoteConfigFallbackValue.rawValue] is null represents a present JSON + * `null`; a null wrapper means the key is absent or the bundle failed strict validation. + * + * @param context any Android context used only to access the application asset. + * @param contextKey logical Remote Config key from the generated bundle. + */ + @JvmStatic + fun fallbackRemoteConfigValue( + context: Context, + contextKey: String, + ): QRemoteConfigFallbackValue? = BundledRemoteConfigDefaults.value(context, contextKey) + /** * An entry point to use Qonversion SDK. Call to initialize Qonversion SDK with required and extra configs. * The function is the best way to set additional configs you need to use Qonversion SDK. diff --git a/sdk/src/main/java/com/qonversion/android/sdk/dto/QRemoteConfigFallbackValue.kt b/sdk/src/main/java/com/qonversion/android/sdk/dto/QRemoteConfigFallbackValue.kt new file mode 100644 index 000000000..80756ea99 --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/dto/QRemoteConfigFallbackValue.kt @@ -0,0 +1,13 @@ +package com.qonversion.android.sdk.dto + +/** + * A value read directly from the Remote Config defaults bundled with the app. + * + * [rawValue] is one of the JSON-compatible Kotlin values: [String], [Double], + * [Boolean], an immutable [List], an immutable [Map], or `null`. The wrapper + * itself remains non-null for a present JSON `null`, so callers can distinguish + * that value from a missing or invalid bundled key. + */ +class QRemoteConfigFallbackValue internal constructor( + val rawValue: Any?, +) diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/services/BundledRemoteConfigDefaults.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/services/BundledRemoteConfigDefaults.kt new file mode 100644 index 000000000..af2b55696 --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/services/BundledRemoteConfigDefaults.kt @@ -0,0 +1,445 @@ +package com.qonversion.android.sdk.internal.services + +import android.content.Context +import com.qonversion.android.sdk.dto.QRemoteConfigFallbackValue +import com.squareup.moshi.JsonReader +import okio.Buffer +import okio.ByteString.Companion.decodeBase64 +import java.io.ByteArrayOutputStream +import java.io.InputStream +import java.math.BigInteger +import java.nio.ByteBuffer +import java.nio.charset.CodingErrorAction +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.util.Collections + +internal const val BUNDLED_REMOTE_CONFIG_DEFAULTS_FILE_NAME = "qonversion_remote_config_defaults.json" +internal const val BUNDLED_REMOTE_CONFIG_DEFAULTS_MAX_BYTES = 8 * 1024 * 1024 +internal const val BUNDLED_REMOTE_CONFIG_DEFAULT_VALUE_MAX_BYTES = 64 * 1024 +internal const val BUNDLED_REMOTE_CONFIG_DEFAULT_JSON_MAX_DEPTH = 64 + +private const val BUNDLED_REMOTE_CONFIG_DEFAULTS_SCHEMA_VERSION = 1 +private const val BUNDLED_REMOTE_CONFIG_READ_BUFFER_BYTES = 8 * 1024 +private const val BUNDLED_REMOTE_CONFIG_DEFAULTS_MAX_KEYS = 1_000 +private const val BUNDLED_REMOTE_CONFIG_LOGICAL_KEY_MAX_BYTES = 256 +private const val BUNDLED_REMOTE_CONFIG_UID_MAX_CODE_POINTS = 36 +private const val BUNDLED_REMOTE_CONFIG_DEFAULTS_DIGEST_DOMAIN = + "qonversion.remote-config-fallback-defaults.v1" +private const val PORTABLE_JSON_MAX_INTEGER = 9_007_199_254_740_991L +private val PORTABLE_JSON_MAX_INTEGER_BIG = BigInteger.valueOf(PORTABLE_JSON_MAX_INTEGER) +private val PORTABLE_JSON_MIN_INTEGER_BIG = PORTABLE_JSON_MAX_INTEGER_BIG.negate() +private val LOWERCASE_SHA256_PATTERN = Regex("^[0-9a-f]{64}$") + +internal fun interface BundledRemoteConfigDefaultsAssetSource { + fun open(context: Context): InputStream +} + +internal class BundledRemoteConfigDefaultsReader( + private val assetSource: BundledRemoteConfigDefaultsAssetSource = + BundledRemoteConfigDefaultsAssetSource { context -> + context.assets.open(BUNDLED_REMOTE_CONFIG_DEFAULTS_FILE_NAME) + }, + private val maxArtifactBytes: Int = BUNDLED_REMOTE_CONFIG_DEFAULTS_MAX_BYTES, +) { + fun read(context: Context): BundledRemoteConfigDefaultsDocument? { + return try { + val bytes = assetSource.open(context).use { it.readBounded(maxArtifactBytes) } ?: return null + parse(bytes) + } catch (_: Exception) { + null + } + } + + @Suppress("ComplexMethod", "ComplexCondition", "ReturnCount") + private fun parse(bytes: ByteArray): BundledRemoteConfigDefaultsDocument? { + val source = bytes.decodeStrictUtf8() ?: return null + val reader = JsonReader.of(Buffer().write(bytes)) + reader.isLenient = false + + return try { + reader.beginObject() + if (!reader.readExpectedName("schemaVersion")) return null + val schemaVersion = reader.nextInt() + if (!reader.readExpectedName("projectId")) return null + val projectId = reader.nextLong() + if (!reader.readExpectedName("environmentUid")) return null + val environmentUid = reader.nextString() + if (!reader.readExpectedName("releaseUid")) return null + val releaseUid = reader.nextString() + if (!reader.readExpectedName("releaseNumber")) return null + val releaseNumber = reader.nextLong() + if (!reader.readExpectedName("manifestContentHash")) return null + val manifestContentHash = reader.nextString() + if (!reader.readExpectedName("defaultsDigest")) return null + val defaultsDigest = reader.nextString() + if (!reader.readExpectedName("defaults")) return null + val defaults = readDefaults(reader) ?: return null + if (reader.hasNext()) return null + reader.endObject() + if (reader.peek() != JsonReader.Token.END_DOCUMENT) return null + + if (schemaVersion != BUNDLED_REMOTE_CONFIG_DEFAULTS_SCHEMA_VERSION || + projectId <= 0 || projectId > PORTABLE_JSON_MAX_INTEGER || + releaseNumber <= 0 || releaseNumber > PORTABLE_JSON_MAX_INTEGER || + !environmentUid.isValidRemoteConfigUid() || !releaseUid.isValidRemoteConfigUid() || + !LOWERCASE_SHA256_PATTERN.matches(manifestContentHash) || + !LOWERCASE_SHA256_PATTERN.matches(defaultsDigest) + ) { + return null + } + + val computedDigest = computeDefaultsDigest( + schemaVersion = schemaVersion, + projectId = projectId, + environmentUid = environmentUid, + releaseUid = releaseUid, + releaseNumber = releaseNumber, + manifestContentHash = manifestContentHash, + defaults = defaults, + ) + if (computedDigest != defaultsDigest) return null + + val document = BundledRemoteConfigDefaultsDocument( + projectId = projectId, + environmentUid = environmentUid, + releaseUid = releaseUid, + releaseNumber = releaseNumber, + manifestContentHash = manifestContentHash, + defaultsDigest = defaultsDigest, + defaults = defaults, + ) + if (document.canonicalJson() != source) return null + document + } catch (_: Exception) { + null + } + } + + @Suppress("ComplexMethod", "ComplexCondition", "ReturnCount") + private fun readDefaults(reader: JsonReader): List? { + val defaults = mutableListOf() + reader.beginArray() + var previousKeyBytes: ByteArray? = null + while (reader.hasNext()) { + if (defaults.size == BUNDLED_REMOTE_CONFIG_DEFAULTS_MAX_KEYS) return null + reader.beginObject() + if (!reader.readExpectedName("key")) return null + val key = reader.nextString() + if (!reader.readExpectedName("variationUid")) return null + val variationUid = reader.nextString() + if (!reader.readExpectedName("valueBase64")) return null + val valueBase64 = reader.nextString() + if (reader.hasNext()) return null + reader.endObject() + + val keyBytes = key.toByteArray(StandardCharsets.UTF_8) + if (keyBytes.isEmpty() || keyBytes.size > BUNDLED_REMOTE_CONFIG_LOGICAL_KEY_MAX_BYTES || + !variationUid.isValidRemoteConfigUid() || + previousKeyBytes?.let { compareUnsignedUtf8(it, keyBytes) >= 0 } == true + ) { + return null + } + previousKeyBytes = keyBytes + + val decoded = valueBase64.decodeBase64() ?: return null + if (decoded.base64() != valueBase64) return null + val rawJson = decoded.toByteArray() + if (rawJson.isEmpty() || rawJson.size > BUNDLED_REMOTE_CONFIG_DEFAULT_VALUE_MAX_BYTES || + rawJson.decodeStrictUtf8() == null + ) { + return null + } + val parsedValue = parsePortableJson(rawJson) ?: return null + defaults += BundledRemoteConfigDefault( + key = key, + variationUid = variationUid, + valueBase64 = valueBase64, + rawJson = rawJson, + parsedValue = parsedValue.value, + ) + } + reader.endArray() + return defaults + } +} + +internal class BundledRemoteConfigDefaultsCache( + private val reader: BundledRemoteConfigDefaultsReader, +) { + @Volatile + private var loaded: LoadedDocument? = null + + fun value(context: Context, contextKey: String): QRemoteConfigFallbackValue? { + val document = loadedDocument(context) ?: return null + return document.defaultFor(contextKey)?.toPublicValue() + } + + private fun loadedDocument(context: Context): BundledRemoteConfigDefaultsDocument? { + val existing = loaded + if (existing != null) return existing.document + return synchronized(this) { + val rechecked = loaded + if (rechecked != null) { + rechecked.document + } else { + reader.read(context).also { loaded = LoadedDocument(it) } + } + } + } + + private data class LoadedDocument(val document: BundledRemoteConfigDefaultsDocument?) +} + +internal object BundledRemoteConfigDefaults { + @Volatile + private var cache = defaultCache() + + fun value(context: Context, contextKey: String): QRemoteConfigFallbackValue? = + cache.value(context, contextKey) + + @Synchronized + internal fun installReaderForTests(reader: BundledRemoteConfigDefaultsReader) { + cache = BundledRemoteConfigDefaultsCache(reader) + } + + @Synchronized + internal fun resetForTests() { + cache = defaultCache() + } + + private fun defaultCache() = BundledRemoteConfigDefaultsCache(BundledRemoteConfigDefaultsReader()) +} + +internal class BundledRemoteConfigDefaultsDocument( + val projectId: Long, + val environmentUid: String, + val releaseUid: String, + val releaseNumber: Long, + val manifestContentHash: String, + val defaultsDigest: String, + defaults: List, +) { + private val orderedDefaults = Collections.unmodifiableList(defaults.toList()) + private val defaultsByKey = Collections.unmodifiableMap(orderedDefaults.associateBy { it.key }) + + fun defaultFor(key: String): BundledRemoteConfigDefault? = defaultsByKey[key] + + internal fun canonicalJson(): String = buildString { + append("{\"schemaVersion\":1,\"projectId\":").append(projectId) + append(",\"environmentUid\":").appendGoJsonString(environmentUid) + append(",\"releaseUid\":").appendGoJsonString(releaseUid) + append(",\"releaseNumber\":").append(releaseNumber) + append(",\"manifestContentHash\":\"").append(manifestContentHash).append('"') + append(",\"defaultsDigest\":\"").append(defaultsDigest).append('"') + append(",\"defaults\":[") + orderedDefaults.forEachIndexed { index, value -> + if (index > 0) append(',') + append("{\"key\":").appendGoJsonString(value.key) + append(",\"variationUid\":").appendGoJsonString(value.variationUid) + append(",\"valueBase64\":\"").append(value.valueBase64).append("\"}") + } + append("]}") + } +} + +internal class BundledRemoteConfigDefault( + val key: String, + val variationUid: String, + val valueBase64: String, + rawJson: ByteArray, + private val parsedValue: Any?, +) { + private val storedRawJson = rawJson.clone() + val rawJsonBytes: ByteArray get() = storedRawJson.clone() + + fun toPublicValue() = QRemoteConfigFallbackValue(parsedValue) + + internal fun digestBytes(): ByteArray = storedRawJson.clone() +} + +private data class ParsedJson(val value: Any?) + +private fun parsePortableJson(bytes: ByteArray): ParsedJson? = try { + val reader = JsonReader.of(Buffer().write(bytes)) + reader.isLenient = false + val value = reader.readPortableJsonValue(depth = 1) + if (reader.peek() != JsonReader.Token.END_DOCUMENT) null else ParsedJson(value) +} catch (_: Exception) { + null +} + +@Suppress("ComplexMethod") +private fun JsonReader.readPortableJsonValue(depth: Int): Any? = when (peek()) { + JsonReader.Token.BEGIN_ARRAY -> { + if (depth > BUNDLED_REMOTE_CONFIG_DEFAULT_JSON_MAX_DEPTH) error("JSON is too deep") + beginArray() + val result = mutableListOf() + while (hasNext()) result += readPortableJsonValue(depth + 1) + endArray() + Collections.unmodifiableList(result) + } + JsonReader.Token.BEGIN_OBJECT -> { + if (depth > BUNDLED_REMOTE_CONFIG_DEFAULT_JSON_MAX_DEPTH) error("JSON is too deep") + beginObject() + val result = linkedMapOf() + while (hasNext()) { + val name = nextName() + if (!name.hasValidSurrogatePairs()) error("unpaired surrogate in JSON object member") + if (result.containsKey(name)) error("duplicate JSON object member") + result[name] = readPortableJsonValue(depth + 1) + } + endObject() + Collections.unmodifiableMap(result) + } + JsonReader.Token.STRING -> nextString().also { + if (!it.hasValidSurrogatePairs()) error("unpaired surrogate in JSON string") + } + JsonReader.Token.NUMBER -> { + val token = nextString() + if (token.isPlainJsonInteger()) { + val integer = BigInteger(token) + if (integer < PORTABLE_JSON_MIN_INTEGER_BIG || integer > PORTABLE_JSON_MAX_INTEGER_BIG) { + error("JSON integer is outside the portable range") + } + } + token.toDouble().takeIf(Double::isFinite) ?: error("JSON number is not finite binary64") + } + JsonReader.Token.BOOLEAN -> nextBoolean() + JsonReader.Token.NULL -> nextNull() + else -> error("expected one JSON value") +} + +private fun String.isPlainJsonInteger(): Boolean = none { it == '.' || it == 'e' || it == 'E' } + +private fun String.hasValidSurrogatePairs(): Boolean { + var index = 0 + var valid = true + while (index < length && valid) { + val current = this[index] + when { + current.isHighSurrogate() -> { + valid = index + 1 < length && this[index + 1].isLowSurrogate() + if (valid) index += 2 + } + current.isLowSurrogate() -> valid = false + else -> index += 1 + } + } + return valid +} + +private fun computeDefaultsDigest( + schemaVersion: Int, + projectId: Long, + environmentUid: String, + releaseUid: String, + releaseNumber: Long, + manifestContentHash: String, + defaults: List, +): String { + val digest = MessageDigest.getInstance("SHA-256") + digest.writeLengthPrefixed(BUNDLED_REMOTE_CONFIG_DEFAULTS_DIGEST_DOMAIN.toByteArray()) + digest.writeLengthPrefixed(schemaVersion.toString().toByteArray()) + digest.writeLengthPrefixed(projectId.toString().toByteArray()) + digest.writeLengthPrefixed(environmentUid.toByteArray()) + digest.writeLengthPrefixed(releaseUid.toByteArray()) + digest.writeLengthPrefixed(releaseNumber.toString().toByteArray()) + digest.writeLengthPrefixed(manifestContentHash.toByteArray()) + digest.writeLengthPrefixed(defaults.size.toString().toByteArray()) + defaults.forEach { value -> + digest.writeLengthPrefixed(value.key.toByteArray()) + digest.writeLengthPrefixed(value.variationUid.toByteArray()) + digest.writeLengthPrefixed(value.digestBytes()) + } + return digest.digest().toLowercaseHex() +} + +private fun MessageDigest.writeLengthPrefixed(value: ByteArray) { + update(ByteBuffer.allocate(Long.SIZE_BYTES).putLong(value.size.toLong()).array()) + update(value) +} + +private fun ByteArray.toLowercaseHex(): String = buildString(size * 2) { + for (byte in this@toLowercaseHex) { + val value = byte.toInt() and BYTE_MASK + append(HEX[value ushr HEX_HIGH_NIBBLE_SHIFT]) + append(HEX[value and HEX_NIBBLE_MASK]) + } +} + +private fun JsonReader.readExpectedName(expected: String): Boolean = + hasNext() && nextName() == expected + +private fun String.isValidRemoteConfigUid(): Boolean = + isNotEmpty() && codePointCount(0, length) <= BUNDLED_REMOTE_CONFIG_UID_MAX_CODE_POINTS + +private fun compareUnsignedUtf8(left: ByteArray, right: ByteArray): Int { + val sharedLength = minOf(left.size, right.size) + for (index in 0 until sharedLength) { + val comparison = (left[index].toInt() and BYTE_MASK) + .compareTo(right[index].toInt() and BYTE_MASK) + if (comparison != 0) return comparison + } + return left.size.compareTo(right.size) +} + +private fun InputStream.readBounded(maxBytes: Int): ByteArray? { + val output = ByteArrayOutputStream(minOf(maxBytes, BUNDLED_REMOTE_CONFIG_READ_BUFFER_BYTES)) + val buffer = ByteArray(BUNDLED_REMOTE_CONFIG_READ_BUFFER_BYTES) + var total = 0 + var read = read(buffer) + while (read >= 0) { + if (read > 0) { + if (read > maxBytes - total) return null + output.write(buffer, 0, read) + total += read + } + read = read(buffer) + } + return output.toByteArray() +} + +private fun ByteArray.decodeStrictUtf8(): String? = try { + StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(this)) + .toString() +} catch (_: Exception) { + null +} + +@Suppress("ComplexMethod") +private fun StringBuilder.appendGoJsonString(value: String): StringBuilder { + append('"') + value.forEach { character -> + when (character) { + '"' -> append("\\\"") + '\\' -> append("\\\\") + '\b' -> append("\\b") + '\u000c' -> append("\\f") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + '<' -> append("\\u003c") + '>' -> append("\\u003e") + '&' -> append("\\u0026") + '\u2028' -> append("\\u2028") + '\u2029' -> append("\\u2029") + else -> if (character < ' ') { + append("\\u00") + append(HEX[(character.code ushr HEX_HIGH_NIBBLE_SHIFT) and HEX_NIBBLE_MASK]) + append(HEX[character.code and HEX_NIBBLE_MASK]) + } else { + append(character) + } + } + } + return append('"') +} + +private const val BYTE_MASK = 0xff +private const val HEX_HIGH_NIBBLE_SHIFT = 4 +private const val HEX_NIBBLE_MASK = 0x0f +private const val HEX = "0123456789abcdef" diff --git a/sdk/src/test/java/com/qonversion/android/sdk/QonversionBundledRemoteConfigDefaultsTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/QonversionBundledRemoteConfigDefaultsTest.kt new file mode 100644 index 000000000..f0225afd0 --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/QonversionBundledRemoteConfigDefaultsTest.kt @@ -0,0 +1,60 @@ +package com.qonversion.android.sdk + +import android.content.Context +import com.qonversion.android.sdk.internal.services.BundledRemoteConfigDefaults +import com.qonversion.android.sdk.internal.services.BundledRemoteConfigDefaultsAssetSource +import com.qonversion.android.sdk.internal.services.BundledRemoteConfigDefaultsReader +import io.mockk.mockk +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import java.io.ByteArrayInputStream + +internal class QonversionBundledRemoteConfigDefaultsTest { + @After + fun resetProcessCache() { + BundledRemoteConfigDefaults.resetForTests() + } + + @Test + fun `static fallback getter works without initializing Qonversion`() { + val context = mockk(relaxed = true) + BundledRemoteConfigDefaults.installReaderForTests( + BundledRemoteConfigDefaultsReader( + BundledRemoteConfigDefaultsAssetSource { + ByteArrayInputStream(SERVER_GOLDEN_ARTIFACT.toByteArray()) + }, + ), + ) + + val value = Qonversion.fallbackRemoteConfigValue(context, "alpha") + + assertNotNull(value) + assertEquals(mapOf("message" to "Привет 👋"), value?.rawValue) + assertNull(Qonversion.fallbackRemoteConfigValue(context, "missing")) + } + + @Test + fun `Java static API shape accepts only Context and context key`() { + val method = Qonversion::class.java.getMethod( + "fallbackRemoteConfigValue", + Context::class.java, + String::class.java, + ) + + assertEquals("com.qonversion.android.sdk.dto.QRemoteConfigFallbackValue", method.returnType.name) + } + + private companion object { + const val SERVER_GOLDEN_ARTIFACT = + "{\"schemaVersion\":1,\"projectId\":42,\"environmentUid\":\"env-production\"," + + "\"releaseUid\":\"release-portable\",\"releaseNumber\":7," + + "\"manifestContentHash\":\"0291766e896e3f36aca5385082d74e8bd70fabf12ee776b7dfa2cd4d961c9dea\"," + + "\"defaultsDigest\":\"9e4dfcb4069901c3af4341383c814b24cdc39b20491f7a4593e0036d01f39540\"," + + "\"defaults\":[{\"key\":\"alpha\",\"variationUid\":\"variation-alpha\"," + + "\"valueBase64\":\"IHsgIm1lc3NhZ2UiOiAi0J/RgNC40LLQtdGCIPCfkYsiIH0gCg==\"}," + + "{\"key\":\"beta\",\"variationUid\":\"variation-beta\",\"valueBase64\":\"bnVsbA==\"}]}" + } +} diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/services/BundledRemoteConfigDefaultsReaderTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/services/BundledRemoteConfigDefaultsReaderTest.kt new file mode 100644 index 000000000..84ab5b5bc --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/services/BundledRemoteConfigDefaultsReaderTest.kt @@ -0,0 +1,432 @@ +package com.qonversion.android.sdk.internal.services + +import android.content.Context +import okio.Buffer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.ByteArrayInputStream +import java.nio.ByteBuffer +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.util.Base64 +import java.util.concurrent.Callable +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +internal class BundledRemoteConfigDefaultsReaderTest { + private val context = io.mockk.mockk(relaxed = true) + + @After + fun resetProcessCache() { + BundledRemoteConfigDefaults.resetForTests() + } + + @Test + fun `server golden parses with exact raw bytes and header`() { + val goldenBytes = SERVER_GOLDEN_ARTIFACT.toByteArray() + assertEquals( + SERVER_GOLDEN_ARTIFACT_SHA256, + MessageDigest.getInstance("SHA-256").digest(goldenBytes).joinToString("") { "%02x".format(it) }, + ) + val document = reader(goldenBytes).read(context) + + assertNotNull(document) + requireNotNull(document) + assertEquals(42L, document.projectId) + assertEquals("env-production", document.environmentUid) + assertEquals("release-portable", document.releaseUid) + assertEquals(7L, document.releaseNumber) + assertEquals(SERVER_GOLDEN_MANIFEST_CONTENT_HASH, document.manifestContentHash) + assertEquals(SERVER_GOLDEN_DEFAULTS_DIGEST, document.defaultsDigest) + assertEquals( + " { \"message\": \"Привет 👋\" } \n".toByteArray().toList(), + requireNotNull(document.defaultFor("alpha")).rawJsonBytes.toList(), + ) + assertEquals("variation-alpha", document.defaultFor("alpha")?.variationUid) + assertEquals("null", String(requireNotNull(document.defaultFor("beta")).rawJsonBytes)) + } + + @Test + fun `all JSON value kinds and Unicode are exposed including wrapped null`() { + val cache = BundledRemoteConfigDefaultsCache(reader(SERVER_ALL_TYPES_ARTIFACT.toByteArray())) + + assertEquals(listOf(1.0, "two", false), cache.value(context, "array")?.rawValue) + assertEquals(true, cache.value(context, "bool")?.rawValue) + val nullValue = cache.value(context, "null") + assertNotNull("a present JSON null must be distinguishable from a missing key", nullValue) + assertNull(nullValue?.rawValue) + assertEquals(42.5, cache.value(context, "number")?.rawValue) + assertEquals( + mapOf("enabled" to true, "nested" to "value"), + cache.value(context, "object")?.rawValue, + ) + assertEquals("hello", cache.value(context, "string")?.rawValue) + assertEquals("Привет 👋", cache.value(context, "unicode")?.rawValue) + assertNull(cache.value(context, "missing")) + } + + @Test + fun `corrupt or noncanonical artifacts fail closed`() { + val valid = String(artifact(listOf(default("alpha", "true"), default("beta", "null")))) + val invalidCases = mapOf( + "digest" to valid.replace( + Regex("\\\"defaultsDigest\\\":\\\"[0-9a-f]{64}\\\""), + "\"defaultsDigest\":\"${"0".repeat(64)}\"", + ).toByteArray(), + "base64" to valid.replace( + Regex("\\\"valueBase64\\\":\\\"[^\\\"]+\\\""), + "\"valueBase64\":\"***\"", + ).toByteArray(), + "json" to artifact(listOf(default("alpha", "not-json"))), + "schema" to artifact(listOf(default("alpha", "true")), schemaVersion = 2), + "manifest hash uppercase" to valid.replace( + SERVER_GOLDEN_MANIFEST_CONTENT_HASH, + SERVER_GOLDEN_MANIFEST_CONTENT_HASH.uppercase(), + ).toByteArray(), + "unsorted" to artifact(listOf(default("beta", "null"), default("alpha", "true"))), + "duplicate" to artifact(listOf(default("alpha", "true"), default("alpha", "false"))), + "missing field" to valid.replace(Regex(",\\\"releaseUid\\\":\\\"[^\\\"]+\\\""), "").toByteArray(), + "unknown field" to valid.replaceFirst("{", "{\"unknown\":true,").toByteArray(), + "duplicate field" to valid.replace( + "{\"schemaVersion\":1,", + "{\"schemaVersion\":1,\"schemaVersion\":1,", + ).toByteArray(), + "noncanonical whitespace" to " $valid".toByteArray(), + "legacy fallback" to "{\"remote_config_list\":[]}".toByteArray(), + ) + + invalidCases.forEach { (name, bytes) -> + assertNull(name, reader(bytes).read(context)) + } + } + + @Test + fun `header identifiers and collection bounds are enforced`() { + val validDefault = default("alpha", "true") + val invalidCases = mapOf( + "zero project id" to artifact(listOf(validDefault), projectId = 0), + "negative project id" to artifact(listOf(validDefault), projectId = -1), + "zero release number" to artifact(listOf(validDefault), releaseNumber = 0), + "negative release number" to artifact(listOf(validDefault), releaseNumber = -1), + "empty environment uid" to artifact(listOf(validDefault), environmentUid = ""), + "long environment uid" to artifact(listOf(validDefault), environmentUid = "e".repeat(37)), + "empty release uid" to artifact(listOf(validDefault), releaseUid = ""), + "long release uid" to artifact(listOf(validDefault), releaseUid = "r".repeat(37)), + "empty key" to artifact(listOf(DefaultFixture("", "variation", "true".toByteArray()))), + "key over 256 UTF-8 bytes" to artifact( + listOf(DefaultFixture("é".repeat(129), "variation", "true".toByteArray())), + ), + "empty variation uid" to artifact( + listOf(DefaultFixture("alpha", "", "true".toByteArray())), + ), + "long variation uid" to artifact( + listOf(DefaultFixture("alpha", "v".repeat(37), "true".toByteArray())), + ), + "more than 1000 defaults" to artifact( + List(1_001) { index -> default("key-${index.toString().padStart(4, '0')}", "true") }, + ), + ) + + invalidCases.forEach { (name, bytes) -> + assertNull(name, reader(bytes).read(context)) + } + + assertNotNull( + "UID limits are measured in Unicode code points", + reader(artifact(listOf(validDefault), environmentUid = "😀".repeat(36))).read(context), + ) + assertNotNull( + "key limit is inclusive and measured in UTF-8 bytes", + reader( + artifact(listOf(DefaultFixture("é".repeat(128), "variation", "true".toByteArray()))), + ).read(context), + ) + } + + @Test + fun `default entries reject missing duplicate unknown and noncanonical fields`() { + val valid = String(artifact(listOf(default("alpha", "true")))) + val invalidCases = mapOf( + "missing variation uid" to valid.replace( + Regex(",\"variationUid\":\"[^\"]+\""), + "", + ), + "duplicate key field" to valid.replace( + "{\"key\":\"alpha\",", + "{\"key\":\"alpha\",\"key\":\"alpha\",", + ), + "unknown default field" to valid.replace( + "{\"key\":\"alpha\",", + "{\"unknown\":true,\"key\":\"alpha\",", + ), + "unpadded base64" to valid.replace("dHJ1ZQ==", "dHJ1ZQ"), + ) + + invalidCases.forEach { (name, artifact) -> + assertNull(name, reader(artifact.toByteArray()).read(context)) + } + } + + @Test + fun `invalid UTF-8 and oversized asset fail closed`() { + val invalidUtf8 = artifact(listOf(default("alpha", "true"))).also { + it[it.indexOf('a'.code.toByte())] = 0x80.toByte() + } + + assertNull(reader(invalidUtf8).read(context)) + assertNull(reader(ByteArray(BUNDLED_REMOTE_CONFIG_DEFAULTS_MAX_BYTES + 1) { ' '.code.toByte() }).read(context)) + } + + @Test + fun `value larger than serving limit and JSON deeper than serving limit fail closed`() { + val tooLarge = "\"${"x".repeat(BUNDLED_REMOTE_CONFIG_DEFAULT_VALUE_MAX_BYTES)}\"" + val tooDeep = "[".repeat(BUNDLED_REMOTE_CONFIG_DEFAULT_JSON_MAX_DEPTH + 1) + + "null" + "]".repeat(BUNDLED_REMOTE_CONFIG_DEFAULT_JSON_MAX_DEPTH + 1) + + assertNull(reader(artifact(listOf(default("alpha", tooLarge)))).read(context)) + assertNull(reader(artifact(listOf(default("alpha", tooDeep)))).read(context)) + } + + @Test + fun `portable number profile accepts shared boundaries and rejects divergence`() { + val finite = BundledRemoteConfigDefaultsCache( + reader(SERVER_NUMBER_BOUNDARIES_ARTIFACT.toByteArray()), + ) + + assertEquals(1e308, finite.value(context, "float_max")?.rawValue) + assertEquals(9_007_199_254_740_991.0, finite.value(context, "int_max")?.rawValue) + assertEquals(-9_007_199_254_740_991.0, finite.value(context, "int_min")?.rawValue) + listOf("1e309", "9007199254740992", "-9007199254740992").forEach { number -> + assertNull(number, reader(artifact(listOf(default("number", number)))).read(context)) + } + assertNull( + "project ID outside the portable exact integer range", + reader(artifact(listOf(default("number", "1")), projectId = 9_007_199_254_740_992L)).read(context), + ) + assertNull( + "release number outside the portable exact integer range", + reader(artifact(listOf(default("number", "1")), releaseNumber = 9_007_199_254_740_992L)).read(context), + ) + } + + @Test + fun `escaped surrogate pairs match the producer portable Unicode profile`() { + val valid = BundledRemoteConfigDefaultsCache( + reader(SERVER_ESCAPED_SURROGATE_ARTIFACT.toByteArray()), + ) + assertEquals("👋", valid.value(context, "escaped_pair")?.rawValue) + + listOf("\"\\ud83d\"", "\"\\ud83d\\u0041\"", "\"\\udc4b\"").forEach { raw -> + assertNull(raw, reader(artifact(listOf(default("unicode", raw)))).read(context)) + } + } + + @Test + fun `process cache loads asset once under concurrency and returns immutable values`() { + val reads = AtomicInteger() + val source = BundledRemoteConfigDefaultsAssetSource { + reads.incrementAndGet() + ByteArrayInputStream( + artifact( + listOf(default("object", "{\"nested\":[{\"value\":1}]}")), + ), + ) + } + val cache = BundledRemoteConfigDefaultsCache(BundledRemoteConfigDefaultsReader(source)) + val executor = Executors.newFixedThreadPool(8) + + val results = executor.invokeAll( + List(64) { Callable { cache.value(context, "object") } }, + ).map { it.get(5, TimeUnit.SECONDS) } + executor.shutdownNow() + + assertEquals(1, reads.get()) + assertTrue(results.all { it?.rawValue == mapOf("nested" to listOf(mapOf("value" to 1.0))) }) + val first = requireNotNull(results.first()).rawValue as Map<*, *> + @Suppress("UNCHECKED_CAST") + val mutable = first as MutableMap + assertThrows(UnsupportedOperationException::class.java) { mutable["mutated"] = true } + assertEquals( + mapOf("nested" to listOf(mapOf("value" to 1.0))), + cache.value(context, "object")?.rawValue, + ) + } + + @Test + fun `raw bytes and digest bytes are defensive copies`() { + val document = requireNotNull( + reader(artifact(listOf(default("alpha", "true")))).read(context), + ) + val entry = requireNotNull(document.defaultFor("alpha")) + + entry.rawJsonBytes[0] = 'f'.code.toByte() + entry.digestBytes()[0] = 'f'.code.toByte() + + assertEquals("true", String(entry.rawJsonBytes)) + assertEquals("true", String(entry.digestBytes())) + } + + @Test + fun `invalid artifact is negatively cached`() { + val reads = AtomicInteger() + val cache = BundledRemoteConfigDefaultsCache( + BundledRemoteConfigDefaultsReader( + BundledRemoteConfigDefaultsAssetSource { + reads.incrementAndGet() + ByteArrayInputStream("not-json".toByteArray()) + }, + ), + ) + + repeat(10) { assertNull(cache.value(context, "any")) } + + assertEquals(1, reads.get()) + } + + private fun reader(bytes: ByteArray) = BundledRemoteConfigDefaultsReader( + BundledRemoteConfigDefaultsAssetSource { ByteArrayInputStream(bytes) }, + ) + + private data class DefaultFixture( + val key: String, + val variationUid: String, + val value: ByteArray, + ) + + private fun default(key: String, rawJson: String) = DefaultFixture( + key = key, + variationUid = "variation-$key", + value = rawJson.toByteArray(), + ) + + private fun artifact( + defaults: List, + schemaVersion: Int = 1, + projectId: Long = 42, + environmentUid: String = "env-production", + releaseUid: String = "release-portable", + releaseNumber: Long = 7, + manifestContentHash: String = SERVER_GOLDEN_MANIFEST_CONTENT_HASH, + ): ByteArray { + val digest = defaultsDigest( + schemaVersion, + projectId, + environmentUid, + releaseUid, + releaseNumber, + manifestContentHash, + defaults, + ) + return buildString { + append("{\"schemaVersion\":").append(schemaVersion) + append(",\"projectId\":").append(projectId) + append(",\"environmentUid\":").append(jsonString(environmentUid)) + append(",\"releaseUid\":").append(jsonString(releaseUid)) + append(",\"releaseNumber\":").append(releaseNumber) + append(",\"manifestContentHash\":\"").append(manifestContentHash).append('"') + append(",\"defaultsDigest\":\"").append(digest).append('"') + append(",\"defaults\":[") + defaults.forEachIndexed { index, value -> + if (index > 0) append(',') + append("{\"key\":").append(jsonString(value.key)) + append(",\"variationUid\":").append(jsonString(value.variationUid)) + append(",\"valueBase64\":\"") + .append(Base64.getEncoder().encodeToString(value.value)) + .append("\"}") + } + append("]}") + }.toByteArray() + } + + private fun defaultsDigest( + schemaVersion: Int, + projectId: Long, + environmentUid: String, + releaseUid: String, + releaseNumber: Long, + manifestContentHash: String, + defaults: List, + ): String { + val digest = MessageDigest.getInstance("SHA-256") + fun part(bytes: ByteArray) { + digest.update(ByteBuffer.allocate(Long.SIZE_BYTES).putLong(bytes.size.toLong()).array()) + digest.update(bytes) + } + fun part(value: String) = part(value.toByteArray(StandardCharsets.UTF_8)) + + part("qonversion.remote-config-fallback-defaults.v1") + part(schemaVersion.toString()) + part(projectId.toString()) + part(environmentUid) + part(releaseUid) + part(releaseNumber.toString()) + part(manifestContentHash) + part(defaults.size.toString()) + defaults.forEach { value -> + part(value.key) + part(value.variationUid) + part(value.value) + } + return digest.digest().joinToString("") { "%02x".format(it) } + } + + private fun jsonString(value: String): String { + val buffer = Buffer() + val writer = com.squareup.moshi.JsonWriter.of(buffer) + writer.value(value) + writer.close() + return buffer.readUtf8() + } + + private companion object { + // Replaced only if the server contract golden changes. This is copied + // byte-for-byte from configurator's fallback_artifact_test.go. + const val SERVER_GOLDEN_ARTIFACT = + "{\"schemaVersion\":1,\"projectId\":42,\"environmentUid\":\"env-production\"," + + "\"releaseUid\":\"release-portable\",\"releaseNumber\":7," + + "\"manifestContentHash\":\"0291766e896e3f36aca5385082d74e8bd70fabf12ee776b7dfa2cd4d961c9dea\"," + + "\"defaultsDigest\":\"9e4dfcb4069901c3af4341383c814b24cdc39b20491f7a4593e0036d01f39540\"," + + "\"defaults\":[{\"key\":\"alpha\",\"variationUid\":\"variation-alpha\"," + + "\"valueBase64\":\"IHsgIm1lc3NhZ2UiOiAi0J/RgNC40LLQtdGCIPCfkYsiIH0gCg==\"}," + + "{\"key\":\"beta\",\"variationUid\":\"variation-beta\",\"valueBase64\":\"bnVsbA==\"}]}" + const val SERVER_GOLDEN_MANIFEST_CONTENT_HASH = + "0291766e896e3f36aca5385082d74e8bd70fabf12ee776b7dfa2cd4d961c9dea" + const val SERVER_GOLDEN_DEFAULTS_DIGEST = + "9e4dfcb4069901c3af4341383c814b24cdc39b20491f7a4593e0036d01f39540" + const val SERVER_GOLDEN_ARTIFACT_SHA256 = + "db09d5051c9702773d6dedb4023c8099b20f0cf49daf0471f553c44d4ceaafc5" + const val SERVER_ALL_TYPES_ARTIFACT = + "{\"schemaVersion\":1,\"projectId\":42,\"environmentUid\":\"env-production\"," + + "\"releaseUid\":\"release-all-json-types\",\"releaseNumber\":8," + + "\"manifestContentHash\":\"aa6a89035cd667e719c826029b46d063107991009382955b2332974fea7e417d\"," + + "\"defaultsDigest\":\"05237b0c07eac66a5ae1f7268c9afef3f0170b8649d22559388a4071ee81e95f\"," + + "\"defaults\":[{\"key\":\"array\",\"variationUid\":\"variation-array\",\"valueBase64\":\"WzEsInR3byIsZmFsc2Vd\"}," + + "{\"key\":\"bool\",\"variationUid\":\"variation-bool\",\"valueBase64\":\"dHJ1ZQ==\"}," + + "{\"key\":\"null\",\"variationUid\":\"variation-null\",\"valueBase64\":\"bnVsbA==\"}," + + "{\"key\":\"number\",\"variationUid\":\"variation-number\",\"valueBase64\":\"NDIuNQ==\"}," + + "{\"key\":\"object\",\"variationUid\":\"variation-object\",\"valueBase64\":\"eyJlbmFibGVkIjp0cnVlLCJuZXN0ZWQiOiJ2YWx1ZSJ9\"}," + + "{\"key\":\"string\",\"variationUid\":\"variation-string\",\"valueBase64\":\"ImhlbGxvIg==\"}," + + "{\"key\":\"unicode\",\"variationUid\":\"variation-unicode\",\"valueBase64\":\"ItCf0YDQuNCy0LXRgiDwn5GLIg==\"}]}" + const val SERVER_NUMBER_BOUNDARIES_ARTIFACT = + "{\"schemaVersion\":1,\"projectId\":42,\"environmentUid\":\"env-production\"," + + "\"releaseUid\":\"release-number-boundaries\",\"releaseNumber\":9," + + "\"manifestContentHash\":\"3896effad7acc9270542cb9c9ce76975c65888a19f6efc6eafba299e8e75be4b\"," + + "\"defaultsDigest\":\"0f8138237ed0fc124a161e6dcc2f5fe6e3fa78691b0dea2dbf7f42bc66736d8d\"," + + "\"defaults\":[{\"key\":\"float_max\",\"variationUid\":\"variation-float_max\",\"valueBase64\":\"MWUzMDg=\"}," + + "{\"key\":\"int_max\",\"variationUid\":\"variation-int_max\",\"valueBase64\":\"OTAwNzE5OTI1NDc0MDk5MQ==\"}," + + "{\"key\":\"int_min\",\"variationUid\":\"variation-int_min\",\"valueBase64\":\"LTkwMDcxOTkyNTQ3NDA5OTE=\"}]}" + const val SERVER_ESCAPED_SURROGATE_ARTIFACT = + "{\"schemaVersion\":1,\"projectId\":42,\"environmentUid\":\"env-production\"," + + "\"releaseUid\":\"release-escaped-surrogate\",\"releaseNumber\":10," + + "\"manifestContentHash\":\"e2d2e6cd92b6aca3bae40d4196bcedb6beade7b3dbf672bc13193b3dd9e7ad02\"," + + "\"defaultsDigest\":\"474f2f24b12594d138755a501c2868553a158201ddc955d1c9f5fc7900c8ce44\"," + + "\"defaults\":[{\"key\":\"escaped_pair\",\"variationUid\":\"variation-escaped-pair\"," + + "\"valueBase64\":\"Ilx1ZDgzZFx1ZGM0YiI=\"}]}" + } +}