diff --git a/docs/release-notes-draft-rc-v2-train.md b/docs/release-notes-draft-rc-v2-train.md
new file mode 100644
index 000000000..b5aacd384
--- /dev/null
+++ b/docs/release-notes-draft-rc-v2-train.md
@@ -0,0 +1,64 @@
+# Release notes draft — RC v2 train (Android SDK)
+
+Status: DRAFT for the upcoming release from `release/rc-v2`. Merge the relevant sections into the
+GitHub release description / changelog when the train ships.
+
+## Breaking behavior change: TLS certificate verification is now enforced
+
+Previous SDK versions disabled TLS verification on their own HTTP clients: the core API client
+(`NetworkModule.provideOkHttpClient`) trusted every certificate and every hostname, and the NoCodes
+module's `HttpsURLConnection` client did the same. This release removes both trust-all paths
+(commits `2cb72d80`, `907c3dc7`). All SDK traffic is now verified against the device's trust store,
+exactly like any other HTTPS traffic in your app.
+
+**Who is affected**
+
+- Apps inspecting SDK traffic through an intercepting proxy (Charles, Proxyman, mitmproxy, Fiddler)
+ with a locally installed root certificate.
+- Test or staging setups pointing the SDK at endpoints with self-signed or otherwise untrusted
+ certificates (e.g. via a proxy URL).
+- Corporate environments performing TLS interception without distributing the interception root to
+ the Android user trust store.
+
+**What you will see**
+
+Requests from the SDK fail the TLS handshake — `SSLHandshakeException` /
+`CertificateException`-style errors surfacing through the SDK as network errors. Production apps
+talking directly to Qonversion over the public internet are not affected.
+
+**What to do**
+
+Do not re-introduce trust-all TLS. For debugging, declare your proxy's root CA as a trusted debug
+certificate with Android's standard [network security configuration](https://developer.android.com/privacy-and-security/security-config):
+
+```xml
+
+
+
+
+
+
+
+
+```
+
+```xml
+
+
+```
+
+`` applies only to debuggable builds, so release builds keep full verification.
+
+## Other changes worth calling out
+
+- **Persistent Remote Config last-known-good cache.** Remote configs (v1 `remoteConfig` /
+ `remoteConfigList`) are now persisted per project/environment/user and served when a fetch fails
+ or the device is offline — previously such calls errored once the in-memory cache was gone after
+ a restart. If your code treats a remote-config error as "no config", it will now more often
+ receive the last successfully fetched values instead.
+- **Stricter `remoteConfigList` parsing.** A malformed element in the response now fails the whole
+ list call with `ResponseParsingFailed` instead of being silently skipped, so a partial list can
+ no longer be mistaken for the full one.
+- **New public API: `Qonversion.fallbackRemoteConfigValue(context, contextKey)`.** Synchronously
+ reads a default from the `qonversion_remote_config_defaults.json` asset bundled with the app —
+ available before SDK initialization and without any network.
diff --git a/nocodes/build.gradle b/nocodes/build.gradle
index 626d2985a..3b262cccb 100644
--- a/nocodes/build.gradle
+++ b/nocodes/build.gradle
@@ -67,10 +67,12 @@ dependencies {
api project(':sdk')
+ testImplementation 'junit:junit:4.13.2'
+
androidTestImplementation 'androidx.test:core:1.5.0'
androidTestImplementation "androidx.test:runner:1.5.2"
androidTestImplementation "androidx.test:rules:1.5.0"
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
}
-apply from: "../scripts/maven-release.gradle"
\ No newline at end of file
+apply from: "../scripts/maven-release.gradle"
diff --git a/nocodes/src/main/java/io/qonversion/nocodes/internal/networkLayer/networkClient/NetworkClientImpl.kt b/nocodes/src/main/java/io/qonversion/nocodes/internal/networkLayer/networkClient/NetworkClientImpl.kt
index fb4ee5280..61d359678 100644
--- a/nocodes/src/main/java/io/qonversion/nocodes/internal/networkLayer/networkClient/NetworkClientImpl.kt
+++ b/nocodes/src/main/java/io/qonversion/nocodes/internal/networkLayer/networkClient/NetworkClientImpl.kt
@@ -20,12 +20,6 @@ import java.io.OutputStreamWriter
import java.net.HttpURLConnection
import java.net.MalformedURLException
import java.net.URL
-import java.security.SecureRandom
-import java.security.cert.X509Certificate
-import javax.net.ssl.HttpsURLConnection
-import javax.net.ssl.SSLContext
-import javax.net.ssl.TrustManager
-import javax.net.ssl.X509TrustManager
private const val NETWORK_ENCODING = "utf-8"
@@ -66,19 +60,6 @@ internal class NetworkClientImpl(
return try {
val connection = url.openConnection() as HttpURLConnection
- // Trust all certificates for staging
- if (connection is HttpsURLConnection) {
- val trustAllCerts = arrayOf(object : X509TrustManager {
- override fun checkClientTrusted(chain: Array, authType: String) {}
- override fun checkServerTrusted(chain: Array, authType: String) {}
- override fun getAcceptedIssuers(): Array = arrayOf()
- })
- val sslContext = SSLContext.getInstance("TLS")
- sslContext.init(null, trustAllCerts, SecureRandom())
- connection.sslSocketFactory = sslContext.socketFactory
- connection.setHostnameVerifier { _, _ -> true }
- }
-
// Set smart timeout based on fallback availability
val timeout = if (isFallbackAvailable) {
TimeoutConstants.FALLBACK_AVAILABLE_TIMEOUT
diff --git a/nocodes/src/test/java/io/qonversion/nocodes/internal/networkLayer/networkClient/NetworkClientImplTest.kt b/nocodes/src/test/java/io/qonversion/nocodes/internal/networkLayer/networkClient/NetworkClientImplTest.kt
new file mode 100644
index 000000000..eda788bb2
--- /dev/null
+++ b/nocodes/src/test/java/io/qonversion/nocodes/internal/networkLayer/networkClient/NetworkClientImplTest.kt
@@ -0,0 +1,47 @@
+package io.qonversion.nocodes.internal.networkLayer.networkClient
+
+import io.qonversion.nocodes.internal.common.serializers.Serializer
+import org.junit.Assert.assertSame
+import org.junit.Test
+import java.net.URL
+import java.net.URLConnection
+import java.net.URLStreamHandler
+import java.security.Principal
+import java.security.cert.Certificate
+import javax.net.ssl.HttpsURLConnection
+
+internal class NetworkClientImplTest {
+ @Test
+ fun `https connections retain platform TLS verification`() {
+ lateinit var platformConnection: TestHttpsURLConnection
+ val url = URL(null, "https://api.qonversion.io", object : URLStreamHandler() {
+ override fun openConnection(url: URL): URLConnection {
+ return TestHttpsURLConnection(url).also { platformConnection = it }
+ }
+ })
+ val platformSocketFactory = HttpsURLConnection.getDefaultSSLSocketFactory()
+ val platformHostnameVerifier = HttpsURLConnection.getDefaultHostnameVerifier()
+
+ val connection = NetworkClientImpl(UnusedSerializer()).connect(url) as HttpsURLConnection
+
+ assertSame(platformConnection, connection)
+ assertSame(platformSocketFactory, connection.sslSocketFactory)
+ assertSame(platformHostnameVerifier, connection.hostnameVerifier)
+ }
+
+ private class UnusedSerializer : Serializer {
+ override fun serialize(data: Map): String = error("not used")
+ override fun deserialize(payload: String): Any = error("not used")
+ }
+
+ private class TestHttpsURLConnection(url: URL) : HttpsURLConnection(url) {
+ override fun connect() = Unit
+ override fun disconnect() = Unit
+ override fun usingProxy(): Boolean = false
+ override fun getCipherSuite(): String = ""
+ override fun getLocalCertificates(): Array? = null
+ override fun getServerCertificates(): Array = emptyArray()
+ override fun getPeerPrincipal(): Principal? = null
+ override fun getLocalPrincipal(): Principal? = null
+ }
+}
diff --git a/sample/paywall_config.default.json b/sample/paywall_config.default.json
new file mode 100644
index 000000000..7ad6c41f2
--- /dev/null
+++ b/sample/paywall_config.default.json
@@ -0,0 +1,23 @@
+{
+ "headline": "Find your calm",
+ "subtitle": "Guided meditations, sleep stories and breathing exercises. Five minutes a day is enough.",
+ "accentColor": "#7C5CFF",
+ "ctaText": "Start 7-day free trial",
+ "showCountdown": false,
+ "countdownSeconds": 0,
+ "products": [
+ {
+ "id": "calmly_monthly",
+ "title": "Monthly",
+ "priceText": "$9.99 / month",
+ "badge": null
+ },
+ {
+ "id": "calmly_annual",
+ "title": "Annual",
+ "priceText": "$59.99 / year",
+ "badge": "BEST VALUE"
+ }
+ ],
+ "highlightProductId": "calmly_annual"
+}
diff --git a/sample/src/main/AndroidManifest.xml b/sample/src/main/AndroidManifest.xml
index 13c15b910..91e552eff 100644
--- a/sample/src/main/AndroidManifest.xml
+++ b/sample/src/main/AndroidManifest.xml
@@ -46,9 +46,12 @@
SDK. Production merchants set this to their project's uid
issued in Qonversion Connect Apps onboarding.
- Scoped to the Qonversion sample project uid `kZfGkwHa`
- (matches DEFAULT_PROJECT_KEY in App.java). Must stay in sync
- with whatever project this sample initializes the SDK with.
+ Scoped to the Qonversion sample project uid `kZfGkwHa`.
+ Must stay in sync with whatever project this sample
+ initializes the SDK with — while App.java defaults to the
+ Remote Config v2 local playground project this filter does
+ not match, so redemption links stay inert until the project
+ key is switched back via the Home configuration dialog.
-->
diff --git a/sample/src/main/assets/qonversion_remote_config_defaults.json b/sample/src/main/assets/qonversion_remote_config_defaults.json
new file mode 100644
index 000000000..c5bb05660
--- /dev/null
+++ b/sample/src/main/assets/qonversion_remote_config_defaults.json
@@ -0,0 +1 @@
+{"schemaVersion":1,"projectId":12109,"environmentUid":"d3v00000-0000-4000-8000-000000000001","releaseUid":"7b90a56d-70a1-4a8d-a54e-9f90bdc2ef78","releaseNumber":45,"manifestContentHash":"059cb11e690a109f572249f4af48da18f616e5bf2d53965d5c9d3fb72f17e29e","defaultsDigest":"20b0b4b19076f4476fab744fa3c2ad4e91e34aaa46666962029c9e9d5d03cf80","defaults":[{"key":"breathing_exercises","variationUid":"7c692452993197e9aa1c6e7966636e8a","valueBase64":"eyJlbmFibGVkIjpmYWxzZSwic2Vzc2lvbnMiOjN9"},{"key":"paywall_config","variationUid":"paywall-summer-sale-1","valueBase64":"eyJoZWFkbGluZSI6IkF1dHVtbiBjYWxtIOKAlCA0MCUgb2ZmIiwic3VidGl0bGUiOiJBbm51YWwgcGxhbiBkaXNjb3VudGVkIGZvciB0aGUgbmV4dCBmaXZlIG1pbnV0ZXMuIEJyZWF0aGUgaW4sIHNhdmUsIGJyZWF0aGUgb3V0LiIsImFjY2VudENvbG9yIjoiI0U4NUQ3NSIsImN0YVRleHQiOiJDbGFpbSA0MCUgb2ZmIG5vdyIsInNob3dDb3VudGRvd24iOnRydWUsImNvdW50ZG93blNlY29uZHMiOjMwMCwicHJvZHVjdHMiOlt7ImlkIjoiY2FsbWx5X21vbnRobHkiLCJ0aXRsZSI6Ik1vbnRobHkiLCJwcmljZVRleHQiOiIkOS45OSAvIG1vbnRoIiwiYmFkZ2UiOm51bGx9LHsiaWQiOiJjYWxtbHlfYW5udWFsIiwidGl0bGUiOiJBbm51YWwiLCJwcmljZVRleHQiOiIkMzUuOTkgLyB5ZWFyIiwiYmFkZ2UiOiItNDAlIn1dLCJoaWdobGlnaHRQcm9kdWN0SWQiOiJjYWxtbHlfYW5udWFsIn0="}]}
\ No newline at end of file
diff --git a/sample/src/main/java/io/qonversion/sample/App.java b/sample/src/main/java/io/qonversion/sample/App.java
index 44cf7466a..81b93b577 100644
--- a/sample/src/main/java/io/qonversion/sample/App.java
+++ b/sample/src/main/java/io/qonversion/sample/App.java
@@ -9,12 +9,16 @@
import com.qonversion.android.sdk.QonversionConfig;
import com.qonversion.android.sdk.dto.QEnvironment;
import com.qonversion.android.sdk.dto.QLaunchMode;
+import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigV2Config;
import io.qonversion.nocodes.NoCodes;
import io.qonversion.nocodes.NoCodesConfig;
public class App extends MultiDexApplication {
- private static final String DEFAULT_PROJECT_KEY = "PV77YHL7qnGvsdmpTs7gimsxUvY-Znl2";
+ // The RC v2 playground project. The RemoteConfigV2Fragment demo only works against the project
+ // on the local gateway's RC v2 allowlist, so that project is the sample's default. The
+ // configuration dialog on the Home screen still overrides it at runtime without a rebuild.
+ private static final String DEFAULT_PROJECT_KEY = UtilsKt.RC_V2_PLAYGROUND_PROJECT_KEY;
@Override
public void onCreate() {
@@ -22,21 +26,48 @@ public void onCreate() {
String projectKey = getProjectKey(this, DEFAULT_PROJECT_KEY);
String apiUrl = getApiUrl(this);
+ // The sample's default project belongs to the local RC v2 playground. Keeping the
+ // legacy/identity API on production while snapshots use the local gateway creates a
+ // split-brain identity scope: identify() mutates a production user and the local RC
+ // session continues to resolve the anonymous uid. An explicitly configured URL still
+ // wins, and a non-playground project keeps the SDK's normal production default.
+ String effectiveApiUrl = apiUrl != null
+ ? apiUrl
+ : (DEFAULT_PROJECT_KEY.equals(projectKey)
+ ? UtilsKt.RC_V2_PLAYGROUND_BASE_URL + "/"
+ : null);
+ // Session mint resolves the same production client that RC v2 targets. Creating the demo
+ // user in Sandbox would make init succeed while every production session bootstrap is 404.
+ QEnvironment effectiveEnvironment = DEFAULT_PROJECT_KEY.equals(projectKey)
+ ? QEnvironment.Production
+ : QEnvironment.Sandbox;
QonversionConfig.Builder qonversionConfigBuilder = new QonversionConfig.Builder(
this,
projectKey,
QLaunchMode.SubscriptionManagement
- ).setEnvironment(QEnvironment.Sandbox);
+ ).setEnvironment(effectiveEnvironment);
+
+ // Remote Config v2 has no default base URL — the pipeline stays dormant until a config is
+ // supplied, and it is addressed independently of setProxyURL below (which only moves the
+ // legacy REST API). minFetchIntervalSeconds is 0 so the demo is never throttled.
+ qonversionConfigBuilder.setRemoteConfigV2Config(new QRemoteConfigV2Config(
+ UtilsKt.RC_V2_PLAYGROUND_BASE_URL,
+ UtilsKt.RC_V2_PLAYGROUND_ENVIRONMENT_UID,
+ 0,
+ DEFAULT_PROJECT_KEY.equals(projectKey)
+ ? new LocalRemoteConfigIdentifyAssertionProvider()
+ : null
+ ));
NoCodesConfig.Builder noCodesConfigBuilder = new NoCodesConfig.Builder(
this,
projectKey
).setCustomFallbackFileName("fallbacks/nocodes_fallbacks.json");
- if (apiUrl != null) {
- qonversionConfigBuilder.setProxyURL(apiUrl);
- noCodesConfigBuilder.setProxyURL(apiUrl);
+ if (effectiveApiUrl != null) {
+ qonversionConfigBuilder.setProxyURL(effectiveApiUrl);
+ noCodesConfigBuilder.setProxyURL(effectiveApiUrl);
}
Qonversion.initialize(qonversionConfigBuilder.build());
diff --git a/sample/src/main/java/io/qonversion/sample/LocalRemoteConfigIdentifyAssertionProvider.java b/sample/src/main/java/io/qonversion/sample/LocalRemoteConfigIdentifyAssertionProvider.java
new file mode 100644
index 000000000..20a435a50
--- /dev/null
+++ b/sample/src/main/java/io/qonversion/sample/LocalRemoteConfigIdentifyAssertionProvider.java
@@ -0,0 +1,45 @@
+package io.qonversion.sample;
+
+import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigIdentifyAssertionCallback;
+import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigIdentifyAssertionProvider;
+
+import java.io.BufferedReader;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+
+/** Local-playground bridge that models an app asking its authenticated backend for an assertion. */
+final class LocalRemoteConfigIdentifyAssertionProvider implements QRemoteConfigIdentifyAssertionProvider {
+ private static final String URL_STRING = "http://10.0.2.2:7089/remote-config-assertion";
+
+ @Override
+ public void requestAssertion(String externalUserId, QRemoteConfigIdentifyAssertionCallback callback) {
+ new Thread(() -> callback.onResult(fetch(externalUserId)), "rc-v2-local-assertion").start();
+ }
+
+ private String fetch(String externalUserId) {
+ HttpURLConnection connection = null;
+ try {
+ connection = (HttpURLConnection) new URL(URL_STRING).openConnection();
+ connection.setConnectTimeout(2_000);
+ connection.setReadTimeout(2_000);
+ connection.setRequestMethod("POST");
+ connection.setRequestProperty("Content-Type", "text/plain; charset=utf-8");
+ connection.setDoOutput(true);
+ try (OutputStream output = connection.getOutputStream()) {
+ output.write(externalUserId.getBytes(StandardCharsets.UTF_8));
+ }
+ if (connection.getResponseCode() != 200) return null;
+ try (BufferedReader reader = new BufferedReader(
+ new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) {
+ return reader.readLine();
+ }
+ } catch (Exception ignored) {
+ return null;
+ } finally {
+ if (connection != null) connection.disconnect();
+ }
+ }
+}
diff --git a/sample/src/main/java/io/qonversion/sample/OtherFragment.kt b/sample/src/main/java/io/qonversion/sample/OtherFragment.kt
index 870c70ff5..1cbbcf61f 100644
--- a/sample/src/main/java/io/qonversion/sample/OtherFragment.kt
+++ b/sample/src/main/java/io/qonversion/sample/OtherFragment.kt
@@ -48,6 +48,14 @@ class OtherFragment : Fragment() {
findNavController().navigate(R.id.remoteConfigsFragment)
}
+ binding.buttonRemoteConfigV2.setOnClickListener {
+ findNavController().navigate(R.id.remoteConfigV2Fragment)
+ }
+
+ binding.buttonPaywallDemo.setOnClickListener {
+ findNavController().navigate(R.id.paywallDemoFragment)
+ }
+
binding.buttonNoCodes.setOnClickListener {
findNavController().navigate(R.id.noCodesFragment)
}
diff --git a/sample/src/main/java/io/qonversion/sample/PaywallConfig.kt b/sample/src/main/java/io/qonversion/sample/PaywallConfig.kt
new file mode 100644
index 000000000..092a555a7
--- /dev/null
+++ b/sample/src/main/java/io/qonversion/sample/PaywallConfig.kt
@@ -0,0 +1,180 @@
+@file:OptIn(ExperimentalQonversionApi::class)
+
+package io.qonversion.sample
+
+import android.graphics.Color
+import com.qonversion.android.sdk.ExperimentalQonversionApi
+import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigDecoder
+import org.json.JSONArray
+import org.json.JSONException
+import org.json.JSONObject
+
+/** The Remote Config context key the Calmly paywall is driven by. */
+const val PAYWALL_CONTEXT_KEY = "paywall_config"
+
+/** One purchasable plan rendered as a card. */
+data class PaywallProduct(
+ val id: String,
+ val title: String,
+ val priceText: String,
+ /** Optional chip drawn on the card, e.g. "BEST VALUE". Absent or JSON `null` means no chip. */
+ val badge: String?,
+)
+
+/**
+ * Everything the paywall needs to render itself.
+ *
+ * The screen has no hardcoded copy, colors or plans of its own: it is a pure function of this
+ * model, so publishing a new release of [PAYWALL_CONTEXT_KEY] is enough to change the product.
+ */
+data class PaywallConfig(
+ val headline: String,
+ val subtitle: String,
+ /** `#RRGGBB` (or `#AARRGGBB`). Not validated at decode time — see [accentColorOrDefault]. */
+ val accentColor: String,
+ val ctaText: String,
+ val showCountdown: Boolean,
+ val countdownSeconds: Int,
+ val products: List,
+ val highlightProductId: String,
+)
+
+/**
+ * The defaults shipped inside the binary — what the developer released to the store.
+ *
+ * The screen renders fully from these before any network call, so a cold start with no
+ * connectivity still shows a complete paywall.
+ */
+val BUNDLED_PAYWALL_CONFIG = PaywallConfig(
+ headline = "Find your calm",
+ subtitle = "Guided meditations, sleep stories and breathing exercises. Five minutes a day is enough.",
+ accentColor = "#7C5CFF",
+ ctaText = "Start 7-day free trial",
+ showCountdown = false,
+ countdownSeconds = 0,
+ products = listOf(
+ PaywallProduct(
+ id = "calmly_monthly",
+ title = "Monthly",
+ priceText = "$9.99 / month",
+ badge = null,
+ ),
+ PaywallProduct(
+ id = "calmly_annual",
+ title = "Annual",
+ priceText = "$59.99 / year",
+ badge = "BEST VALUE",
+ ),
+ ),
+ highlightProductId = "calmly_annual",
+)
+
+/** Accent color used when the configured one is missing or not a parseable hex string. */
+const val PAYWALL_FALLBACK_ACCENT_COLOR = 0xFF7C5CFF.toInt()
+
+/**
+ * Parses [PaywallConfig.accentColor], falling back to [PAYWALL_FALLBACK_ACCENT_COLOR].
+ *
+ * A broken color is deliberately not a decode failure: rejecting the whole release over one
+ * cosmetic field would throw away valid copy and pricing. The screen keeps its default color and
+ * reports the bad value instead, which is exactly the input for decode-failure telemetry.
+ */
+fun PaywallConfig.accentColorOrDefault(): Int = try {
+ Color.parseColor(accentColor)
+} catch (_: IllegalArgumentException) {
+ PAYWALL_FALLBACK_ACCENT_COLOR
+}
+
+/** Whether [PaywallConfig.accentColor] would render as configured. */
+fun PaywallConfig.hasValidAccentColor(): Boolean = try {
+ Color.parseColor(accentColor)
+ true
+} catch (_: IllegalArgumentException) {
+ false
+}
+
+/**
+ * Decodes the JSON text stored for [PAYWALL_CONTEXT_KEY].
+ *
+ * Returning `null` rejects the candidate and lets the SDK fall to the next resolution-ladder
+ * position (cache, then the bundled defaults file), so this decoder is strict about the fields the
+ * screen cannot render without — copy, CTA text and at least one product — and lenient about the
+ * rest.
+ */
+val PaywallConfigDecoder = QRemoteConfigDecoder { rawJson ->
+ try {
+ val root = JSONObject(rawJson)
+
+ val products = root.optJSONArray("products").toProducts()
+ if (products.isEmpty()) return@QRemoteConfigDecoder null
+
+ PaywallConfig(
+ headline = root.requiredString("headline") ?: return@QRemoteConfigDecoder null,
+ subtitle = root.requiredString("subtitle") ?: return@QRemoteConfigDecoder null,
+ // Kept verbatim: an unparseable color degrades at render time, it does not reject.
+ accentColor = root.optNullableString("accentColor") ?: BUNDLED_PAYWALL_CONFIG.accentColor,
+ ctaText = root.requiredString("ctaText") ?: return@QRemoteConfigDecoder null,
+ showCountdown = root.optBoolean("showCountdown", false),
+ countdownSeconds = root.optInt("countdownSeconds", 0).coerceAtLeast(0),
+ products = products,
+ highlightProductId = root.optNullableString("highlightProductId").orEmpty(),
+ )
+ } catch (_: JSONException) {
+ // A decoder must never crash the read: any malformed payload is simply not a candidate.
+ null
+ }
+}
+
+private fun JSONArray?.toProducts(): List {
+ if (this == null) return emptyList()
+ return (0 until length()).mapNotNull { index ->
+ val item = optJSONObject(index) ?: return@mapNotNull null
+ PaywallProduct(
+ id = item.requiredString("id") ?: return@mapNotNull null,
+ title = item.requiredString("title") ?: return@mapNotNull null,
+ priceText = item.requiredString("priceText") ?: return@mapNotNull null,
+ badge = item.optNullableString("badge")?.takeIf { it.isNotBlank() },
+ )
+ }
+}
+
+/** A present, non-blank string, or `null` — which callers turn into a rejection. */
+private fun JSONObject.requiredString(name: String): String? =
+ optNullableString(name)?.takeIf { it.isNotBlank() }
+
+/** Distinguishes a missing member and the JSON literal `null` from the string `"null"`. */
+private fun JSONObject.optNullableString(name: String): String? =
+ if (isNull(name)) null else optString(name).takeIf { it.isNotEmpty() }
+
+/**
+ * Renders the wire shape of this config.
+ *
+ * The screen logs the bundled default through this so the exact JSON can be pasted into the
+ * dashboard as the initial value of [PAYWALL_CONTEXT_KEY] — the decoder above is the only contract
+ * between the two, and this keeps both sides written from the same source.
+ */
+fun PaywallConfig.toWireJson(): JSONObject = JSONObject().apply {
+ put("headline", headline)
+ put("subtitle", subtitle)
+ put("accentColor", accentColor)
+ put("ctaText", ctaText)
+ put("showCountdown", showCountdown)
+ put("countdownSeconds", countdownSeconds)
+ put(
+ "products",
+ JSONArray().apply {
+ products.forEach { product ->
+ put(
+ JSONObject().apply {
+ put("id", product.id)
+ put("title", product.title)
+ put("priceText", product.priceText)
+ // JSONObject.put(String, null) removes the member, so spell the null out.
+ put("badge", product.badge ?: JSONObject.NULL)
+ },
+ )
+ }
+ },
+ )
+ put("highlightProductId", highlightProductId)
+}
diff --git a/sample/src/main/java/io/qonversion/sample/PaywallDemoFragment.kt b/sample/src/main/java/io/qonversion/sample/PaywallDemoFragment.kt
new file mode 100644
index 000000000..68e1528e1
--- /dev/null
+++ b/sample/src/main/java/io/qonversion/sample/PaywallDemoFragment.kt
@@ -0,0 +1,491 @@
+@file:OptIn(ExperimentalQonversionApi::class)
+
+package io.qonversion.sample
+
+import android.content.Context
+import android.content.res.ColorStateList
+import android.graphics.drawable.GradientDrawable
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.util.Log
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.LinearLayout
+import android.widget.Toast
+import androidx.core.content.ContextCompat
+import androidx.fragment.app.Fragment
+import com.qonversion.android.sdk.ExperimentalQonversionApi
+import com.qonversion.android.sdk.Qonversion
+import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigActivationResult
+import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigApplyPolicy
+import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigFetchResult
+import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigFetchStatus
+import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigSnapshot
+import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigSource
+import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigSubscription
+import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigUpdate
+import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigValue
+import io.qonversion.sample.databinding.FragmentPaywallDemoBinding
+import io.qonversion.sample.databinding.ItemPaywallProductBinding
+import org.json.JSONException
+import org.json.JSONObject
+import java.util.Locale
+
+private const val TAG = "PaywallDemo"
+
+private const val COUNTDOWN_TICK_MS = 1000L
+private const val COUNTDOWN_BAR_MAX = 1000
+private const val SECONDS_PER_MINUTE = 60
+
+/**
+ * A paywall for the fictional meditation app "Calmly", rendered entirely from one Remote Config
+ * key — [PAYWALL_CONTEXT_KEY].
+ *
+ * Nothing on this screen is hardcoded UI copy: headline, subtitle, accent color, plans, badges,
+ * CTA text and the countdown all come from a [PaywallConfig]. That makes the screen a faithful
+ * stand-in for a real integration, where shipping a config release changes the product without
+ * shipping an app.
+ *
+ * The three states an integrator actually has to handle are all visible here:
+ *
+ * 1. **Bundled default** — [BUNDLED_PAYWALL_CONFIG] renders before any network call, so a cold
+ * start with no connectivity still shows a complete paywall.
+ * 2. **Resolved** — the typed read `snapshot.value(key, decoder)` reports where the value came
+ * from ([QRemoteConfigSource]) and under which [QRemoteConfigApplyPolicy], both shown verbatim
+ * in the source line at the bottom.
+ * 3. **Pending** — a fetched release the app has not applied yet. The SDK exposes no explicit
+ * "pending release" handle, so this screen derives it: a plain `fetch` completes with the *last
+ * fetched* release, and when its number is ahead of the activated one and it changes this key,
+ * the release is waiting for an activation.
+ */
+class PaywallDemoFragment : Fragment() {
+
+ private var _binding: FragmentPaywallDemoBinding? = null
+ private val binding get() = _binding!!
+
+ private val snapshots get() = Qonversion.shared.remoteConfigSnapshots()
+
+ private var subscription: QRemoteConfigSubscription? = null
+
+ /** The config currently on screen — kept so a re-render of the same values leaves it alone. */
+ private var renderedConfig: PaywallConfig? = null
+
+ private val countdownHandler = Handler(Looper.getMainLooper())
+ private var countdownTotalSeconds = 0
+ private var countdownRemainingSeconds = 0
+
+ private val countdownTick = object : Runnable {
+ override fun run() {
+ if (_binding == null) return
+ countdownRemainingSeconds = (countdownRemainingSeconds - 1).coerceAtLeast(0)
+ renderCountdownValue()
+ if (countdownRemainingSeconds > 0) countdownHandler.postDelayed(this, COUNTDOWN_TICK_MS)
+ }
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentPaywallDemoBinding.inflate(inflater, container, false)
+
+ logBundledDefaultOnce()
+ setupButtons()
+
+ // First paint is the bundled default and touches no SDK state: `current` is guarded by the
+ // SDK's read-before-activate assert (debug builds crash on it — proven live on the
+ // emulator), so nothing may be read until an activate() has run. The paywall is still
+ // complete on screen before the first byte is sent.
+ render(BUNDLED_PAYWALL_CONFIG, resolved = null, releaseNumber = 0L)
+
+ // Live updates: an activation performed elsewhere — including the SDK's own immediate-policy
+ // swap — re-renders this screen in place.
+ subscription = snapshots.subscribeOnConfigUpdate { update -> onConfigUpdated(update) }
+
+ // activate() is the integrator's handshake with the read guard: it applies whatever is
+ // already fetched (or nothing) and only THEN is `current` legal to read. All real
+ // rendering flows through its callback and the subscription above.
+ snapshots.activate { _ ->
+ val activated = snapshots.current
+ if (_binding != null) {
+ renderFromSnapshot(activated)
+ syncOnOpen(activated.releaseNumber)
+ }
+ }
+
+ return binding.root
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ countdownHandler.removeCallbacks(countdownTick)
+ // The subscription outlives the view, so it must be released with it.
+ subscription?.remove()
+ subscription = null
+ _binding = null
+ }
+
+ private fun setupButtons() {
+ binding.buttonRefresh.setOnClickListener { refresh() }
+ binding.buttonActivateNow.setOnClickListener { activatePending() }
+ binding.buttonCta.setOnClickListener {
+ Toast.makeText(context, getString(R.string.paywall_cta_toast), Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ // region SDK calls
+
+ /** The refresh affordance: fetch a release and apply it in one step. */
+ private fun refresh() {
+ binding.progressBar.visibility = View.VISIBLE
+ snapshots.fetchAndActivate { result -> onActivation(result) }
+ }
+
+ private fun activatePending() {
+ binding.progressBar.visibility = View.VISIBLE
+ snapshots.activate { result -> onActivation(result) }
+ }
+
+ /**
+ * What the screen does the moment it opens, and the one place the fetch/activate split is a
+ * product decision rather than a mechanism.
+ *
+ * With nothing activated there is no user-visible state to protect, so a release is fetched and
+ * applied straight away. With a release already on screen the fetch deliberately stops short of
+ * activating: swapping copy and pricing under someone who is reading them is exactly what the
+ * on-next-activate policy exists to prevent, so a newer release is announced instead.
+ *
+ * A plain fetch completes with the *last fetched* release rather than the activated one, which
+ * is what makes a pending release observable at all.
+ */
+ private fun syncOnOpen(activatedReleaseNumber: Long) {
+ binding.progressBar.visibility = View.VISIBLE
+ if (activatedReleaseNumber == 0L) {
+ snapshots.fetchAndActivate { result -> onActivation(result) }
+ } else {
+ snapshots.fetch { result -> onProbeResult(result) }
+ }
+ }
+
+ /**
+ * Callbacks arrive on the main thread exactly once, but carry no guarantee that the view is
+ * still alive, so every handler goes through the nullable binding.
+ */
+ private fun onActivation(result: QRemoteConfigActivationResult) {
+ _binding?.let { b ->
+ b.progressBar.visibility = View.GONE
+ renderFromSnapshot(result.snapshot)
+ hidePending()
+ reportFetchStatus(result.fetchStatus)
+ }
+ }
+
+ private fun onProbeResult(result: QRemoteConfigFetchResult) {
+ val b = _binding ?: return
+ b.progressBar.visibility = View.GONE
+
+ val fetched = result.snapshot
+ // Re-read the activated release here rather than trusting what was rendered: an
+ // immediate-policy release may have been swapped in — and re-rendered through the
+ // subscription — while this fetch was in flight.
+ val activated = snapshots.current
+
+ val pendingRaw = fetched.rawValue(PAYWALL_CONTEXT_KEY)?.value
+ val activatedRaw = activated.rawValue(PAYWALL_CONTEXT_KEY)?.value
+
+ // A newer release that does not touch this key is not "pending" as far as this screen is
+ // concerned, so it is not announced.
+ if (fetched.releaseNumber > activated.releaseNumber && pendingRaw != activatedRaw) {
+ showPending(fetched)
+ } else {
+ hidePending()
+ }
+ }
+
+ /**
+ * Fires whenever a release becomes current — an explicit activate, or an immediate-policy
+ * release the SDK admitted on its own. The latter is what makes the screen change while the
+ * user is looking at it.
+ */
+ private fun onConfigUpdated(update: QRemoteConfigUpdate) {
+ if (_binding == null) return
+ if (!update.changedKeys.contains(PAYWALL_CONTEXT_KEY)) return
+
+ Log.i(TAG, "Config update: release ${update.snapshot.releaseNumber}, $PAYWALL_CONTEXT_KEY changed")
+ renderFromSnapshot(update.snapshot)
+ hidePending()
+ }
+
+ private fun reportFetchStatus(status: QRemoteConfigFetchStatus?) {
+ if (status == null || status == QRemoteConfigFetchStatus.Fetched ||
+ status == QRemoteConfigFetchStatus.NotModified
+ ) {
+ return
+ }
+ Toast.makeText(
+ context,
+ getString(R.string.paywall_fetch_status_format, status.name),
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+
+ // endregion
+
+ // region rendering
+
+ /**
+ * The single typed read this whole screen is built on.
+ *
+ * A decoder that returns null rejects a candidate and the SDK falls to the next
+ * resolution-ladder position; when every position is rejected — or the key is simply unknown —
+ * the read answers null and the bundled default takes over.
+ */
+ private fun renderFromSnapshot(snapshot: QRemoteConfigSnapshot) {
+ val resolved = snapshot.value(PAYWALL_CONTEXT_KEY, PaywallConfigDecoder)
+
+ if (resolved == null && snapshot.contextKeys.contains(PAYWALL_CONTEXT_KEY)) {
+ // The key exists in the release but nothing on the ladder survived the decoder. Worth
+ // saying out loud: this is the shape a decode-failure looks like from the app side.
+ Log.w(TAG, "`$PAYWALL_CONTEXT_KEY` is present in release ${snapshot.releaseNumber} but did not decode")
+ }
+
+ render(resolved?.value ?: BUNDLED_PAYWALL_CONFIG, resolved, snapshot.releaseNumber)
+ }
+
+ private fun render(
+ config: PaywallConfig,
+ resolved: QRemoteConfigValue?,
+ releaseNumber: Long,
+ ) {
+ val binding = _binding ?: return
+
+ if (!config.hasValidAccentColor()) {
+ Log.w(TAG, "accentColor \"${config.accentColor}\" is not a parseable hex color, keeping the default")
+ }
+ val accent = config.accentColorOrDefault()
+
+ binding.headline.text = config.headline
+ binding.subtitle.text = config.subtitle
+
+ binding.buttonCta.text = config.ctaText
+ binding.buttonCta.backgroundTintList = ColorStateList.valueOf(accent)
+
+ renderProducts(config, accent)
+ renderCountdown(config, accent)
+ renderSource(config, resolved, releaseNumber)
+
+ renderedConfig = config
+ }
+
+ private fun renderProducts(config: PaywallConfig, accent: Int) {
+ val container = binding.productsContainer
+ val inflater = LayoutInflater.from(container.context)
+ val spacing = container.context.dp(PRODUCT_SPACING_DP)
+
+ container.removeAllViews()
+ config.products.forEachIndexed { index, product ->
+ val item = ItemPaywallProductBinding.inflate(inflater, container, false)
+
+ item.productTitle.text = product.title
+ item.productPrice.text = product.priceText
+
+ val highlighted = product.id == config.highlightProductId
+ item.root.background = cardBackground(container.context, accent, highlighted)
+
+ if (product.badge == null) {
+ item.productBadge.visibility = View.GONE
+ } else {
+ item.productBadge.visibility = View.VISIBLE
+ item.productBadge.text = product.badge
+ item.productBadge.background = badgeBackground(container.context, accent)
+ }
+
+ (item.root.layoutParams as? LinearLayout.LayoutParams)?.topMargin =
+ if (index == 0) 0 else spacing
+
+ container.addView(item.root)
+ }
+ }
+
+ /** The highlight ring: a thicker stroke in the accent color on the promoted plan. */
+ private fun cardBackground(context: Context, accent: Int, highlighted: Boolean): GradientDrawable {
+ // Drawables loaded from resources share a constant state, so each card must mutate its own.
+ val background = ContextCompat.getDrawable(context, R.drawable.paywall_product_card)
+ ?.mutate() as GradientDrawable
+ background.setStroke(
+ context.dp(if (highlighted) HIGHLIGHT_STROKE_DP else PLAIN_STROKE_DP),
+ if (highlighted) accent else ContextCompat.getColor(context, R.color.colorDivider),
+ )
+ return background
+ }
+
+ private fun badgeBackground(context: Context, accent: Int): GradientDrawable {
+ val background = ContextCompat.getDrawable(context, R.drawable.paywall_badge_chip)
+ ?.mutate() as GradientDrawable
+ background.setColor(accent)
+ return background
+ }
+
+ private fun renderCountdown(config: PaywallConfig, accent: Int) {
+ _binding?.let { binding ->
+ val enabled = config.showCountdown && config.countdownSeconds > 0
+ if (!enabled) {
+ countdownHandler.removeCallbacks(countdownTick)
+ countdownTotalSeconds = 0
+ countdownRemainingSeconds = 0
+ binding.countdownContainer.visibility = View.GONE
+ } else {
+ binding.countdownContainer.visibility = View.VISIBLE
+ binding.countdownText.setTextColor(accent)
+ binding.countdownBar.progressTintList = ColorStateList.valueOf(accent)
+
+ // Restart only when the countdown itself was re-configured; an unrelated re-render
+ // must not silently give the user their time back.
+ val previous = renderedConfig
+ val unchanged = previous != null &&
+ previous.showCountdown == config.showCountdown &&
+ previous.countdownSeconds == config.countdownSeconds
+ if (unchanged && countdownTotalSeconds == config.countdownSeconds) {
+ renderCountdownValue()
+ } else {
+ countdownHandler.removeCallbacks(countdownTick)
+ countdownTotalSeconds = config.countdownSeconds
+ countdownRemainingSeconds = config.countdownSeconds
+ renderCountdownValue()
+ countdownHandler.postDelayed(countdownTick, COUNTDOWN_TICK_MS)
+ }
+ }
+ }
+ }
+
+ private fun renderCountdownValue() {
+ val binding = _binding ?: return
+
+ binding.countdownText.text = if (countdownRemainingSeconds > 0) {
+ getString(R.string.paywall_countdown_format, formatDuration(countdownRemainingSeconds))
+ } else {
+ getString(R.string.paywall_countdown_expired)
+ }
+
+ binding.countdownBar.max = COUNTDOWN_BAR_MAX
+ binding.countdownBar.progress = if (countdownTotalSeconds <= 0) {
+ 0
+ } else {
+ countdownRemainingSeconds * COUNTDOWN_BAR_MAX / countdownTotalSeconds
+ }
+ }
+
+ /**
+ * The unobtrusive provenance line: which ladder position answered, under which release, apply
+ * policy, and the experiment/group the release attributes the value to.
+ */
+ private fun renderSource(
+ config: PaywallConfig,
+ resolved: QRemoteConfigValue?,
+ releaseNumber: Long,
+ ) {
+ val binding = _binding ?: return
+
+ val text = StringBuilder()
+ if (resolved == null) {
+ text.append(getString(R.string.paywall_source_local))
+ } else {
+ text.append(
+ getString(
+ R.string.paywall_source_format,
+ getString(resolved.source.label()),
+ releaseNumber.toString(),
+ )
+ )
+ text.append(getString(R.string.paywall_source_segment_format, getString(resolved.applyPolicy.label())))
+ resolved.metadataJson?.experimentSegment()?.let { segment ->
+ text.append(getString(R.string.paywall_source_segment_format, segment))
+ }
+ }
+ if (!config.hasValidAccentColor()) {
+ text.append(getString(R.string.paywall_source_bad_color_format, config.accentColor))
+ }
+
+ binding.sourceText.text = text
+ }
+
+ private fun showPending(pending: QRemoteConfigSnapshot) {
+ val binding = _binding ?: return
+
+ val policy = pending.value(PAYWALL_CONTEXT_KEY, PaywallConfigDecoder)?.applyPolicy
+ ?: QRemoteConfigApplyPolicy.OnNextActivate
+
+ binding.pendingContainer.visibility = View.VISIBLE
+ binding.pendingText.text = getString(
+ R.string.paywall_pending_format,
+ pending.releaseNumber.toString(),
+ getString(policy.label()),
+ )
+ }
+
+ private fun hidePending() {
+ _binding?.pendingContainer?.visibility = View.GONE
+ }
+
+ // endregion
+
+ /**
+ * Prints the wire shape of the bundled default once per process, so it can be pasted into the
+ * dashboard as the initial value of [PAYWALL_CONTEXT_KEY].
+ */
+ private fun logBundledDefaultOnce() {
+ if (bundledDefaultLogged) return
+ bundledDefaultLogged = true
+ // Android's JSONStringer escapes forward slashes, which is valid JSON but noisy to paste;
+ // unescaping them keeps the logged text identical to what belongs in the dashboard field.
+ val json = BUNDLED_PAYWALL_CONFIG.toWireJson().toString(JSON_INDENT).replace("\\/", "/")
+ Log.i(TAG, "Bundled default for `$PAYWALL_CONTEXT_KEY` — paste as the key's initial value:\n$json")
+ }
+
+ private fun QRemoteConfigSource.label(): Int = when (this) {
+ QRemoteConfigSource.Server -> R.string.paywall_source_server
+ QRemoteConfigSource.Cache -> R.string.paywall_source_cache
+ QRemoteConfigSource.Fallback -> R.string.paywall_source_fallback
+ }
+
+ private fun QRemoteConfigApplyPolicy.label(): Int = when (this) {
+ QRemoteConfigApplyPolicy.Immediate -> R.string.paywall_policy_immediate
+ QRemoteConfigApplyPolicy.OnNextActivate -> R.string.paywall_policy_on_next_activate
+ }
+
+ /** Metadata is app-defined JSON; surface the experiment/group pair when the release carries it. */
+ private fun String.experimentSegment(): String? = try {
+ val metadata = JSONObject(this)
+ val experiment = metadata.optString("experiment").takeIf { it.isNotEmpty() }
+ val group = metadata.optString("group").takeIf { it.isNotEmpty() }
+ when {
+ experiment != null && group != null -> getString(R.string.paywall_experiment_format, experiment, group)
+ experiment != null -> experiment
+ else -> null
+ }
+ } catch (_: JSONException) {
+ null
+ }
+
+ private fun formatDuration(totalSeconds: Int): String = String.format(
+ Locale.US,
+ "%02d:%02d",
+ totalSeconds / SECONDS_PER_MINUTE,
+ totalSeconds % SECONDS_PER_MINUTE,
+ )
+
+ private fun Context.dp(value: Int): Int = (value * resources.displayMetrics.density).toInt()
+
+ companion object {
+ private const val PRODUCT_SPACING_DP = 10
+ private const val PLAIN_STROKE_DP = 1
+ private const val HIGHLIGHT_STROKE_DP = 2
+ private const val JSON_INDENT = 2
+
+ /** Per-process, so re-opening the screen does not spam the log the human is reading. */
+ private var bundledDefaultLogged = false
+ }
+}
diff --git a/sample/src/main/java/io/qonversion/sample/RemoteConfigV2Adapter.kt b/sample/src/main/java/io/qonversion/sample/RemoteConfigV2Adapter.kt
new file mode 100644
index 000000000..07b8787f1
--- /dev/null
+++ b/sample/src/main/java/io/qonversion/sample/RemoteConfigV2Adapter.kt
@@ -0,0 +1,89 @@
+@file:OptIn(ExperimentalQonversionApi::class)
+
+package io.qonversion.sample
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.content.ContextCompat
+import androidx.recyclerview.widget.RecyclerView
+import com.qonversion.android.sdk.ExperimentalQonversionApi
+import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigSource
+import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigValue
+import io.qonversion.sample.databinding.ItemRemoteConfigV2Binding
+import org.json.JSONArray
+import org.json.JSONException
+import org.json.JSONObject
+
+/** One resolved Remote Config v2 key, paired with the context key it was read under. */
+class ResolvedEntry(
+ val contextKey: String,
+ val value: QRemoteConfigValue,
+)
+
+class RemoteConfigV2Adapter(
+ private val entries: List
+) : RecyclerView.Adapter() {
+
+ class ValueViewHolder(val binding: ItemRemoteConfigV2Binding) : RecyclerView.ViewHolder(binding.root)
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ValueViewHolder {
+ val binding = ItemRemoteConfigV2Binding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false
+ )
+ return ValueViewHolder(binding)
+ }
+
+ override fun onBindViewHolder(holder: ValueViewHolder, position: Int) {
+ val entry = entries[position]
+ val context = holder.itemView.context
+
+ with(holder.binding) {
+ contextKey.text = entry.contextKey
+
+ // The source is the whole point of v2: every key reports independently whether it came
+ // from the server, the on-device cache, or the bundled fallback file.
+ source.text = entry.value.source.name
+ source.setTextColor(ContextCompat.getColor(context, entry.value.source.color()))
+
+ applyPolicy.text = entry.value.applyPolicy.name
+ variationUid.text = entry.value.variationUid.ifEmpty {
+ context.getString(R.string.rc_v2_no_variation)
+ }
+
+ rawValue.text = prettyPrint(entry.value.value)
+
+ val metadata = entry.value.metadataJson
+ if (metadata == null) {
+ metadataLabel.visibility = View.GONE
+ metadataJson.visibility = View.GONE
+ } else {
+ metadataLabel.visibility = View.VISIBLE
+ metadataJson.visibility = View.VISIBLE
+ metadataJson.text = prettyPrint(metadata)
+ }
+ }
+ }
+
+ override fun getItemCount() = entries.size
+
+ /** Values arrive as raw JSON text; indent them when they parse, show them verbatim otherwise. */
+ private fun prettyPrint(raw: String): String = try {
+ val trimmed = raw.trim()
+ when {
+ trimmed.startsWith("{") -> JSONObject(trimmed).toString(2)
+ trimmed.startsWith("[") -> JSONArray(trimmed).toString(2)
+ else -> raw
+ }
+ } catch (_: JSONException) {
+ raw
+ }
+
+ private fun QRemoteConfigSource.color(): Int = when (this) {
+ QRemoteConfigSource.Server -> R.color.colorGreen
+ QRemoteConfigSource.Cache -> R.color.colorOrange
+ QRemoteConfigSource.Fallback -> R.color.colorRed
+ }
+}
diff --git a/sample/src/main/java/io/qonversion/sample/RemoteConfigV2Fragment.kt b/sample/src/main/java/io/qonversion/sample/RemoteConfigV2Fragment.kt
new file mode 100644
index 000000000..14fca504a
--- /dev/null
+++ b/sample/src/main/java/io/qonversion/sample/RemoteConfigV2Fragment.kt
@@ -0,0 +1,259 @@
+@file:OptIn(ExperimentalQonversionApi::class)
+
+package io.qonversion.sample
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Toast
+import androidx.core.content.ContextCompat
+import androidx.fragment.app.Fragment
+import androidx.recyclerview.widget.LinearLayoutManager
+import com.qonversion.android.sdk.ExperimentalQonversionApi
+import com.qonversion.android.sdk.Qonversion
+import com.qonversion.android.sdk.dto.QonversionError
+import com.qonversion.android.sdk.dto.QUser
+import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigActivationResult
+import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigFetchResult
+import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigSnapshot
+import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigSubscription
+import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigUpdate
+import com.qonversion.android.sdk.listeners.QonversionUserCallback
+import io.qonversion.sample.databinding.FragmentRemoteConfigV2Binding
+
+private const val TAG = "RemoteConfigV2Fragment"
+
+/**
+ * Remote Config v2 playground.
+ *
+ * The v2 pipeline is fetch/activate, not fetch/serve: a fetch only makes a release available and
+ * [com.qonversion.android.sdk.QRemoteConfigSnapshots.activate] swaps the whole release into
+ * `current` atomically. This screen exercises the full customer journey against a local
+ * environment — fetch a release, list every resolved key with its own source/apply policy/
+ * metadata, watch live updates through a subscription, and switch identity.
+ *
+ * The pipeline is dormant unless `App` passed a `QRemoteConfigV2Config` to
+ * `QonversionConfig.Builder.setRemoteConfigV2Config`. While dormant every fetch completes with
+ * `NotConfigured` and `current` stays empty, which this screen reports as-is rather than hiding.
+ */
+class RemoteConfigV2Fragment : Fragment() {
+
+ private var _binding: FragmentRemoteConfigV2Binding? = null
+ private val binding get() = _binding!!
+
+ private val snapshots get() = Qonversion.shared.remoteConfigSnapshots()
+
+ private var subscription: QRemoteConfigSubscription? = null
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentRemoteConfigV2Binding.inflate(inflater, container, false)
+
+ binding.recyclerViewValues.layoutManager = LinearLayoutManager(context)
+ setupButtons()
+ renderEnvironment()
+ renderSubscriptionState()
+ activate()
+
+ return binding.root
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ // The subscription outlives the view, so it must be released with it.
+ unsubscribeFromUpdates()
+ _binding = null
+ }
+
+ private fun setupButtons() {
+ binding.buttonFetchAndActivate.setOnClickListener { fetchAndActivate() }
+ binding.buttonFetch.setOnClickListener { fetch() }
+ binding.buttonActivate.setOnClickListener { activate() }
+
+ binding.buttonToggleSubscription.setOnClickListener {
+ if (subscription == null) subscribeToUpdates() else unsubscribeFromUpdates()
+ }
+
+ binding.buttonIdentify.setOnClickListener { identify(DEMO_USER_ID) }
+ binding.buttonLogout.setOnClickListener { logout() }
+ }
+
+ private fun renderEnvironment() {
+ binding.environmentInfo.text = getString(
+ R.string.rc_v2_environment_format,
+ RC_V2_PLAYGROUND_BASE_URL,
+ RC_V2_PLAYGROUND_ENVIRONMENT_UID
+ )
+ }
+
+ // region SDK calls
+
+ private fun fetchAndActivate() {
+ binding.progressBar.visibility = View.VISIBLE
+ snapshots.fetchAndActivate { result -> onActivation(result) }
+ }
+
+ private fun fetch() {
+ binding.progressBar.visibility = View.VISIBLE
+ snapshots.fetch { result -> onFetch(result) }
+ }
+
+ private fun activate() {
+ binding.progressBar.visibility = View.VISIBLE
+ snapshots.activate { result -> onActivation(result) }
+ }
+
+ /**
+ * Callbacks are guaranteed to arrive on the main thread exactly once, but not that the view is
+ * still alive, so every handler goes through the nullable binding.
+ */
+ private fun onFetch(result: QRemoteConfigFetchResult) {
+ _binding?.let { b ->
+ b.progressBar.visibility = View.GONE
+ b.statusText.text = getString(
+ R.string.rc_v2_fetch_status_format,
+ result.status.name,
+ result.snapshot.releaseNumber.toString()
+ )
+ // Fetching an on-next-activate release must not make the candidate look current.
+ // An immediate-policy release is already current by callback time, even when this
+ // screen is not subscribed to update events, so it is safe and necessary to render it.
+ val activatedImmediately = result.snapshot.contextKeys.any { contextKey ->
+ result.snapshot.rawValue(contextKey)?.applyPolicy ==
+ com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigApplyPolicy.Immediate
+ }
+ if (activatedImmediately) renderSnapshot(result.snapshot)
+ }
+ }
+
+ private fun onActivation(result: QRemoteConfigActivationResult) {
+ _binding?.let { b ->
+ b.progressBar.visibility = View.GONE
+ val fetchStatus = result.fetchStatus?.name ?: getString(R.string.rc_v2_no_fetch)
+ b.statusText.text = getString(
+ R.string.rc_v2_activation_status_format,
+ fetchStatus,
+ result.changed.toString()
+ )
+ renderSnapshot(result.snapshot)
+ }
+ }
+
+ private fun subscribeToUpdates() {
+ subscription = snapshots.subscribeOnConfigUpdate { update -> onConfigUpdated(update) }
+ renderSubscriptionState()
+ Toast.makeText(context, getString(R.string.rc_v2_subscribed), Toast.LENGTH_SHORT).show()
+ }
+
+ private fun unsubscribeFromUpdates() {
+ subscription?.remove()
+ subscription = null
+ renderSubscriptionState()
+ }
+
+ /**
+ * Fires whenever a release becomes current — either an explicit activate or an immediate-policy
+ * release admitted by the SDK on its own.
+ */
+ private fun onConfigUpdated(update: QRemoteConfigUpdate) {
+ _binding?.let { b ->
+ b.updateText.text = getString(
+ R.string.rc_v2_update_format,
+ update.snapshot.releaseNumber.toString(),
+ update.changedKeys.sorted().joinToString(", ").ifEmpty {
+ getString(R.string.rc_v2_no_changed_keys)
+ }
+ )
+ b.updateText.visibility = View.VISIBLE
+ renderSnapshot(update.snapshot)
+ }
+ }
+
+ private fun identify(userId: String) {
+ binding.progressBar.visibility = View.VISIBLE
+ Qonversion.shared.identify(userId, object : QonversionUserCallback {
+ override fun onSuccess(user: QUser) {
+ _binding?.let { b ->
+ b.progressBar.visibility = View.GONE
+ b.identityText.text = getString(R.string.rc_v2_identity_format, user.identityId ?: user.qonversionId)
+ }
+ // Identity changes the resolution scope, so the previous release no longer applies.
+ Toast.makeText(context, getString(R.string.rc_v2_identified, userId), Toast.LENGTH_SHORT).show()
+ }
+
+ override fun onError(error: QonversionError) {
+ _binding?.progressBar?.visibility = View.GONE
+ showError(requireContext(), error, TAG)
+ }
+ })
+ }
+
+ private fun logout() {
+ Qonversion.shared.logout()
+ binding.identityText.text = getString(R.string.rc_v2_identity_anonymous)
+ Toast.makeText(context, getString(R.string.rc_v2_logged_out), Toast.LENGTH_SHORT).show()
+ }
+
+ // endregion
+
+ // region rendering
+
+ private fun renderSnapshot(snapshot: QRemoteConfigSnapshot) {
+ val binding = _binding ?: return
+
+ // contextKeys also contains server tombstones used to suppress removed values. Count and
+ // display only keys which remain readable from the server/fallback resolution ladder.
+ val values = snapshot.contextKeys.sorted().mapNotNull { contextKey ->
+ snapshot.rawValue(contextKey)?.let { value -> ResolvedEntry(contextKey, value) }
+ }
+
+ // A fallback-only snapshot carries no release: releaseNumber is 0 and releaseUid is empty.
+ binding.releaseInfo.text = if (snapshot.releaseNumber == 0L) {
+ getString(R.string.rc_v2_no_release)
+ } else {
+ getString(
+ R.string.rc_v2_release_format,
+ snapshot.releaseNumber.toString(),
+ snapshot.releaseUid,
+ values.size.toString()
+ )
+ }
+
+ if (values.isEmpty()) {
+ binding.emptyStateText.visibility = View.VISIBLE
+ binding.recyclerViewValues.visibility = View.GONE
+ } else {
+ binding.emptyStateText.visibility = View.GONE
+ binding.recyclerViewValues.visibility = View.VISIBLE
+ binding.recyclerViewValues.adapter = RemoteConfigV2Adapter(values)
+ }
+ }
+
+ private fun renderSubscriptionState() {
+ val binding = _binding ?: return
+ val subscribed = subscription != null
+
+ binding.subscriptionIndicator.setBackgroundColor(
+ ContextCompat.getColor(
+ requireContext(),
+ if (subscribed) R.color.colorGreen else R.color.colorGray
+ )
+ )
+ binding.subscriptionText.setText(
+ if (subscribed) R.string.rc_v2_subscription_active else R.string.rc_v2_subscription_inactive
+ )
+ binding.buttonToggleSubscription.setText(
+ if (subscribed) R.string.rc_v2_unsubscribe else R.string.rc_v2_subscribe
+ )
+ }
+
+ // endregion
+
+ companion object {
+ private const val DEMO_USER_ID = "test-user-1"
+ }
+}
diff --git a/sample/src/main/java/io/qonversion/sample/utils.kt b/sample/src/main/java/io/qonversion/sample/utils.kt
index f8e6dd9c9..7525f7367 100644
--- a/sample/src/main/java/io/qonversion/sample/utils.kt
+++ b/sample/src/main/java/io/qonversion/sample/utils.kt
@@ -6,6 +6,25 @@ import android.util.Log
import android.widget.Toast
import com.qonversion.android.sdk.dto.QonversionError
+/**
+ * Remote Config v2 local playground.
+ *
+ * These point the sample at the docker dev environment so the RC v2 screen can complete a real
+ * customer journey: publish a config in the local dashboard, fetch it here.
+ *
+ * - `10.0.2.2` is how the Android emulator reaches the host machine's loopback; `7101` is the local
+ * api-gateway, which owns the SDK-facing `v3/remote-config-v2` routes. Plain http is fine
+ * because the manifest permits cleartext and `network_security_config.xml` already trusts this
+ * host. On a physical device replace it with the host's LAN address.
+ * - The project key must belong to the one project on the gateway's RC v2 allowlist; any other
+ * project answers 404 by design.
+ * - The environment uid is checked against every snapshot envelope the SDK receives, so it must
+ * match the environment the release was published into exactly.
+ */
+const val RC_V2_PLAYGROUND_PROJECT_KEY = "ZKyxaGP3A0AGiUgZzyhbuolC-U0FQrlx"
+const val RC_V2_PLAYGROUND_BASE_URL = "http://10.0.2.2:7101"
+const val RC_V2_PLAYGROUND_ENVIRONMENT_UID = "d3v00000-0000-4000-8000-000000000001"
+
private const val QONVERSION_PREFS = "qonversion_config"
private const val KEY_PROJECT_KEY = "project_key"
private const val KEY_API_URL = "api_url"
diff --git a/sample/src/main/res/drawable/ic_refresh.xml b/sample/src/main/res/drawable/ic_refresh.xml
new file mode 100644
index 000000000..89b8c81ca
--- /dev/null
+++ b/sample/src/main/res/drawable/ic_refresh.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/sample/src/main/res/drawable/paywall_badge_chip.xml b/sample/src/main/res/drawable/paywall_badge_chip.xml
new file mode 100644
index 000000000..f64d2bb58
--- /dev/null
+++ b/sample/src/main/res/drawable/paywall_badge_chip.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
diff --git a/sample/src/main/res/drawable/paywall_pending_banner.xml b/sample/src/main/res/drawable/paywall_pending_banner.xml
new file mode 100644
index 000000000..5eaee0efc
--- /dev/null
+++ b/sample/src/main/res/drawable/paywall_pending_banner.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/sample/src/main/res/drawable/paywall_product_card.xml b/sample/src/main/res/drawable/paywall_product_card.xml
new file mode 100644
index 000000000..6a6a28325
--- /dev/null
+++ b/sample/src/main/res/drawable/paywall_product_card.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
diff --git a/sample/src/main/res/layout/fragment_other.xml b/sample/src/main/res/layout/fragment_other.xml
index 63a1f83e6..63eb374f1 100644
--- a/sample/src/main/res/layout/fragment_other.xml
+++ b/sample/src/main/res/layout/fragment_other.xml
@@ -61,6 +61,34 @@
app:iconTint="@color/colorWhite"
app:iconGravity="textStart" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/sample/src/main/res/layout/fragment_remote_config_v2.xml b/sample/src/main/res/layout/fragment_remote_config_v2.xml
new file mode 100644
index 000000000..f7ee2e7a0
--- /dev/null
+++ b/sample/src/main/res/layout/fragment_remote_config_v2.xml
@@ -0,0 +1,304 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/sample/src/main/res/layout/item_paywall_product.xml b/sample/src/main/res/layout/item_paywall_product.xml
new file mode 100644
index 000000000..0eeed53a3
--- /dev/null
+++ b/sample/src/main/res/layout/item_paywall_product.xml
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/sample/src/main/res/layout/item_remote_config_v2.xml b/sample/src/main/res/layout/item_remote_config_v2.xml
new file mode 100644
index 000000000..c5975212b
--- /dev/null
+++ b/sample/src/main/res/layout/item_remote_config_v2.xml
@@ -0,0 +1,142 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/sample/src/main/res/navigation/nav_graph.xml b/sample/src/main/res/navigation/nav_graph.xml
index 39301aace..449e46a7e 100644
--- a/sample/src/main/res/navigation/nav_graph.xml
+++ b/sample/src/main/res/navigation/nav_graph.xml
@@ -43,6 +43,18 @@
android:label="@string/remote_configs"
tools:layout="@layout/fragment_remote_configs" />
+
+
+
+
Enter custom URL
Note: Changing these settings will restart the app.
+
+ Remote Config v2
+ Current release
+ Fetch & activate
+ Live updates
+ Identity
+ Identify re-scopes resolution and supersedes any fetch in flight.
+ Fetch and activate
+ Fetch
+ Activate
+ Subscribe to updates
+ Unsubscribe
+ Identify test-user-1
+ Log out
+ Subscribed — updates arrive live
+ Not subscribed
+ Subscribed to config updates
+ Logged out
+ Identified as %1$s
+ Identity: anonymous
+ Identity: %1$s
+ No release activated yet — tap Fetch and activate
+ Release %1$s · %2$s · %3$s keys
+ Fetch status: %1$s · fetched release %2$s
+ Fetch: %1$s · changed: %2$s
+ Update: release %1$s · changed: %2$s
+ nothing
+ not fetched
+ %1$s\nenv %2$s
+ No resolved values. The pipeline is dormant unless a QRemoteConfigV2Config was set at init.
+ Source
+ Apply policy
+ Variation
+ Value
+ Metadata
+ —
+
+
+ Paywall demo (Remote Config)
+ CALMLY
+ Refresh config
+ Activate now
+ Demo only — no purchase was started.
+ Offer ends in %1$s
+ Offer expired
+ Fetch: %1$s
+ New config pending (release #%1$s, %2$s) — applies on next activation
+ config: %1$s · release #%2$s
+ config: local default
+ · %1$s
+ · bad accentColor \"%1$s\"
+ server
+ cache
+ fallback
+ immediate
+ on next activation
+ exp %1$s / group %2$s
+
Loading…
diff --git a/sdk/build.gradle b/sdk/build.gradle
index 1a0594a67..c095bddbb 100644
--- a/sdk/build.gradle
+++ b/sdk/build.gradle
@@ -84,7 +84,11 @@ ext {
network = [
core : "com.squareup.retrofit2:retrofit:$retrofit_version",
moshiConverter : "com.squareup.retrofit2:converter-moshi:$retrofit_version",
- okhttp : "com.squareup.okhttp3:okhttp:$okhttp_version"
+ okhttp : "com.squareup.okhttp3:okhttp:$okhttp_version",
+ // Retrofit 2.9.0 pulls okhttp 3.14.9, which wins conflict resolution over
+ // okhttp_version above; MockWebServer touches okhttp3.internal.* so it must
+ // match the version that actually resolves, not the one declared.
+ mockWebServer : "com.squareup.okhttp3:mockwebserver:3.14.9"
]
lifecycle = [
@@ -164,6 +168,9 @@ dependencies {
// Mockito
testImplementation 'org.mockito:mockito-core:4.3.1'
+ // MockWebServer (HTTP contract tests)
+ testImplementation network.mockWebServer
+
testImplementation 'androidx.test:core:1.5.0'
testImplementation 'androidx.test.ext:junit:1.1.5'
testImplementation "org.json:json:20180813"
diff --git a/sdk/src/main/java/com/qonversion/android/sdk/ExperimentalQonversionApi.kt b/sdk/src/main/java/com/qonversion/android/sdk/ExperimentalQonversionApi.kt
new file mode 100644
index 000000000..1f52a492b
--- /dev/null
+++ b/sdk/src/main/java/com/qonversion/android/sdk/ExperimentalQonversionApi.kt
@@ -0,0 +1,26 @@
+package com.qonversion.android.sdk
+
+/**
+ * Marks a Qonversion API that is still taking shape.
+ *
+ * A declaration annotated with this marker is shipped so integrators can try it, but it is
+ * explicitly **not** a stability promise: its signature, semantics and even its existence may
+ * change in any release without a deprecation cycle.
+ *
+ * Kotlin callers opt in with `@OptIn(ExperimentalQonversionApi::class)`; Java callers can use the
+ * API directly, since the opt-in requirement is a Kotlin compiler concept only.
+ */
+@RequiresOptIn(
+ level = RequiresOptIn.Level.ERROR,
+ message = "This Qonversion API is experimental. Its behavior and signature may change " +
+ "without notice. Opt in with @OptIn(ExperimentalQonversionApi::class).",
+)
+@Retention(AnnotationRetention.BINARY)
+@Target(
+ AnnotationTarget.CLASS,
+ AnnotationTarget.FUNCTION,
+ AnnotationTarget.PROPERTY,
+ AnnotationTarget.CONSTRUCTOR,
+ AnnotationTarget.TYPEALIAS,
+)
+annotation class ExperimentalQonversionApi
diff --git a/sdk/src/main/java/com/qonversion/android/sdk/QRemoteConfigSnapshots.kt b/sdk/src/main/java/com/qonversion/android/sdk/QRemoteConfigSnapshots.kt
new file mode 100644
index 000000000..44f2034af
--- /dev/null
+++ b/sdk/src/main/java/com/qonversion/android/sdk/QRemoteConfigSnapshots.kt
@@ -0,0 +1,93 @@
+package com.qonversion.android.sdk
+
+import com.qonversion.android.sdk.dto.QRemoteConfigFallbackValue
+import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigSnapshot
+import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigSubscription
+import com.qonversion.android.sdk.listeners.QRemoteConfigUpdateListener
+import com.qonversion.android.sdk.listeners.QonversionRemoteConfigActivationCallback
+import com.qonversion.android.sdk.listeners.QonversionRemoteConfigFetchCallback
+
+/**
+ * The Remote Config v2 snapshot API.
+ *
+ * The model is fetch/activate, not fetch/serve: a fetch only makes a release *available*, and
+ * [activate] swaps the whole release atomically into [current]. Values therefore never change
+ * under a running screen unless the app asks for it — or unless the release itself declares the
+ * immediate apply policy, in which case the SDK performs the same whole-release swap on admission
+ * and notifies [subscribeOnConfigUpdate] listeners.
+ *
+ * Every callback of this API is delivered on the main thread, exactly once. Reads ([current],
+ * [fallbackRemoteConfigValue]) are synchronous and safe from any thread.
+ *
+ * The API is dormant unless the app passes a `QRemoteConfigV2Config` to
+ * `QonversionConfig.Builder.setRemoteConfigV2Config`. While dormant there is no release at all:
+ * fetches complete with `NotConfigured`, [current] is empty (it does **not** fall back to the
+ * bundled defaults, because there is no scope to resolve them for), subscriptions never fire, and
+ * [fallbackRemoteConfigValue] keeps answering because it reads the app asset directly.
+ */
+@ExperimentalQonversionApi
+interface QRemoteConfigSnapshots {
+
+ /**
+ * The release that is currently activated.
+ *
+ * Reading before the first [activate] is a supported but flagged path: in a debug build the
+ * SDK reports it loudly (read-before-activate), and in a release build it silently performs a
+ * single implicit activation so the app is never served an empty config by accident.
+ */
+ val current: QRemoteConfigSnapshot
+
+ /**
+ * Fetches a release using the SDK's default timeout.
+ *
+ * @param callback delivered with the best available data — freshly fetched, previously
+ * activated, or bundled — and the fetch status.
+ */
+ fun fetch(callback: QonversionRemoteConfigFetchCallback)
+
+ /**
+ * Fetches a release, giving up on *waiting* after [timeoutMs].
+ *
+ * On timeout the callback fires with `TimedOut` and the best available snapshot, while the
+ * request itself keeps running: if it succeeds later, the release is admitted as usual and
+ * becomes available to the next [activate].
+ *
+ * @param timeoutMs how long to wait for the completion, in milliseconds. A non-positive value
+ * waives the caller's own deadline; the SDK still applies an internal ceiling (30 seconds), so
+ * a completion always arrives.
+ */
+ fun fetch(timeoutMs: Long, callback: QonversionRemoteConfigFetchCallback)
+
+ /**
+ * Atomically swaps the last fetched release into [current].
+ *
+ * @param callback delivered with `changed = true` when the activated release differs from the
+ * previously activated one.
+ */
+ fun activate(callback: QonversionRemoteConfigActivationCallback)
+
+ /** Runs [fetch] and then [activate], delivering the activation result. */
+ fun fetchAndActivate(callback: QonversionRemoteConfigActivationCallback)
+
+ /** [fetchAndActivate] with an explicit fetch timeout — see [fetch]. */
+ fun fetchAndActivate(timeoutMs: Long, callback: QonversionRemoteConfigActivationCallback)
+
+ /**
+ * Reads a value directly from the Remote Config defaults bundled with the app.
+ *
+ * Synchronous and independent of networking, identity, caches and activation, so it answers
+ * before the first fetch or activate. Returns `null` when the key is absent from the bundle or
+ * the bundle failed strict validation.
+ */
+ fun fallbackRemoteConfigValue(contextKey: String): QRemoteConfigFallbackValue?
+
+ /**
+ * Subscribes to config updates: the changed-key diff plus the release that became current.
+ *
+ * While the pipeline is dormant the subscription is inert: nothing is ever fetched or
+ * activated, so no update can be delivered.
+ *
+ * @return a handle to stop receiving updates.
+ */
+ fun subscribeOnConfigUpdate(listener: QRemoteConfigUpdateListener): QRemoteConfigSubscription
+}
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..3ca2c068a 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.
@@ -76,6 +97,23 @@ interface Qonversion {
}
}
+ /**
+ * The experimental Remote Config v2 snapshot API: fetch, activate, and read an immutable
+ * release whose every value reports its own source (server, cache or bundled fallback).
+ *
+ * Unrelated to [remoteConfig] / [remoteConfigList], which serve the v1 pipeline.
+ *
+ * Always returns a usable object. If the app did not pass a
+ * [com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigV2Config] to
+ * [QonversionConfig.Builder.setRemoteConfigV2Config], the pipeline is dormant: fetches
+ * complete with `NotConfigured`, `current` is empty, and only
+ * [QRemoteConfigSnapshots.fallbackRemoteConfigValue] answers.
+ *
+ * @see QRemoteConfigSnapshots
+ */
+ @ExperimentalQonversionApi
+ fun remoteConfigSnapshots(): QRemoteConfigSnapshots
+
/**
* Call this function to sync the subscriber data with the first launch
* when Qonversion is implemented.
diff --git a/sdk/src/main/java/com/qonversion/android/sdk/QonversionConfig.kt b/sdk/src/main/java/com/qonversion/android/sdk/QonversionConfig.kt
index fab0a6459..44a184a4b 100644
--- a/sdk/src/main/java/com/qonversion/android/sdk/QonversionConfig.kt
+++ b/sdk/src/main/java/com/qonversion/android/sdk/QonversionConfig.kt
@@ -1,3 +1,5 @@
+@file:OptIn(ExperimentalQonversionApi::class)
+
package com.qonversion.android.sdk
import android.app.Application
@@ -7,6 +9,7 @@ import com.qonversion.android.sdk.dto.QLaunchMode
import android.content.Context
import androidx.annotation.RawRes
import com.qonversion.android.sdk.dto.entitlements.QEntitlementsCacheLifetime
+import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigV2Config
import com.qonversion.android.sdk.internal.EntitlementsUpdateListenerAdapter
import com.qonversion.android.sdk.internal.dto.config.CacheConfig
import com.qonversion.android.sdk.internal.dto.config.PrimaryConfig
@@ -28,7 +31,8 @@ class QonversionConfig internal constructor(
internal val application: Application,
internal val primaryConfig: PrimaryConfig,
internal val cacheConfig: CacheConfig,
- internal val deferredPurchasesListener: QDeferredPurchasesListener? = null
+ internal val deferredPurchasesListener: QDeferredPurchasesListener? = null,
+ internal val remoteConfigV2Config: QRemoteConfigV2Config? = null
) {
/**
@@ -53,6 +57,7 @@ class QonversionConfig internal constructor(
internal var proxyUrl: String? = null
internal var isKidsMode: Boolean = false
internal var sendFbAttribution: Boolean = true
+ internal var remoteConfigV2Config: QRemoteConfigV2Config? = null
@RawRes
internal var fallbackFileIdentifier: Int? = null
@@ -83,9 +88,9 @@ class QonversionConfig internal constructor(
* Fallback file will be used in rare cases of network connection or Qonversion API issues for new users without a cache available.
* This allows purchases and entitlements to be processed for new users even if the Qonversion API faces issues.
* This also makes it possible to receive remote configs for cases when the network connection is unavailable.
- * There is no need to use this function if you put qonversion_fallbacks.json into the `assets` folder.
- * Use this function only if you put qonversion_fallbacks.json into the `res/raw` folder.
- * In that case, `id` should look like `R.raw.qonversion_fallbacks`.
+ * There is no need to use this function if you put qonversion_android_fallbacks.json into the `assets` folder.
+ * Use this function only if you put a fallback JSON file into the `res/raw` folder.
+ * In that case, `id` should look like `R.raw.qonversion_android_fallbacks`.
*
* @param id the identifier for the fallback file.
*
@@ -145,6 +150,22 @@ class QonversionConfig internal constructor(
}
}
+ /**
+ * Enables the experimental Remote Config v2 snapshot pipeline.
+ *
+ * Without this call the pipeline stays dormant: the SDK creates no v2 storage, starts no
+ * background workers and contacts no v2 endpoint. There is no default base URL — the whole
+ * feature is opt-in per app.
+ *
+ * @param config addressing of the Remote Config v2 gateway.
+ * @return builder instance for chain calls.
+ * @see Qonversion.remoteConfigs
+ */
+ @ExperimentalQonversionApi
+ fun setRemoteConfigV2Config(config: QRemoteConfigV2Config): Builder = apply {
+ this.remoteConfigV2Config = config
+ }
+
/**
* Use this function to enable Qonversion SDK Kids mode.
* With this mode activated, our SDK does not collect any information that violates Google Children's Privacy Policy.
@@ -186,7 +207,8 @@ class QonversionConfig internal constructor(
context.application,
primaryConfig,
cacheConfig,
- deferredPurchasesListener
+ deferredPurchasesListener,
+ remoteConfigV2Config
)
}
}
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/dto/QRemoteConfigurationAssignmentType.kt b/sdk/src/main/java/com/qonversion/android/sdk/dto/QRemoteConfigurationAssignmentType.kt
index 78c871100..128e11ce6 100644
--- a/sdk/src/main/java/com/qonversion/android/sdk/dto/QRemoteConfigurationAssignmentType.kt
+++ b/sdk/src/main/java/com/qonversion/android/sdk/dto/QRemoteConfigurationAssignmentType.kt
@@ -3,13 +3,15 @@ package com.qonversion.android.sdk.dto
enum class QRemoteConfigurationAssignmentType(val type: String) {
Auto("auto"),
Manual("manual"),
- Unknown("unknown");
+ Unknown("unknown"),
+ Frozen("frozen");
companion object {
fun fromType(type: String): QRemoteConfigurationAssignmentType {
return when (type) {
"auto" -> Auto
"manual" -> Manual
+ "frozen" -> Frozen
else -> Unknown
}
}
diff --git a/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigActivationResult.kt b/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigActivationResult.kt
new file mode 100644
index 000000000..41841a20d
--- /dev/null
+++ b/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigActivationResult.kt
@@ -0,0 +1,19 @@
+package com.qonversion.android.sdk.dto.remoteconfig
+
+import com.qonversion.android.sdk.ExperimentalQonversionApi
+
+/**
+ * The completion value of an activation.
+ *
+ * @param changed `true` when this activation made at least one key differ from the previously
+ * activated release — i.e. "something changed since the last activation".
+ * @param snapshot the snapshot that is current after the activation.
+ * @param fetchStatus outcome of the fetch that preceded the activation, or `null` when the
+ * activation was requested on its own.
+ */
+@ExperimentalQonversionApi
+class QRemoteConfigActivationResult internal constructor(
+ val changed: Boolean,
+ val snapshot: QRemoteConfigSnapshot,
+ val fetchStatus: QRemoteConfigFetchStatus? = null,
+)
diff --git a/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigApplyPolicy.kt b/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigApplyPolicy.kt
new file mode 100644
index 000000000..433bfb250
--- /dev/null
+++ b/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigApplyPolicy.kt
@@ -0,0 +1,21 @@
+package com.qonversion.android.sdk.dto.remoteconfig
+
+import com.qonversion.android.sdk.ExperimentalQonversionApi
+
+/**
+ * How a fetched release asks to be applied.
+ *
+ * Activation is always a full atomic swap of the whole release — the policy decides *when* that
+ * swap happens, never *which part* of the release is swapped.
+ */
+@ExperimentalQonversionApi
+enum class QRemoteConfigApplyPolicy {
+ /** The release becomes current only when the app calls `activate()`. */
+ OnNextActivate,
+
+ /**
+ * The release is activated as soon as it is admitted. A single immediate key activates the
+ * whole release, since a release is never applied partially.
+ */
+ Immediate,
+}
diff --git a/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigDecoder.kt b/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigDecoder.kt
new file mode 100644
index 000000000..69f5b8e85
--- /dev/null
+++ b/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigDecoder.kt
@@ -0,0 +1,22 @@
+package com.qonversion.android.sdk.dto.remoteconfig
+
+import com.qonversion.android.sdk.ExperimentalQonversionApi
+
+/**
+ * Decodes one raw Remote Config JSON value into an app type.
+ *
+ * The decoder is the per-key validator seam of the snapshot: returning `null` (or throwing) means
+ * "this raw value is not usable for this key", which makes the read fall to the next position of
+ * the resolution ladder — the previously activated value, then the bundled default.
+ *
+ * Implementations must be deterministic and side-effect free: the same raw JSON is decoded again
+ * on later reads, and a decoder that answers differently over time makes reads unstable.
+ */
+@ExperimentalQonversionApi
+fun interface QRemoteConfigDecoder {
+ /**
+ * @param rawJson the exact JSON text stored for the key.
+ * @return the decoded value, or `null` to reject this raw value.
+ */
+ fun decode(rawJson: String): T?
+}
diff --git a/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigFetchResult.kt b/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigFetchResult.kt
new file mode 100644
index 000000000..d77ad9072
--- /dev/null
+++ b/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigFetchResult.kt
@@ -0,0 +1,20 @@
+package com.qonversion.android.sdk.dto.remoteconfig
+
+import com.qonversion.android.sdk.ExperimentalQonversionApi
+
+/**
+ * The completion value of a fetch.
+ *
+ * [snapshot] is the best available data at completion time: the last fetched release when one is
+ * held (which, on a successful fetch, is the release this call just brought in), otherwise the
+ * currently activated release, otherwise the bundled defaults. Each key read from it still reports
+ * its own [QRemoteConfigSource], including on a [QRemoteConfigFetchStatus.TimedOut] completion.
+ *
+ * It is therefore a *fetch* view, not the activated one: it can show a release that
+ * `QRemoteConfigSnapshots.current` will only serve after the next `activate()`.
+ */
+@ExperimentalQonversionApi
+class QRemoteConfigFetchResult internal constructor(
+ val status: QRemoteConfigFetchStatus,
+ val snapshot: QRemoteConfigSnapshot,
+)
diff --git a/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigFetchStatus.kt b/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigFetchStatus.kt
new file mode 100644
index 000000000..17537664a
--- /dev/null
+++ b/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigFetchStatus.kt
@@ -0,0 +1,37 @@
+package com.qonversion.android.sdk.dto.remoteconfig
+
+import com.qonversion.android.sdk.ExperimentalQonversionApi
+
+/**
+ * Outcome of a Remote Config fetch attempt.
+ *
+ * None of these statuses changes what `QRemoteConfigSnapshots.current` returns: a fetched release becomes
+ * current only through `activate()` — or immediately, when the release itself asks for it via
+ * [QRemoteConfigApplyPolicy.Immediate].
+ */
+@ExperimentalQonversionApi
+enum class QRemoteConfigFetchStatus {
+ /** A new release was fetched and admitted. */
+ Fetched,
+
+ /** The server confirmed the held release is still current. */
+ NotModified,
+
+ /**
+ * The caller's timeout elapsed first. The request keeps running in the background, and its
+ * result is admitted when it arrives — it is simply no longer awaited.
+ */
+ TimedOut,
+
+ /** The minimum fetch interval or a failure backoff blocked the attempt. */
+ Throttled,
+
+ /** The attempt failed. */
+ Failed,
+
+ /** An identity change replaced the scope this fetch belonged to. */
+ Superseded,
+
+ /** Remote Config v2 is not configured for this app, so no fetch was attempted. */
+ NotConfigured,
+}
diff --git a/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigSnapshot.kt b/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigSnapshot.kt
new file mode 100644
index 000000000..3dd25afcd
--- /dev/null
+++ b/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigSnapshot.kt
@@ -0,0 +1,96 @@
+@file:OptIn(ExperimentalQonversionApi::class)
+
+package com.qonversion.android.sdk.dto.remoteconfig
+
+import com.qonversion.android.sdk.ExperimentalQonversionApi
+import com.qonversion.android.sdk.internal.remoteconfig.RemoteConfigResolvedValue
+import com.qonversion.android.sdk.internal.remoteconfig.RemoteConfigSnapshot
+import com.qonversion.android.sdk.internal.remoteconfig.RemoteConfigSnapshotApplyPolicy
+import com.qonversion.android.sdk.internal.remoteconfig.RemoteConfigSnapshotValueSource
+import com.qonversion.android.sdk.internal.services.decodePortableRemoteConfigJson
+
+/**
+ * An immutable view of one Remote Config release.
+ *
+ * A snapshot never changes: holding it lets an app read several keys that are guaranteed to belong
+ * to the same release, even if another release is activated meanwhile. Take a fresh snapshot from
+ * `QRemoteConfigSnapshots.current` to observe a newer activation.
+ *
+ * Every read answers with a [QRemoteConfigValue] carrying the value **and** its
+ * [QRemoteConfigSource], or `null` when no ladder position could produce a value: the key is
+ * unknown to both the release and the bundled defaults, it was explicitly deleted from the release
+ * and has no bundled default, or — for a typed read — every candidate was rejected by the decoder.
+ */
+@ExperimentalQonversionApi
+class QRemoteConfigSnapshot internal constructor(
+ private val snapshot: RemoteConfigSnapshot,
+) {
+ /** Identifier of the release this snapshot holds, or an empty string for a fallback-only one. */
+ val releaseUid: String get() = snapshot.releaseUid
+
+ /** Monotonic number of the release this snapshot holds, or `0` for a fallback-only one. */
+ val releaseNumber: Long get() = snapshot.releaseNumber
+
+ /** Every context key readable from this snapshot, including keys served by bundled defaults. */
+ val contextKeys: Set get() = snapshot.readableKeys
+
+ /**
+ * Reads [contextKey] as the exact JSON text stored for it.
+ *
+ * Raw reads never reject a value, so their source is [QRemoteConfigSource.Server] or
+ * [QRemoteConfigSource.Fallback] — [QRemoteConfigSource.Cache] is reachable only through a
+ * typed read whose decoder rejected the current release's value.
+ */
+ fun rawValue(contextKey: String): QRemoteConfigValue? =
+ snapshot.rawValue(contextKey)?.toPublicValue { bytes -> bytes.toString(Charsets.UTF_8) }
+
+ /**
+ * Reads [contextKey] as an opaque JSON tree: a [Map], [List], [String], [Double], [Boolean],
+ * or `null` for a JSON `null`.
+ *
+ * The wrapper stays non-null for a present JSON `null`, so an explicit null value remains
+ * distinguishable from a missing key.
+ */
+ fun jsonValue(contextKey: String): QRemoteConfigValue? =
+ snapshot.value(contextKey) { bytes -> decodePortableRemoteConfigJson(bytes) }
+ ?.toPublicValue { decoded -> decoded.value }
+
+ /**
+ * Reads [contextKey] through [decoder].
+ *
+ * A decoder that returns `null` (or throws) rejects the value and the read falls to the next
+ * resolution-ladder position, which is what makes [QRemoteConfigSource.Cache] observable.
+ */
+ fun value(contextKey: String, decoder: QRemoteConfigDecoder): QRemoteConfigValue? =
+ snapshot.value(contextKey) { bytes -> decoder.decode(bytes.toString(Charsets.UTF_8)) }
+ ?.toPublicValue { decoded -> decoded }
+
+ private fun RemoteConfigResolvedValue.toPublicValue(
+ transform: (T) -> R,
+ ): QRemoteConfigValue = QRemoteConfigValue(
+ value = transform(value),
+ source = source.toPublicSource(),
+ variationUid = variationUid,
+ applyPolicy = applyPolicy.toPublicApplyPolicy(),
+ metadataJson = metadataBytes.toMetadataJson(),
+ )
+}
+
+/**
+ * A release always carries a `metadata` member, and "no metadata" is spelled as the JSON literal
+ * `null` on the wire. Collapsing it to a Kotlin `null` keeps `metadataJson != null` meaning
+ * "there is metadata" for both server-served and bundled values.
+ */
+internal fun ByteArray?.toMetadataJson(): String? =
+ this?.toString(Charsets.UTF_8)?.takeUnless { it == "null" }
+
+internal fun RemoteConfigSnapshotValueSource.toPublicSource(): QRemoteConfigSource = when (this) {
+ RemoteConfigSnapshotValueSource.Server -> QRemoteConfigSource.Server
+ RemoteConfigSnapshotValueSource.Cache -> QRemoteConfigSource.Cache
+ RemoteConfigSnapshotValueSource.Fallback -> QRemoteConfigSource.Fallback
+}
+
+internal fun RemoteConfigSnapshotApplyPolicy.toPublicApplyPolicy(): QRemoteConfigApplyPolicy = when (this) {
+ RemoteConfigSnapshotApplyPolicy.OnNextActivate -> QRemoteConfigApplyPolicy.OnNextActivate
+ RemoteConfigSnapshotApplyPolicy.Immediate -> QRemoteConfigApplyPolicy.Immediate
+}
diff --git a/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigSource.kt b/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigSource.kt
new file mode 100644
index 000000000..aeb2ec21c
--- /dev/null
+++ b/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigSource.kt
@@ -0,0 +1,25 @@
+package com.qonversion.android.sdk.dto.remoteconfig
+
+import com.qonversion.android.sdk.ExperimentalQonversionApi
+
+/**
+ * Where a resolved Remote Config value came from.
+ *
+ * The resolution ladder is always tried in this order: [Server], then [Cache], then [Fallback].
+ * Every read result carries its position on that ladder, so a caller can tell a freshly targeted
+ * value from a value that survived a failed decode or from the defaults bundled with the app.
+ */
+@ExperimentalQonversionApi
+enum class QRemoteConfigSource {
+ /** The value carried by the release this snapshot holds. */
+ Server,
+
+ /**
+ * The previously activated release's value, reused because this snapshot's own value did not
+ * survive the caller-supplied decode. Only reachable for typed reads.
+ */
+ Cache,
+
+ /** The value from the Remote Config defaults bundled with the app. */
+ Fallback,
+}
diff --git a/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigSubscription.kt b/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigSubscription.kt
new file mode 100644
index 000000000..7681777ea
--- /dev/null
+++ b/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigSubscription.kt
@@ -0,0 +1,13 @@
+package com.qonversion.android.sdk.dto.remoteconfig
+
+import com.qonversion.android.sdk.ExperimentalQonversionApi
+
+/**
+ * Handle of a config-update subscription.
+ *
+ * Call [remove] to stop receiving updates. Removing twice is safe.
+ */
+@ExperimentalQonversionApi
+fun interface QRemoteConfigSubscription {
+ fun remove()
+}
diff --git a/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigUpdate.kt b/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigUpdate.kt
new file mode 100644
index 000000000..d0d237b6b
--- /dev/null
+++ b/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigUpdate.kt
@@ -0,0 +1,35 @@
+package com.qonversion.android.sdk.dto.remoteconfig
+
+import com.qonversion.android.sdk.ExperimentalQonversionApi
+import com.qonversion.android.sdk.internal.remoteconfig.RemoteConfigSnapshotUpdate
+
+/**
+ * Describes one activation delivered to a config-update listener.
+ *
+ * The update is always a whole-release swap: [changedKeys] is the diff against the previously
+ * activated release, and [snapshot] is the complete release that is now current.
+ */
+@ExperimentalQonversionApi
+class QRemoteConfigUpdate internal constructor(
+ private val update: RemoteConfigSnapshotUpdate,
+) {
+ /** The release that became current with this activation. */
+ val snapshot: QRemoteConfigSnapshot = QRemoteConfigSnapshot(update.snapshot)
+
+ /** Keys whose effective value differs from the previously activated release. */
+ val changedKeys: Set get() = update.changedKeys
+
+ /** Raw JSON metadata attached to a changed key, or `null` when the key carries none. */
+ fun metadataJson(contextKey: String): String? =
+ update.metadataForKey(contextKey).toMetadataJson()
+
+ /**
+ * Apply policy declared for [contextKey] by the release that just became current, or `null`
+ * when the key is not readable from it.
+ *
+ * A key with [QRemoteConfigApplyPolicy.Immediate] means this update was delivered without the
+ * app calling `activate()` — the whole release was swapped atomically on admission.
+ */
+ fun applyPolicy(contextKey: String): QRemoteConfigApplyPolicy? =
+ snapshot.rawValue(contextKey)?.applyPolicy
+}
diff --git a/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigV2Config.kt b/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigV2Config.kt
new file mode 100644
index 000000000..478e925fb
--- /dev/null
+++ b/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigV2Config.kt
@@ -0,0 +1,86 @@
+package com.qonversion.android.sdk.dto.remoteconfig
+
+import com.qonversion.android.sdk.ExperimentalQonversionApi
+
+private const val REMOTE_CONFIG_V2_UID_MAX_CODE_POINTS = 36
+
+/** Receives one short-lived host-signed assertion requested by Remote Config v2. */
+@ExperimentalQonversionApi
+fun interface QRemoteConfigIdentifyAssertionCallback {
+ /** Pass `null` when no assertion can be obtained; the SDK then fails closed. */
+ fun onResult(assertion: String?)
+}
+
+/**
+ * Obtains a short-lived assertion from the app's authenticated backend for an identified user.
+ *
+ * The SDK calls this only when it must mint a Remote Config session for an existing identified
+ * account. Implementations may complete asynchronously and must never put the host signing key in
+ * the app. The assertion is opaque to the SDK and is sent only to the configured gateway's
+ * `/v3/remote-config-v2/session/identify` endpoint.
+ */
+@ExperimentalQonversionApi
+fun interface QRemoteConfigIdentifyAssertionProvider {
+ fun requestAssertion(externalUserId: String, callback: QRemoteConfigIdentifyAssertionCallback)
+}
+
+/**
+ * Enables the experimental Remote Config v2 snapshot pipeline.
+ *
+ * The pipeline is **dormant** unless this configuration is passed to
+ * `QonversionConfig.Builder.setRemoteConfigV2Config`: without it the SDK builds no v2 store, opens
+ * no v2 connection, and `QRemoteConfigSnapshots` answers every fetch with
+ * [QRemoteConfigFetchStatus.NotConfigured] while still serving bundled defaults. There is no
+ * default base URL and no production endpoint is contacted implicitly.
+ *
+ * The targeting context a snapshot was resolved for is deliberately **not** configured here, and no
+ * future version will ask for it. The fingerprint hashes mutable targeting context (app/OS version,
+ * locale, purchases, properties); it rotates legitimately and MUST NOT be pinned across fetches.
+ * Identity isolation is the session's job: every snapshot read travels on a session token minted
+ * for exactly one identity, the gateway routes on that session, and the SDK stores each identity's
+ * releases under its own scoped storage key.
+ *
+ * The numeric project id is deliberately **not** configured here either, although a served snapshot
+ * is checked against one. Unlike the fingerprint it is stable, but the app is not its source: the
+ * SDK learns it from the gateway's session bootstrap, pins the first value it is ever told, and
+ * treats a later bootstrap that answers with a different one as a hard failure.
+ *
+ * That is a trade, not a strict improvement: the check no longer proves a snapshot belongs to the
+ * project the developer meant to target — the first bootstrap is trusted — it proves that every
+ * snapshot and every later session agree with the first one. What it buys is that a value the app
+ * could only ever get wrong is gone, and the property that actually protects a user — a snapshot
+ * being served for the session that asked for it — is enforced against the server's own answer.
+ *
+ * @param baseUrl base URL of the Remote Config v2 gateway, e.g. `https://host/`. The SDK appends
+ * its own paths, so a bare origin is expected.
+ * @param environmentUid uid of the Remote Config environment to read.
+ * @param minFetchIntervalSeconds minimum interval between real network fetches, in seconds.
+ * `0` (the default) means "auto": the production default interval in a release build and no
+ * throttling at all in a debuggable one, so a developer iterating on an environment sees every
+ * change. An explicit positive value wins over auto in both build modes. Forced fetches bypass
+ * the interval either way, and failure backoff applies independently of it.
+ * @param identifyAssertionProvider obtains a host-signed assertion when `identify()` switches to
+ * an existing account. Without it anonymous Remote Config continues to work, while an identified
+ * session that needs proof of identity fails closed instead of sending a bare external user id.
+ * @throws IllegalArgumentException if any value is malformed.
+ */
+@ExperimentalQonversionApi
+class QRemoteConfigV2Config @JvmOverloads constructor(
+ val baseUrl: String,
+ val environmentUid: String,
+ val minFetchIntervalSeconds: Long = 0,
+ val identifyAssertionProvider: QRemoteConfigIdentifyAssertionProvider? = null,
+) {
+ init {
+ require(baseUrl.startsWith("http://") || baseUrl.startsWith("https://")) {
+ "Remote Config v2 base url must be an absolute http(s) url"
+ }
+ require(
+ environmentUid.isNotEmpty() &&
+ environmentUid.codePointCount(0, environmentUid.length) <= REMOTE_CONFIG_V2_UID_MAX_CODE_POINTS,
+ ) { "Remote Config v2 environment uid must be 1..$REMOTE_CONFIG_V2_UID_MAX_CODE_POINTS code points" }
+ require(minFetchIntervalSeconds >= 0) {
+ "Remote Config v2 minimum fetch interval must not be negative"
+ }
+ }
+}
diff --git a/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigValue.kt b/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigValue.kt
new file mode 100644
index 000000000..b193b323f
--- /dev/null
+++ b/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigValue.kt
@@ -0,0 +1,22 @@
+package com.qonversion.android.sdk.dto.remoteconfig
+
+import com.qonversion.android.sdk.ExperimentalQonversionApi
+
+/**
+ * One resolved Remote Config read: the value plus where it came from.
+ *
+ * @param value the decoded value.
+ * @param source the resolution-ladder position [value] was taken from.
+ * @param variationUid identifier of the variation the value belongs to.
+ * @param applyPolicy the apply policy declared for this key by the release it came from.
+ * @param metadataJson raw JSON metadata attached to the key, or `null` when the release
+ * declares none (the JSON literal `null` on the wire is reported as a Kotlin `null`).
+ */
+@ExperimentalQonversionApi
+class QRemoteConfigValue internal constructor(
+ val value: T,
+ val source: QRemoteConfigSource,
+ val variationUid: String,
+ val applyPolicy: QRemoteConfigApplyPolicy,
+ val metadataJson: String?,
+)
diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/InternalConfig.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/InternalConfig.kt
index bab094bd5..fe597852b 100644
--- a/sdk/src/main/java/com/qonversion/android/sdk/internal/InternalConfig.kt
+++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/InternalConfig.kt
@@ -1,6 +1,7 @@
package com.qonversion.android.sdk.internal
import com.qonversion.android.sdk.QonversionConfig
+import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigV2Config
import com.qonversion.android.sdk.internal.dto.config.PrimaryConfig
import com.qonversion.android.sdk.dto.QEnvironment
import com.qonversion.android.sdk.dto.QLaunchMode
@@ -11,10 +12,12 @@ import com.qonversion.android.sdk.internal.provider.PrimaryConfigProvider
import com.qonversion.android.sdk.internal.provider.UidProvider
import com.qonversion.android.sdk.listeners.QDeferredPurchasesListener
+@OptIn(com.qonversion.android.sdk.ExperimentalQonversionApi::class)
internal class InternalConfig(
override var primaryConfig: PrimaryConfig,
override val cacheConfig: CacheConfig,
- var deferredPurchasesListener: QDeferredPurchasesListener? = null
+ var deferredPurchasesListener: QDeferredPurchasesListener? = null,
+ val remoteConfigV2Config: QRemoteConfigV2Config? = null
) : EnvironmentProvider,
PrimaryConfigProvider,
CacheConfigProvider,
@@ -33,7 +36,8 @@ internal class InternalConfig(
constructor(qonversionConfig: QonversionConfig) : this(
qonversionConfig.primaryConfig,
qonversionConfig.cacheConfig,
- qonversionConfig.deferredPurchasesListener
+ qonversionConfig.deferredPurchasesListener,
+ qonversionConfig.remoteConfigV2Config
)
override val apiUrl: String
diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/QProductCenterManager.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/QProductCenterManager.kt
index db3187160..ea0ab04f8 100644
--- a/sdk/src/main/java/com/qonversion/android/sdk/internal/QProductCenterManager.kt
+++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/QProductCenterManager.kt
@@ -237,12 +237,13 @@ internal class QProductCenterManager internal constructor(
// Invalidate BEFORE handlePendingRequests: the replay must
// miss the cache, or queued completions would be served the
// pre-identify evaluation.
- remoteConfigManager.invalidateRemoteConfigsCache()
+ remoteConfigManager.invalidateRemoteConfigsCache(identityId)
handlePendingRequests()
fireIdentitySuccess(identityId)
} else {
- internalConfig.uid = qonversionUid
- remoteConfigManager.onUserUpdate()
+ remoteConfigManager.onUserUpdate(identityId) {
+ internalConfig.uid = qonversionUid
+ }
launchResultCache.clearPermissionsCache()
launch(RequestTrigger.Identify, object : QonversionLaunchCallback {
override fun onSuccess(launchResult: QLaunchResult) {
@@ -472,13 +473,13 @@ internal class QProductCenterManager internal constructor(
val isLogoutNeeded = identityManager.logoutIfNeeded()
if (isLogoutNeeded) {
- remoteConfigManager.onUserUpdate()
+ val userId = userInfoService.obtainUserId()
+ remoteConfigManager.onUserUpdate(null) {
+ internalConfig.uid = userId
+ }
launchResultCache.clearPermissionsCache()
unhandledLogoutAvailable = true
-
- val userId = userInfoService.obtainUserId()
- internalConfig.uid = userId
}
}
@@ -527,8 +528,9 @@ internal class QProductCenterManager internal constructor(
)
userInfoService.storeQonversionUserId(newUserId)
- internalConfig.uid = newUserId
- remoteConfigManager.onUserUpdate()
+ remoteConfigManager.onUserUpdate(null) {
+ internalConfig.uid = newUserId
+ }
launchResultCache.clearPermissionsCache()
}
diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/QRemoteConfigManager.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/QRemoteConfigManager.kt
index 8b684ee7b..121b5d313 100644
--- a/sdk/src/main/java/com/qonversion/android/sdk/internal/QRemoteConfigManager.kt
+++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/QRemoteConfigManager.kt
@@ -8,8 +8,11 @@ import com.qonversion.android.sdk.dto.QRemoteConfigList
import com.qonversion.android.sdk.dto.QonversionError
import com.qonversion.android.sdk.dto.QonversionErrorCode
import com.qonversion.android.sdk.internal.provider.UserStateProvider
+import com.qonversion.android.sdk.internal.remoteconfig.RemoteConfigIdentityBridge
import com.qonversion.android.sdk.internal.services.QFallbacksService
import com.qonversion.android.sdk.internal.services.QRemoteConfigService
+import com.qonversion.android.sdk.internal.storage.RemoteConfigCache
+import com.qonversion.android.sdk.internal.storage.RemoteConfigCacheScope
import com.qonversion.android.sdk.listeners.QonversionEmptyCallback
import com.qonversion.android.sdk.listeners.QonversionExperimentAttachCallback
import com.qonversion.android.sdk.listeners.QonversionRemoteConfigCallback
@@ -20,18 +23,54 @@ import javax.inject.Inject
private val EmptyContextKey: String? = null
+private fun String?.normalizedRemoteConfigContextKey(): String? = takeUnless { it.isNullOrEmpty() }
+
+internal enum class QRemoteConfigDeliveryOrigin {
+ Network,
+ MemoryCache,
+ RetryBaseline,
+ PersistentLastKnownGood,
+ BundledFallback,
+}
+
+private data class RemoteConfigRequestIdentity(
+ val userGeneration: Int,
+ val cacheScope: RemoteConfigCacheScope?,
+)
+
// Rate-limit tolerance is scoped to remote configs deliberately: the other
// shouldFireFallback consumer (the entitlements path) keeps surfacing
-// ApiRateLimitExceeded unchanged. A locally short-circuited RC request is
-// exactly the case the bundled payload exists for — and since fallbacks are
-// no longer cached, offline repeat calls hit the limiter instead of the old
-// cached-fallback fast path.
+// ApiRateLimitExceeded unchanged. RC requests that are locally short-circuited
+// or receive transient HTTP 408/429 responses are exactly the cases the local
+// fallback chain exists for. Since fallbacks are no longer memory-cached,
+// repeat calls still retry the service whenever the rate limiter permits.
private val QonversionError.shouldFireRemoteConfigFallback
- get(): Boolean = shouldFireFallback || code == QonversionErrorCode.ApiRateLimitExceeded
+ get(): Boolean {
+ if (code in NON_RECOVERABLE_REMOTE_CONFIG_ERRORS) return false
+
+ return shouldFireFallback ||
+ code == QonversionErrorCode.ApiRateLimitExceeded ||
+ code == QonversionErrorCode.ResponseParsingFailed ||
+ httpCode == HTTP_REQUEST_TIMEOUT ||
+ httpCode == HTTP_TOO_MANY_REQUESTS
+ }
+
+private val NON_RECOVERABLE_REMOTE_CONFIG_ERRORS = setOf(
+ QonversionErrorCode.Unknown,
+ QonversionErrorCode.InvalidCredentials,
+ QonversionErrorCode.InvalidClientUid,
+ QonversionErrorCode.UnknownClientPlatform,
+ QonversionErrorCode.ProjectConfigError,
+ QonversionErrorCode.InvalidStoreCredentials,
+)
+
+private const val HTTP_REQUEST_TIMEOUT = 408
+private const val HTTP_TOO_MANY_REQUESTS = 429
internal class QRemoteConfigManager @Inject constructor(
private val remoteConfigService: QRemoteConfigService,
- private val fallbacksService: QFallbacksService
+ private val fallbacksService: QFallbacksService,
+ private val persistentCache: RemoteConfigCache,
) {
private val fallbackData: QFallbackObject? by lazy {
fallbacksService.obtainFallbackData()
@@ -66,9 +105,14 @@ internal class QRemoteConfigManager @Inject constructor(
lateinit var userStateProvider: UserStateProvider
private var loadingStates = mutableMapOf()
+ private val deliveryOrigins = mutableMapOf()
private val listRequests = mutableListOf()
lateinit var userPropertiesManager: QUserPropertiesManager
+
+ /** Observers of the identity/targeting transitions this manager owns (Remote Config v2). */
+ internal val identityBridge = RemoteConfigIdentityBridge()
private val mainHandler = Handler(Looper.getMainLooper())
+ private val identityTransitionLock = Any()
// Bumped on every cache invalidation (attach/detach, user change, explicit
// invalidateRemoteConfigsCache). Loads capture it when they start and skip
@@ -78,8 +122,10 @@ internal class QRemoteConfigManager @Inject constructor(
// caller thread, so the cached fast paths reject stale values immediately
// instead of waiting for the posted main-thread hop to drain.
private val invalidationGeneration = AtomicInteger(0)
+ private val userGeneration = AtomicInteger(0)
+ private var appliedUserGeneration = 0
- fun handlePendingRequests() = postToMainThread {
+ fun handlePendingRequests() = postIdentityAction {
loadingStates.filter { it.value.callbacks.isNotEmpty() }
.keys.forEach { contextKey -> loadRemoteConfig(contextKey, null) }
@@ -97,7 +143,7 @@ internal class QRemoteConfigManager @Inject constructor(
}
}
- fun userChangingRequestFailedWithError(error: QonversionError) = postToMainThread {
+ fun userChangingRequestFailedWithError(error: QonversionError) = postIdentityAction {
// Snapshot the keys: fireToCallbacks runs user callbacks, and a callback that
// re-enters loadRemoteConfig with a new key runs inline (already on the main thread)
// and registers that key in loadingStates. Iterating a copy keeps that re-entrant
@@ -116,25 +162,78 @@ internal class QRemoteConfigManager @Inject constructor(
// stale so the next load fetches a fresh evaluation. Non-destructive —
// loading states and pending callbacks survive, and the generation bump
// stops in-flight loads from re-caching a superseded response.
- fun invalidateRemoteConfigsCache() = invalidateOnAnyThread {}
+ fun invalidateRemoteConfigsCache(externalUserId: String? = null) {
+ invalidateOnAnyThread {}
+ identityBridge.targetingInvalidated(externalUserId)
+ }
- fun onUserUpdate() {
- // Bump synchronously (see invalidateOnAnyThread) — the destructive
- // map replacement still happens on main.
- invalidationGeneration.incrementAndGet()
- postToMainThread {
- loadingStates = mutableMapOf()
+ fun onUserUpdate(externalUserId: String? = null, updateIdentity: () -> Unit = {}) {
+ // The generation and the UID mutation share one linearization point.
+ // Loads and response delivery take the same lock, so a background
+ // logout/identify cannot expose a half-transitioned cache scope.
+ synchronized(identityTransitionLock) {
+ invalidationGeneration.incrementAndGet()
+ userGeneration.incrementAndGet()
+ updateIdentity()
+ identityBridge.identityScopeChanged(externalUserId)
+ if (Looper.myLooper() == Looper.getMainLooper()) {
+ resetIdentityStateIfNeeded()
+ } else {
+ mainHandler.post {
+ synchronized(identityTransitionLock) {
+ resetIdentityStateIfNeeded()
+ }
+ }
+ }
+ }
+ }
+
+ private fun resetIdentityStateIfNeeded() {
+ val currentUserGeneration = userGeneration.get()
+ if (appliedUserGeneration == currentUserGeneration) return
+
+ // Move every waiter across the identity boundary before orphaning the
+ // old states. Clearing the old callback lists is essential: a late old
+ // response still owns those LoadingState instances and must not replay
+ // the same waiter a second time.
+ val pendingSingleRequests = loadingStates.mapValues { (_, state) ->
+ state.callbacks.toList().also { state.callbacks.clear() }
+ }.filterValues { it.isNotEmpty() }
+ loadingStates = mutableMapOf()
+ deliveryOrigins.clear()
+ appliedUserGeneration = currentUserGeneration
+ pendingSingleRequests.forEach { (contextKey, callbacks) ->
+ loadingStates[contextKey] = LoadingState(callbacks = callbacks.toMutableList())
+ if (userStateProvider.isUserStable) {
+ loadRemoteConfig(contextKey, null)
+ }
}
}
+ internal fun lastDeliveryOrigin(contextKey: String?): QRemoteConfigDeliveryOrigin? =
+ synchronized(identityTransitionLock) {
+ if (appliedUserGeneration == userGeneration.get()) {
+ deliveryOrigins[contextKey.normalizedRemoteConfigContextKey()]
+ } else {
+ null
+ }
+ }
+
// The explicit Unit is required: the re-issue path recurses into this
// function, and an inferred expression-body type would depend on itself.
- fun loadRemoteConfig(contextKey: String?, callback: QonversionRemoteConfigCallback?): Unit = postToMainThread {
+ fun loadRemoteConfig(contextKey: String?, callback: QonversionRemoteConfigCallback?): Unit =
+ loadRemoteConfigNormalized(contextKey.normalizedRemoteConfigContextKey(), callback)
+
+ private fun loadRemoteConfigNormalized(
+ contextKey: String?,
+ callback: QonversionRemoteConfigCallback?,
+ ): Unit = postIdentityAction {
loadingStates[contextKey]
?.takeIf { it.generation == invalidationGeneration.get() }
?.loadedConfig
?.takeIf { userStateProvider.isUserStable }
?.let { cached ->
+ deliveryOrigins[contextKey] = QRemoteConfigDeliveryOrigin.MemoryCache
// The cached config is served as is, but properties set right
// before this call must still reach the server (parity with
// iOS) - a cache hit must not swallow the flush.
@@ -163,7 +262,7 @@ internal class QRemoteConfigManager @Inject constructor(
if (callback != null && queued.none { it === callback }) {
callback.onSuccess(cached)
}
- return@postToMainThread
+ return@postIdentityAction
}
val loadingState = loadingStates[contextKey] ?: LoadingState()
@@ -174,119 +273,313 @@ internal class QRemoteConfigManager @Inject constructor(
}
if (!userStateProvider.isUserStable || loadingState.isInProgress) {
- return@postToMainThread
+ return@postIdentityAction
}
loadingState.isInProgress = true
loadingState.loadedConfig = null
val generationAtStart = invalidationGeneration.get()
+ val requestIdentity = captureRequestIdentity()
userPropertiesManager.forceSendProperties(object : QonversionEmptyCallback {
override fun onComplete() {
- remoteConfigService.loadRemoteConfig(contextKey, object : QonversionRemoteConfigCallback {
- override fun onSuccess(remoteConfig: QRemoteConfig) {
- // A successful (or delivered-as-is) response always
- // supersedes any baseline stashed by an earlier retry.
- loadingState.retryBaseline = null
- val currentGeneration = invalidationGeneration.get()
- if (currentGeneration == generationAtStart) {
- loadingState.loadedConfig = remoteConfig
- loadingState.generation = generationAtStart
- fireToCallbacks(contextKey) { onSuccess(remoteConfig) }
- return
- }
+ postIdentityAction {
+ if (requestIdentity.isCurrentAndStable()) {
+ loadRemoteConfigFromService(
+ contextKey,
+ loadingState,
+ generationAtStart,
+ requestIdentity,
+ )
+ } else {
+ reissueSingleAfterUserChange(contextKey, loadingState)
+ }
+ }
+ }
+ })
+ }
- // The cache was invalidated while this load was in
- // flight, so this evaluation is already superseded.
- // Re-issue the load once per generation so the waiting
- // callbacks receive a fresh evaluation instead of the
- // stale one. The state must still be live: a user
- // switch replaces the map, and an orphaned state must
- // not fire a request nobody awaits. The waiters are
- // snapshotted and carried through the retry with the
- // superseded (but valid) evaluation as a baseline — a
- // failed retry degrades to the baseline instead of
- // surfacing an error where the caller previously got
- // a success. The generation cap is defense-in-depth:
- // the retry is bounded primarily by the per-key
- // isInProgress serialisation (one load, hence one
- // superseded response, per generation).
- if (loadingStates[contextKey] === loadingState &&
- loadingState.callbacks.isNotEmpty() &&
- loadingState.reissuedForGeneration != currentGeneration
- ) {
- loadingState.reissuedForGeneration = currentGeneration
- loadingState.isInProgress = false
- // The stash makes the never-worse guarantee
- // uniform: the retry's failure handlers prefer it
- // over both the error and the bundled fallback,
- // reaching late joiners queued during the retry.
- loadingState.retryBaseline = remoteConfig
- val waiters = loadingState.callbacks.toList()
- loadingState.callbacks.clear()
- val baseline = remoteConfig
- loadRemoteConfig(contextKey, object : QonversionRemoteConfigCallback {
- override fun onSuccess(remoteConfig: QRemoteConfig) {
- waiters.forEach { it.onSuccess(remoteConfig) }
- }
-
- override fun onError(error: QonversionError) {
- // Safety net only: with the stash in place
- // the retry resolves via onSuccess; this
- // branch survives for exotic interleavings.
- waiters.forEach { it.onSuccess(baseline) }
- }
- })
- return
+ private fun loadRemoteConfigFromService(
+ contextKey: String?,
+ loadingState: LoadingState,
+ generationAtStart: Int,
+ requestIdentity: RemoteConfigRequestIdentity,
+ ) {
+ remoteConfigService.loadRemoteConfig(contextKey, object : QonversionRemoteConfigCallback {
+ override fun onSuccess(remoteConfig: QRemoteConfig) {
+ postIdentityAction {
+ if (requestIdentity.isCurrentAndStable()) {
+ if (remoteConfig.source.contextKey == contextKey) {
+ handleRemoteConfigSuccess(
+ contextKey,
+ loadingState,
+ generationAtStart,
+ requestIdentity,
+ remoteConfig,
+ )
+ } else {
+ handleRemoteConfigError(
+ contextKey,
+ loadingState,
+ requestIdentity.cacheScope,
+ malformedRemoteConfigResponseError(),
+ )
}
- fireToCallbacks(contextKey) { onSuccess(remoteConfig) }
+ } else {
+ reissueSingleAfterUserChange(contextKey, loadingState)
}
+ }
+ }
- override fun onError(error: QonversionError) {
- val baseline = loadingState.retryBaseline
- loadingState.retryBaseline = null
- // The fallback is a bundled last-resort payload, not a
- // fresh targeting evaluation — deliver it without
- // caching so the next call retries the network instead
- // of pinning the fallback until the next invalidation.
- val bundledConfig = if (error.shouldFireRemoteConfigFallback) {
- fallbackData?.remoteConfigList?.let { list ->
- if (contextKey == null) {
- list.remoteConfigForEmptyContextKey
- } else {
- list.remoteConfigForContextKey(contextKey)
- }
- }
+ override fun onError(error: QonversionError) {
+ postIdentityAction {
+ if (requestIdentity.isCurrentAndStable()) {
+ if (error.code == QonversionErrorCode.RemoteConfigurationNotAvailable &&
+ requestIdentity.cacheScope != null
+ ) {
+ handleAuthoritativeRemoteConfigRemoval(
+ contextKey,
+ loadingState,
+ generationAtStart,
+ requestIdentity,
+ error,
+ )
} else {
- null
+ handleRemoteConfigError(contextKey, loadingState, requestIdentity.cacheScope, error)
}
+ } else {
+ reissueSingleAfterUserChange(contextKey, loadingState)
+ }
+ }
+ }
+ })
+ }
- // A failed retry of a superseded load degrades to the
- // baseline — a real user-specific evaluation seconds
- // old — for everyone, including callers who joined
- // during the retry window. It outranks both the error
- // and the static bundled payload.
- val result = baseline ?: bundledConfig
- result?.let { config ->
- fireToCallbacks(contextKey) { onSuccess(config) }
- } ?: fireToCallbacks(contextKey) { onError(error) }
+ private fun handleAuthoritativeRemoteConfigRemoval(
+ contextKey: String?,
+ loadingState: LoadingState,
+ generationAtStart: Int,
+ requestIdentity: RemoteConfigRequestIdentity,
+ error: QonversionError,
+ ) {
+ if (invalidationGeneration.get() != generationAtStart) {
+ reissueSingleAfterUserChange(contextKey, loadingState)
+ return
+ }
+ val cacheScope = requestIdentity.cacheScope ?: run {
+ handleRemoteConfigError(contextKey, loadingState, null, error)
+ return
+ }
+ persistentCache.remove(cacheScope, contextKey) { committed ->
+ postIdentityAction {
+ when {
+ !requestIdentity.isCurrentAndStable() ->
+ reissueSingleAfterUserChange(contextKey, loadingState)
+ invalidationGeneration.get() != generationAtStart ->
+ reissueSingleAfterUserChange(contextKey, loadingState)
+ committed -> handleRemoteConfigError(contextKey, loadingState, cacheScope, error)
+ else -> {
+ loadingState.retryBaseline = null
+ fireToCallbacks(contextKey) { onError(remoteConfigPersistenceError()) }
}
- })
+ }
+ }
+ }
+ }
+
+ private fun reissueSingleAfterUserChange(
+ contextKey: String?,
+ supersededState: LoadingState,
+ ) {
+ val waiters = supersededState.callbacks.toList()
+ supersededState.callbacks.clear()
+ supersededState.isInProgress = false
+ enqueueIdentityAction {
+ waiters.forEach { loadRemoteConfig(contextKey, it) }
+ }
+ }
+
+ private fun handleRemoteConfigSuccess(
+ contextKey: String?,
+ loadingState: LoadingState,
+ generationAtStart: Int,
+ requestIdentity: RemoteConfigRequestIdentity,
+ remoteConfig: QRemoteConfig,
+ ) {
+ loadingState.retryBaseline = null
+ val currentGeneration = invalidationGeneration.get()
+ if (currentGeneration != generationAtStart) {
+ deliverOrReissueRemoteConfigSuccess(
+ contextKey,
+ loadingState,
+ generationAtStart,
+ remoteConfig,
+ )
+ return
+ }
+
+ val cacheScope = requestIdentity.cacheScope
+ if (cacheScope == null) {
+ deliverOrReissueRemoteConfigSuccess(
+ contextKey,
+ loadingState,
+ generationAtStart,
+ remoteConfig,
+ )
+ return
+ }
+
+ persistentCache.save(cacheScope, remoteConfig) { committed ->
+ postIdentityAction {
+ when {
+ !requestIdentity.isCurrentAndStable() ->
+ reissueSingleAfterUserChange(contextKey, loadingState)
+ invalidationGeneration.get() != generationAtStart ->
+ deliverOrReissueRemoteConfigSuccess(
+ contextKey,
+ loadingState,
+ generationAtStart,
+ remoteConfig,
+ )
+ committed -> deliverOrReissueRemoteConfigSuccess(
+ contextKey,
+ loadingState,
+ generationAtStart,
+ remoteConfig,
+ )
+ else -> handleRemoteConfigError(
+ contextKey,
+ loadingState,
+ cacheScope,
+ remoteConfigPersistenceError(),
+ )
+ }
+ }
+ }
+ }
+
+ private fun deliverOrReissueRemoteConfigSuccess(
+ contextKey: String?,
+ loadingState: LoadingState,
+ generationAtStart: Int,
+ remoteConfig: QRemoteConfig,
+ ) {
+ val currentGeneration = invalidationGeneration.get()
+ if (currentGeneration == generationAtStart) {
+ deliveryOrigins[contextKey] = QRemoteConfigDeliveryOrigin.Network
+ loadingState.loadedConfig = remoteConfig
+ loadingState.generation = generationAtStart
+ fireToCallbacks(contextKey) { onSuccess(remoteConfig) }
+ return
+ }
+
+ // An invalidation superseded this evaluation. Re-issue only while the
+ // loading state is still live; a user switch replaces the map and an
+ // orphaned response must not start a request nobody awaits.
+ val shouldReissue = loadingStates[contextKey] === loadingState &&
+ loadingState.callbacks.isNotEmpty() &&
+ loadingState.reissuedForGeneration != currentGeneration
+ if (shouldReissue) {
+ reissueRemoteConfig(contextKey, loadingState, currentGeneration, remoteConfig)
+ return
+ }
+
+ deliveryOrigins[contextKey] = QRemoteConfigDeliveryOrigin.Network
+ fireToCallbacks(contextKey) { onSuccess(remoteConfig) }
+ }
+
+ private fun reissueRemoteConfig(
+ contextKey: String?,
+ loadingState: LoadingState,
+ currentGeneration: Int,
+ baseline: QRemoteConfig,
+ ) {
+ loadingState.reissuedForGeneration = currentGeneration
+ loadingState.isInProgress = false
+ loadingState.retryBaseline = baseline
+ val waiters = loadingState.callbacks.toList()
+ loadingState.callbacks.clear()
+ loadRemoteConfig(contextKey, object : QonversionRemoteConfigCallback {
+ override fun onSuccess(remoteConfig: QRemoteConfig) {
+ waiters.forEach { it.onSuccess(remoteConfig) }
+ }
+
+ override fun onError(error: QonversionError) {
+ // Safety net only: the retry stash normally resolves via
+ // onSuccess when a transient request failure is eligible for
+ // fallback. Authentication, other client errors and an
+ // authoritative no-config response must remain errors.
+ if (error.shouldFireRemoteConfigFallback) {
+ waiters.forEach { it.onSuccess(baseline) }
+ } else {
+ waiters.forEach { it.onError(error) }
+ }
}
})
}
+ private fun handleRemoteConfigError(
+ contextKey: String?,
+ loadingState: LoadingState,
+ cacheScope: RemoteConfigCacheScope?,
+ error: QonversionError,
+ ) {
+ val baseline = loadingState.retryBaseline
+ loadingState.retryBaseline = null
+ val canRecover = error.shouldFireRemoteConfigFallback
+ val lastKnownGood = if (canRecover && cacheScope != null) {
+ persistentCache.get(cacheScope, contextKey)
+ } else {
+ null
+ }
+ val bundledConfig = if (canRecover) bundledRemoteConfig(contextKey) else null
+
+ // A real user-specific evaluation (even a superseded retry baseline)
+ // outranks persisted LKG, which in turn outranks the static bundle.
+ val result = baseline.takeIf { canRecover } ?: lastKnownGood ?: bundledConfig
+ result?.let { config ->
+ deliveryOrigins[contextKey] = when {
+ baseline != null && canRecover -> QRemoteConfigDeliveryOrigin.RetryBaseline
+ lastKnownGood != null -> QRemoteConfigDeliveryOrigin.PersistentLastKnownGood
+ else -> QRemoteConfigDeliveryOrigin.BundledFallback
+ }
+ fireToCallbacks(contextKey) { onSuccess(config) }
+ } ?: fireToCallbacks(contextKey) { onError(error) }
+ }
+
+ private fun bundledRemoteConfig(contextKey: String?): QRemoteConfig? =
+ fallbackData?.remoteConfigList?.let { list ->
+ if (contextKey == null) {
+ list.remoteConfigForEmptyContextKey
+ } else {
+ list.remoteConfigForContextKey(contextKey)
+ }
+ }
+
fun loadRemoteConfigList(
contextKeys: List,
includeEmptyContextKey: Boolean,
callback: QonversionRemoteConfigListCallback
- ) = postToMainThread {
+ ) = loadRemoteConfigListNormalized(
+ contextKeys.filter(String::isNotEmpty).distinct(),
+ includeEmptyContextKey,
+ callback,
+ )
+
+ private fun loadRemoteConfigListNormalized(
+ contextKeys: List,
+ includeEmptyContextKey: Boolean,
+ callback: QonversionRemoteConfigListCallback,
+ ) = postIdentityAction {
val allKeys = if (includeEmptyContextKey) contextKeys + EmptyContextKey else contextKeys
val currentGeneration = invalidationGeneration.get()
val cachedConfigs = allKeys.map { key ->
loadingStates[key]?.takeIf { it.generation == currentGeneration }?.loadedConfig
}
- if (cachedConfigs.all { it != null }) {
+ if (userStateProvider.isUserStable && cachedConfigs.all { it != null }) {
+ allKeys.forEach { key ->
+ deliveryOrigins[key] = QRemoteConfigDeliveryOrigin.MemoryCache
+ }
// Same as the single-key cache hit: flush pending properties so a
// hit does not swallow them. Gated on stability (parity with iOS)
// so the flush cannot POST mid-identify to a switching uid.
@@ -294,34 +587,64 @@ internal class QRemoteConfigManager @Inject constructor(
userPropertiesManager.forceSendProperties()
}
callback.onSuccess(QRemoteConfigList(cachedConfigs.filterNotNull()))
- return@postToMainThread
+ return@postIdentityAction
}
if (!userStateProvider.isUserStable) {
listRequests.add(ListRequestData(callback, contextKeys, includeEmptyContextKey))
- return@postToMainThread
+ return@postIdentityAction
}
+ val requestIdentity = captureRequestIdentity()
+ val generationAtStart = invalidationGeneration.get()
userPropertiesManager.forceSendProperties(object : QonversionEmptyCallback {
override fun onComplete() {
- remoteConfigService.loadRemoteConfigs(
- contextKeys,
- includeEmptyContextKey,
- getRemoteConfigListCallbackWrapper(contextKeys, includeEmptyContextKey, callback),
- )
+ postIdentityAction {
+ if (requestIdentity.isCurrentAndStable()) {
+ remoteConfigService.loadRemoteConfigs(
+ contextKeys,
+ includeEmptyContextKey,
+ getRemoteConfigListCallbackWrapper(
+ contextKeys,
+ includeEmptyContextKey,
+ callback,
+ requestIdentity,
+ generationAtStart,
+ ),
+ )
+ } else {
+ reissueRemoteConfigListAfterUserChange(contextKeys, includeEmptyContextKey, callback)
+ }
+ }
}
})
}
- fun loadRemoteConfigList(callback: QonversionRemoteConfigListCallback) = postToMainThread {
+ fun loadRemoteConfigList(callback: QonversionRemoteConfigListCallback) = postIdentityAction {
if (!userStateProvider.isUserStable) {
listRequests.add(ListRequestData(callback))
- return@postToMainThread
+ return@postIdentityAction
}
+ val requestIdentity = captureRequestIdentity()
+ val generationAtStart = invalidationGeneration.get()
userPropertiesManager.forceSendProperties(object : QonversionEmptyCallback {
override fun onComplete() {
- remoteConfigService.loadRemoteConfigs(getRemoteConfigListCallbackWrapper(null, true, callback))
+ postIdentityAction {
+ if (requestIdentity.isCurrentAndStable()) {
+ remoteConfigService.loadRemoteConfigs(
+ getRemoteConfigListCallbackWrapper(
+ null,
+ true,
+ callback,
+ requestIdentity,
+ generationAtStart,
+ ),
+ )
+ } else {
+ reissueRemoteConfigListAfterUserChange(null, true, callback)
+ }
+ }
}
})
}
@@ -361,8 +684,9 @@ internal class QRemoteConfigManager @Inject constructor(
// then the cached values are cleared and the action runs on main.
private fun invalidateOnAnyThread(action: () -> Unit) {
invalidationGeneration.incrementAndGet()
- postToMainThread {
+ postIdentityAction {
loadingStates.values.forEach { it.loadedConfig = null }
+ deliveryOrigins.clear()
action()
}
}
@@ -370,58 +694,256 @@ internal class QRemoteConfigManager @Inject constructor(
private fun getRemoteConfigListCallbackWrapper(
contextKeys: List?,
includeEmptyContextKey: Boolean,
- callback: QonversionRemoteConfigListCallback
+ callback: QonversionRemoteConfigListCallback,
+ requestIdentity: RemoteConfigRequestIdentity,
+ generationAtStart: Int,
): QonversionRemoteConfigListCallback {
// Remembering loading states for the case of user change -
// if it happens, we won't store remote configs for different user.
val localLoadingStates = loadingStates
- val generationAtStart = invalidationGeneration.get()
return object : QonversionRemoteConfigListCallback {
override fun onSuccess(remoteConfigList: QRemoteConfigList) {
- if (invalidationGeneration.get() == generationAtStart) {
- remoteConfigList.remoteConfigs.forEach { remoteConfig ->
- val contextKey = remoteConfig.source.contextKey
- val loadingState = localLoadingStates[contextKey] ?: LoadingState()
- loadingState.loadedConfig = remoteConfig
- loadingState.generation = generationAtStart
- localLoadingStates[contextKey] = loadingState
+ postIdentityAction {
+ if (!requestIdentity.isCurrentAndStable()) {
+ reissueRemoteConfigListAfterUserChange(contextKeys, includeEmptyContextKey, callback)
+ return@postIdentityAction
+ }
+ if (!remoteConfigListMatchesRequest(contextKeys, includeEmptyContextKey, remoteConfigList)) {
+ val error = malformedRemoteConfigResponseError()
+ remoteConfigListFallback(
+ contextKeys,
+ includeEmptyContextKey,
+ requestIdentity.cacheScope,
+ )?.let(callback::onSuccess) ?: callback.onError(error)
+ return@postIdentityAction
}
+ handleRemoteConfigListSuccess(
+ contextKeys,
+ includeEmptyContextKey,
+ callback,
+ requestIdentity,
+ generationAtStart,
+ localLoadingStates,
+ remoteConfigList,
+ )
}
-
- callback.onSuccess(remoteConfigList)
}
override fun onError(error: QonversionError) {
- if (!error.shouldFireRemoteConfigFallback) {
- callback.onError(error)
- return
+ postIdentityAction {
+ when {
+ !requestIdentity.isCurrentAndStable() ->
+ reissueRemoteConfigListAfterUserChange(contextKeys, includeEmptyContextKey, callback)
+ !error.shouldFireRemoteConfigFallback -> callback.onError(error)
+ else -> remoteConfigListFallback(
+ contextKeys,
+ includeEmptyContextKey,
+ requestIdentity.cacheScope,
+ )?.let(callback::onSuccess) ?: callback.onError(error)
+ }
}
+ }
+ }
+ }
+
+ private fun remoteConfigListMatchesRequest(
+ contextKeys: List?,
+ includeEmptyContextKey: Boolean,
+ remoteConfigList: QRemoteConfigList,
+ ): Boolean {
+ val returnedContextKeys = remoteConfigList.remoteConfigs.map { it.source.contextKey }
+ val requestedContextKeys = contextKeys?.let { keys ->
+ buildSet {
+ addAll(keys)
+ if (includeEmptyContextKey) add(null)
+ }
+ }
+ return returnedContextKeys.size == returnedContextKeys.distinct().size &&
+ (requestedContextKeys == null || returnedContextKeys.all(requestedContextKeys::contains))
+ }
- val baseRemoteConfigList = fallbackData?.remoteConfigList ?: run {
- callback.onError(error)
- return@onError
+ private fun malformedRemoteConfigResponseError() = QonversionError(
+ QonversionErrorCode.ResponseParsingFailed,
+ "Remote Config response does not match the request",
+ )
+
+ private fun remoteConfigPersistenceError() = QonversionError(
+ QonversionErrorCode.ResponseParsingFailed,
+ "Remote Config could not be persisted as last known good",
+ )
+
+ private fun handleRemoteConfigListSuccess(
+ contextKeys: List?,
+ includeEmptyContextKey: Boolean,
+ callback: QonversionRemoteConfigListCallback,
+ requestIdentity: RemoteConfigRequestIdentity,
+ generationAtStart: Int,
+ localLoadingStates: MutableMap,
+ remoteConfigList: QRemoteConfigList,
+ ) {
+ if (invalidationGeneration.get() != generationAtStart) {
+ // Preserve the legacy list contract: an already-valid response is
+ // still delivered, but a superseded evaluation is never promoted
+ // into either the in-memory cache or the persistent LKG.
+ remoteConfigList.remoteConfigs.forEach { remoteConfig ->
+ deliveryOrigins[remoteConfig.source.contextKey] = QRemoteConfigDeliveryOrigin.Network
+ }
+ callback.onSuccess(remoteConfigList)
+ return
+ }
+
+ val cacheScope = requestIdentity.cacheScope
+ if (cacheScope == null) {
+ completeRemoteConfigListSuccess(
+ callback,
+ generationAtStart,
+ localLoadingStates,
+ remoteConfigList,
+ )
+ return
+ }
+
+ reconcilePersistentCache(
+ contextKeys,
+ includeEmptyContextKey,
+ cacheScope,
+ remoteConfigList.remoteConfigs,
+ ) { committed ->
+ postIdentityAction {
+ when {
+ !requestIdentity.isCurrentAndStable() ->
+ reissueRemoteConfigListAfterUserChange(contextKeys, includeEmptyContextKey, callback)
+ invalidationGeneration.get() != generationAtStart ->
+ reissueRemoteConfigList(contextKeys, includeEmptyContextKey, callback)
+ committed -> completeRemoteConfigListSuccess(
+ callback,
+ generationAtStart,
+ localLoadingStates,
+ remoteConfigList,
+ )
+ else -> remoteConfigListFallback(
+ contextKeys,
+ includeEmptyContextKey,
+ cacheScope,
+ )?.let(callback::onSuccess) ?: callback.onError(remoteConfigPersistenceError())
}
+ }
+ }
+ }
- val remoteConfigList = if (contextKeys == null) {
- baseRemoteConfigList.copy()
+ private fun completeRemoteConfigListSuccess(
+ callback: QonversionRemoteConfigListCallback,
+ generationAtStart: Int,
+ localLoadingStates: MutableMap,
+ remoteConfigList: QRemoteConfigList,
+ ) {
+ remoteConfigList.remoteConfigs.forEach { remoteConfig ->
+ deliveryOrigins[remoteConfig.source.contextKey] = QRemoteConfigDeliveryOrigin.Network
+ }
+ remoteConfigList.remoteConfigs.forEach { remoteConfig ->
+ val contextKey = remoteConfig.source.contextKey
+ val loadingState = localLoadingStates[contextKey] ?: LoadingState()
+ loadingState.loadedConfig = remoteConfig
+ loadingState.generation = generationAtStart
+ localLoadingStates[contextKey] = loadingState
+ }
+
+ callback.onSuccess(remoteConfigList)
+ }
+
+ private fun reconcilePersistentCache(
+ contextKeys: List?,
+ includeEmptyContextKey: Boolean,
+ cacheScope: RemoteConfigCacheScope,
+ remoteConfigs: List,
+ completion: (Boolean) -> Unit,
+ ) {
+ if (contextKeys == null) {
+ persistentCache.replaceAll(cacheScope, remoteConfigs, completion)
+ return
+ }
+
+ val requestedContextKeys = buildList {
+ addAll(contextKeys)
+ if (includeEmptyContextKey) add(null)
+ }.toSet()
+ persistentCache.replaceRequested(cacheScope, requestedContextKeys, remoteConfigs, completion)
+ }
+
+ private fun remoteConfigListFallback(
+ contextKeys: List?,
+ includeEmptyContextKey: Boolean,
+ cacheScope: RemoteConfigCacheScope?,
+ ): QRemoteConfigList? {
+ val persistedConfigs = cacheScope?.let { persistentCache.getAll(it).remoteConfigs }.orEmpty()
+ val bundledConfigList = fallbackData?.remoteConfigList
+ return if (persistedConfigs.isEmpty() && bundledConfigList == null) {
+ null
+ } else {
+ val result = mergeFallbackConfigs(
+ contextKeys,
+ includeEmptyContextKey,
+ persistedConfigs,
+ bundledConfigList,
+ )
+ markFallbackOrigins(result, persistedConfigs)
+ result
+ }
+ }
+
+ private fun mergeFallbackConfigs(
+ contextKeys: List?,
+ includeEmptyContextKey: Boolean,
+ persistedConfigs: List,
+ bundledConfigList: QRemoteConfigList?,
+ ): QRemoteConfigList {
+ val persistedByContext = persistedConfigs.associateBy { it.source.contextKey }
+ val bundledByContext = bundledConfigList?.remoteConfigs.orEmpty().associateBy { it.source.contextKey }
+ val desiredContextKeys = contextKeys?.let { keys ->
+ buildList {
+ addAll(keys)
+ if (includeEmptyContextKey) add(null)
+ }.distinct()
+ } ?: (persistedByContext.keys + bundledByContext.keys)
+
+ return QRemoteConfigList(desiredContextKeys.mapNotNull { key ->
+ persistedByContext[key] ?: bundledByContext[key]
+ })
+ }
+
+ private fun markFallbackOrigins(
+ remoteConfigList: QRemoteConfigList,
+ persistedConfigs: List,
+ ) {
+ val persistedContextKeys = persistedConfigs.map { it.source.contextKey }.toSet()
+ remoteConfigList.remoteConfigs.forEach { remoteConfig ->
+ deliveryOrigins[remoteConfig.source.contextKey] =
+ if (remoteConfig.source.contextKey in persistedContextKeys) {
+ QRemoteConfigDeliveryOrigin.PersistentLastKnownGood
} else {
- val remoteConfigs = baseRemoteConfigList.remoteConfigs.filter { contextKeys.contains(it.source.contextKey) }.toMutableList()
- if (includeEmptyContextKey) {
- baseRemoteConfigList.remoteConfigs.find { it.source.contextKey?.isEmpty() == true }?.let {
- remoteConfigs.add(it)
- }
- }
- QRemoteConfigList(remoteConfigs.toList())
+ QRemoteConfigDeliveryOrigin.BundledFallback
}
-
- // Bundled fallback, not a fresh targeting evaluation — deliver
- // without caching (see the single-key path), so the next call
- // retries the network.
- callback.onSuccess(remoteConfigList)
- }
}
}
+ private fun reissueRemoteConfigList(
+ contextKeys: List?,
+ includeEmptyContextKey: Boolean,
+ callback: QonversionRemoteConfigListCallback,
+ ) {
+ contextKeys?.let {
+ loadRemoteConfigList(it, includeEmptyContextKey, callback)
+ } ?: loadRemoteConfigList(callback)
+ }
+
+ private fun reissueRemoteConfigListAfterUserChange(
+ contextKeys: List?,
+ includeEmptyContextKey: Boolean,
+ callback: QonversionRemoteConfigListCallback,
+ ) = enqueueIdentityAction {
+ reissueRemoteConfigList(contextKeys, includeEmptyContextKey, callback)
+ }
+
private fun fireToCallbacks(contextKey: String?, action: QonversionRemoteConfigCallback.() -> Unit) {
loadingStates[contextKey]?.let { loadingState ->
loadingState.isInProgress = false
@@ -442,4 +964,30 @@ internal class QRemoteConfigManager @Inject constructor(
mainHandler.post(action)
}
}
+
+ private fun postIdentityAction(action: () -> Unit) = postToMainThread {
+ synchronized(identityTransitionLock) {
+ resetIdentityStateIfNeeded()
+ action()
+ }
+ }
+
+ private fun enqueueIdentityAction(action: () -> Unit) {
+ mainHandler.post {
+ synchronized(identityTransitionLock) {
+ resetIdentityStateIfNeeded()
+ action()
+ }
+ }
+ }
+
+ private fun captureRequestIdentity() = RemoteConfigRequestIdentity(
+ userGeneration = userGeneration.get(),
+ cacheScope = persistentCache.currentScope(),
+ )
+
+ private fun RemoteConfigRequestIdentity.isCurrentAndStable(): Boolean =
+ this@QRemoteConfigManager.userGeneration.get() == this.userGeneration &&
+ persistentCache.currentScope() == cacheScope &&
+ userStateProvider.isUserStable
}
diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/QonversionInternal.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/QonversionInternal.kt
index d20eca24e..6cdf66694 100644
--- a/sdk/src/main/java/com/qonversion/android/sdk/internal/QonversionInternal.kt
+++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/QonversionInternal.kt
@@ -6,6 +6,8 @@ import android.net.Uri
import android.os.Handler
import android.os.Looper
import androidx.lifecycle.ProcessLifecycleOwner
+import com.qonversion.android.sdk.ExperimentalQonversionApi
+import com.qonversion.android.sdk.QRemoteConfigSnapshots
import com.qonversion.android.sdk.Qonversion
import com.qonversion.android.sdk.dto.QAttributionProvider
import com.qonversion.android.sdk.dto.QPurchaseOptions
@@ -25,6 +27,8 @@ import com.qonversion.android.sdk.internal.logger.ConsoleLogger
import com.qonversion.android.sdk.internal.logger.ExceptionManager
import com.qonversion.android.sdk.internal.provider.AppStateProvider
import com.qonversion.android.sdk.internal.redemption.RedemptionManager
+import com.qonversion.android.sdk.internal.remoteconfig.RemoteConfigFetchForceReason
+import com.qonversion.android.sdk.internal.remoteconfig.RemoteConfigV2Factory
import com.qonversion.android.sdk.internal.services.QFallbacksService
import com.qonversion.android.sdk.internal.storage.SharedPreferencesCache
import com.qonversion.android.sdk.listeners.QonversionExperimentAttachCallback
@@ -46,6 +50,7 @@ import com.qonversion.android.sdk.dto.QPurchaseResult
import com.qonversion.android.sdk.dto.QPurchaseResultStatus
import com.qonversion.android.sdk.dto.QonversionErrorCode
+@OptIn(ExperimentalQonversionApi::class)
internal class QonversionInternal(
internalConfig: InternalConfig,
application: Application
@@ -59,6 +64,7 @@ internal class QonversionInternal(
private var sharedPreferencesCache: SharedPreferencesCache
private var exceptionManager: ExceptionManager
private var remoteConfigManager: QRemoteConfigManager
+ private val remoteConfigsV2: QRemoteConfigSnapshots
private var fallbackService: QFallbacksService
private val redemptionManager: RedemptionManager
@@ -116,6 +122,33 @@ internal class QonversionInternal(
remoteConfigManager.userPropertiesManager = userPropertiesManager
+ // Remote Config v2 is opt-in: with no QRemoteConfigV2Config the factory builds nothing but
+ // the (bundled-defaults only) public facade, so the pipeline stays completely dormant.
+ val remoteConfigsV2Impl = RemoteConfigV2Factory.create(
+ application,
+ internalConfig,
+ sharedPreferencesCache,
+ logger,
+ )
+ remoteConfigsV2 = remoteConfigsV2Impl
+ remoteConfigsV2Impl.manager?.let { manager ->
+ manager.updateIdentity(
+ internalConfig.uid,
+ RemoteConfigFetchForceReason.Build,
+ userInfoService.getPartnersIdentityId(),
+ )
+ // The v1 manager owns the identity transition; v2 switches its scope inside it, so the
+ // previous identity's release stops being readable at the same instant for both.
+ remoteConfigManager.identityBridge.onIdentityScopeChanged = { externalUserId ->
+ manager.updateIdentity(internalConfig.uid, RemoteConfigFetchForceReason.Identify, externalUserId)
+ }
+ // Targeting can change without the uid changing — identify() that only attaches an
+ // external id, an experiment attach, or an explicit invalidation. Re-read, keep serving.
+ remoteConfigManager.identityBridge.onTargetingInvalidated = { externalUserId ->
+ manager.refreshTargeting(externalUserId)
+ }
+ }
+
val lifecycleHandler = AppLifecycleHandler(this)
postToMainThread { ProcessLifecycleOwner.get().lifecycle.addObserver(lifecycleHandler) }
@@ -307,6 +340,8 @@ internal class QonversionInternal(
})
}
+ override fun remoteConfigSnapshots(): QRemoteConfigSnapshots = remoteConfigsV2
+
override fun invalidateRemoteConfigsCache() {
remoteConfigManager.invalidateRemoteConfigsCache()
}
diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/di/module/AppModule.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/di/module/AppModule.kt
index e9b0ddd6c..2c56410ae 100644
--- a/sdk/src/main/java/com/qonversion/android/sdk/internal/di/module/AppModule.kt
+++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/di/module/AppModule.kt
@@ -11,6 +11,8 @@ import com.qonversion.android.sdk.internal.provider.AppStateProvider
import com.qonversion.android.sdk.internal.services.QFallbacksService
import com.qonversion.android.sdk.internal.storage.LaunchResultCacheWrapper
import com.qonversion.android.sdk.internal.storage.PurchasesCache
+import com.qonversion.android.sdk.internal.storage.PersistentRemoteConfigCache
+import com.qonversion.android.sdk.internal.storage.RemoteConfigCache
import com.qonversion.android.sdk.internal.storage.SharedPreferencesCache
import com.squareup.moshi.Moshi
import dagger.Module
@@ -76,6 +78,15 @@ internal class AppModule(
return LaunchResultCacheWrapper(moshi, sharedPreferencesCache, internalConfig, fallbacksService)
}
+ @ApplicationScope
+ @Provides
+ fun provideRemoteConfigCache(
+ moshi: Moshi,
+ sharedPreferencesCache: SharedPreferencesCache,
+ ): RemoteConfigCache {
+ return PersistentRemoteConfigCache(sharedPreferencesCache, internalConfig, moshi)
+ }
+
@ApplicationScope
@Provides
fun provideFallbackService(
diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/di/module/NetworkModule.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/di/module/NetworkModule.kt
index ddf1d75e1..aa4e1ac0a 100644
--- a/sdk/src/main/java/com/qonversion/android/sdk/internal/di/module/NetworkModule.kt
+++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/di/module/NetworkModule.kt
@@ -32,12 +32,7 @@ import okhttp3.Cache
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
-import java.security.SecureRandom
-import java.security.cert.X509Certificate
import java.util.concurrent.TimeUnit
-import javax.net.ssl.SSLContext
-import javax.net.ssl.TrustManager
-import javax.net.ssl.X509TrustManager
@Module
internal class NetworkModule {
@@ -86,21 +81,11 @@ internal class NetworkModule {
context: Application,
interceptor: NetworkInterceptor
): OkHttpClient {
- val trustAllCerts = arrayOf(object : X509TrustManager {
- override fun checkClientTrusted(chain: Array, authType: String) {}
- override fun checkServerTrusted(chain: Array, authType: String) {}
- override fun getAcceptedIssuers(): Array = arrayOf()
- })
- val sslContext = SSLContext.getInstance("TLS")
- sslContext.init(null, trustAllCerts, SecureRandom())
-
return OkHttpClient.Builder()
.cache(Cache(context.cacheDir, CACHE_SIZE))
.readTimeout(TIMEOUT, TimeUnit.SECONDS)
.connectTimeout(TIMEOUT, TimeUnit.SECONDS)
.addInterceptor(interceptor)
- .sslSocketFactory(sslContext.socketFactory, trustAllCerts[0] as X509TrustManager)
- .hostnameVerifier { _, _ -> true }
.build()
}
diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/errors.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/errors.kt
index 99a392b1e..b4de542a7 100644
--- a/sdk/src/main/java/com/qonversion/android/sdk/internal/errors.kt
+++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/errors.kt
@@ -4,6 +4,8 @@ import com.android.billingclient.api.BillingClient
import com.qonversion.android.sdk.dto.QonversionError
import com.qonversion.android.sdk.dto.QonversionErrorCode
import com.qonversion.android.sdk.internal.billing.BillingError
+import com.squareup.moshi.JsonDataException
+import com.squareup.moshi.JsonEncodingException
import org.json.JSONException
import java.io.IOException
@@ -39,7 +41,11 @@ internal fun BillingError.toQonversionError(): QonversionError {
internal fun Throwable.toQonversionError(): QonversionError {
return when (this) {
- is JSONException -> {
+ // JsonEncodingException (syntactically malformed JSON) extends IOException, so it must be
+ // matched before the IOException branch below. It is never a network condition: the bytes
+ // arrived, they just are not valid JSON. Mapping it to NetworkConnectionFailed would make
+ // callers treat a permanently broken payload as a retryable transient failure.
+ is JSONException, is JsonDataException, is JsonEncodingException -> {
QonversionError(QonversionErrorCode.ResponseParsingFailed, localizedMessage ?: "")
}
diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/DeviceRemoteConfigClientContextProvider.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/DeviceRemoteConfigClientContextProvider.kt
new file mode 100644
index 000000000..9794f8f52
--- /dev/null
+++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/DeviceRemoteConfigClientContextProvider.kt
@@ -0,0 +1,74 @@
+package com.qonversion.android.sdk.internal.remoteconfig
+
+import android.content.Context
+import android.content.pm.PackageManager
+import android.os.Build
+import java.util.Locale
+
+private const val ANDROID_PLATFORM = "android"
+private const val UNKNOWN = "UNKNOWN"
+private const val UNDETERMINED_LANGUAGE_TAG = "und"
+private const val MILLIS_IN_SECOND = 1_000L
+
+/**
+ * Builds the snapshot request's `client_context` from device facts only.
+ *
+ * The constructor takes no identity on purpose. `device_installed_at` is read from
+ * `PackageManager.firstInstallTime`, which is a property of the installed package on this device:
+ * it is untouched by `identify()`, by logout, and by the anonymous uid being re-minted. That is
+ * exactly the invariant the server relies on — it evaluates account age as
+ * `min(device_installed_at, client.created_at)`, so a post-logout client row looks brand new and
+ * only the preserved device install date keeps a long-standing user out of "new users" targeting.
+ *
+ * Reusing the SDK's existing install-date source (`QProductCenterManager` reads the same
+ * `firstInstallTime` for `install_date`) keeps a single notion of "when this device installed the
+ * app" across the wire.
+ */
+internal class DeviceRemoteConfigClientContextProvider(
+ private val context: Context,
+ private val sdkVersion: String,
+) : RemoteConfigClientContextProvider {
+
+ override fun clientContext(): RemoteConfigClientContext? {
+ val packageInfo = packageInfo() ?: return null
+ return RemoteConfigClientContext(
+ platform = ANDROID_PLATFORM,
+ appVersion = packageInfo.versionName ?: UNKNOWN,
+ osVersion = Build.VERSION.RELEASE ?: UNKNOWN,
+ sdkVersion = sdkVersion,
+ locale = locale(),
+ deviceModel = Build.MODEL ?: UNKNOWN,
+ deviceInstalledAtSeconds = packageInfo.firstInstallTime
+ .coerceAtLeast(0) / MILLIS_IN_SECOND,
+ ).takeIf { it.isValid() }
+ }
+
+ private fun packageInfo() = try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ context.packageManager.getPackageInfo(
+ context.packageName,
+ PackageManager.PackageInfoFlags.of(0L),
+ )
+ } else {
+ @Suppress("DEPRECATION")
+ context.packageManager.getPackageInfo(context.packageName, 0)
+ }
+ } catch (_: Exception) {
+ null
+ }
+
+ /**
+ * `Locale.getLanguage()` still returns the pre-1989 ISO-639 codes (`iw`, `in`, `ji` instead of
+ * `he`, `id`, `yi`), which would silently miss those users in locale targeting.
+ * `toLanguageTag()` gives the modern BCP-47 subtags; the separator is normalised to `_` to
+ * match the shape the gateway contract documents (`en_US`).
+ */
+ private fun locale(): String {
+ val tag = try {
+ Locale.getDefault().toLanguageTag()
+ } catch (_: Exception) {
+ ""
+ }
+ return if (tag.isEmpty() || tag == UNDETERMINED_LANGUAGE_TAG) UNKNOWN else tag.replace('-', '_')
+ }
+}
diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/MainThreadDispatcher.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/MainThreadDispatcher.kt
new file mode 100644
index 000000000..dc1dabef7
--- /dev/null
+++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/MainThreadDispatcher.kt
@@ -0,0 +1,26 @@
+package com.qonversion.android.sdk.internal.remoteconfig
+
+import android.os.Handler
+import android.os.Looper
+
+/**
+ * Delivers Remote Config callbacks on the main thread.
+ *
+ * Mirrors `QonversionInternal.postToMainThread`: work already on the main thread runs inline, so a
+ * callback issued from the main thread is not deferred to the next loop iteration.
+ */
+internal class MainThreadDispatcher : RemoteConfigMainDispatcher {
+ private val handler = Handler(Looper.getMainLooper())
+
+ override fun post(action: () -> Unit) {
+ if (Looper.myLooper() == Looper.getMainLooper()) {
+ action()
+ } else {
+ handler.post(action)
+ }
+ }
+
+ override fun postDeferred(action: () -> Unit) {
+ handler.post(action)
+ }
+}
diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/PersistentRemoteConfigFetchPolicyStore.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/PersistentRemoteConfigFetchPolicyStore.kt
new file mode 100644
index 000000000..b9fc5206e
--- /dev/null
+++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/PersistentRemoteConfigFetchPolicyStore.kt
@@ -0,0 +1,110 @@
+package com.qonversion.android.sdk.internal.remoteconfig
+
+import com.qonversion.android.sdk.internal.storage.Cache
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+import com.squareup.moshi.Moshi
+import java.nio.ByteBuffer
+import java.security.MessageDigest
+
+private const val REMOTE_CONFIG_FETCH_POLICY_PREFIX = "qonversion_remote_config_v2_fetch_policy_"
+private const val REMOTE_CONFIG_FETCH_POLICY_VERSION = 1
+private const val REMOTE_CONFIG_FETCH_POLICY_MAX_BYTES = 1_024
+private const val REMOTE_CONFIG_FETCH_POLICY_MAX_FAILURES = 63
+
+internal class PersistentRemoteConfigFetchPolicyStore(
+ private val cache: Cache,
+ moshi: Moshi,
+) : RemoteConfigFetchPolicyStore {
+ private val adapter = moshi.adapter(PersistedRemoteConfigFetchPolicyState::class.java).failOnUnknown()
+
+ @Synchronized
+ @Suppress("ReturnCount")
+ override fun load(scope: RemoteConfigFetchPolicyScope): RemoteConfigFetchPolicyState? {
+ val key = remoteConfigFetchPolicyStorageKey(scope)
+ val raw = try {
+ cache.getString(key, null)
+ } catch (_: Exception) {
+ null
+ } ?: return null
+ val persisted = try {
+ raw.takeIf { it.toByteArray(Charsets.UTF_8).size <= REMOTE_CONFIG_FETCH_POLICY_MAX_BYTES }
+ ?.let(adapter::fromJson)
+ } catch (_: Exception) {
+ null
+ }
+ if (persisted == null || !persisted.isValid()) {
+ removeInvalid(key)
+ return null
+ }
+ return RemoteConfigFetchPolicyState(
+ lastSuccessfulFetchAtMillis = persisted.lastSuccessfulFetchAtMillis,
+ consecutiveRetryableFailures = persisted.consecutiveRetryableFailures,
+ nextAllowedFetchAtMillis = persisted.nextAllowedFetchAtMillis,
+ )
+ }
+
+ @Synchronized
+ @Suppress("ReturnCount")
+ override fun save(scope: RemoteConfigFetchPolicyScope, state: RemoteConfigFetchPolicyState): Boolean {
+ val persisted = PersistedRemoteConfigFetchPolicyState(
+ version = REMOTE_CONFIG_FETCH_POLICY_VERSION,
+ lastSuccessfulFetchAtMillis = state.lastSuccessfulFetchAtMillis,
+ consecutiveRetryableFailures = state.consecutiveRetryableFailures,
+ nextAllowedFetchAtMillis = state.nextAllowedFetchAtMillis,
+ )
+ if (!persisted.isValid()) return false
+ val raw = try {
+ adapter.toJson(persisted)
+ } catch (_: Exception) {
+ return false
+ }
+ if (raw.toByteArray(Charsets.UTF_8).size > REMOTE_CONFIG_FETCH_POLICY_MAX_BYTES) return false
+ return try {
+ cache.updateStringsDurably(
+ values = mapOf(remoteConfigFetchPolicyStorageKey(scope) to raw),
+ removedKeys = emptySet(),
+ )
+ } catch (_: Exception) {
+ false
+ }
+ }
+
+ private fun PersistedRemoteConfigFetchPolicyState.isValid(): Boolean =
+ version == REMOTE_CONFIG_FETCH_POLICY_VERSION &&
+ lastSuccessfulFetchAtMillis >= 0 &&
+ consecutiveRetryableFailures in 0..REMOTE_CONFIG_FETCH_POLICY_MAX_FAILURES &&
+ nextAllowedFetchAtMillis >= 0
+
+ private fun removeInvalid(key: String) {
+ try {
+ cache.updateStringsDurably(emptyMap(), setOf(key))
+ } catch (_: Exception) {
+ // The malformed state remains untrusted even when best-effort cleanup fails.
+ }
+ }
+}
+
+@JsonClass(generateAdapter = true)
+internal data class PersistedRemoteConfigFetchPolicyState(
+ val version: Int,
+ @Json(name = "last_successful_fetch_at_millis")
+ val lastSuccessfulFetchAtMillis: Long,
+ @Json(name = "consecutive_retryable_failures")
+ val consecutiveRetryableFailures: Int,
+ @Json(name = "next_allowed_fetch_at_millis")
+ val nextAllowedFetchAtMillis: Long,
+)
+
+private fun remoteConfigFetchPolicyStorageKey(scope: RemoteConfigFetchPolicyScope): String {
+ val digest = MessageDigest.getInstance("SHA-256")
+ digest.updateLengthPrefixed("remote-config-fetch-policy-v1".encodeToByteArray())
+ digest.updateLengthPrefixed(scope.projectKey.encodeToByteArray())
+ digest.updateLengthPrefixed(scope.environment.encodeToByteArray())
+ return REMOTE_CONFIG_FETCH_POLICY_PREFIX + digest.digest().joinToString("") { byte -> "%02x".format(byte) }
+}
+
+private fun MessageDigest.updateLengthPrefixed(value: ByteArray) {
+ update(ByteBuffer.allocate(Int.SIZE_BYTES).putInt(value.size).array())
+ update(value)
+}
diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/QRemoteConfigSnapshotsImpl.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/QRemoteConfigSnapshotsImpl.kt
new file mode 100644
index 000000000..94a4474ef
--- /dev/null
+++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/QRemoteConfigSnapshotsImpl.kt
@@ -0,0 +1,83 @@
+@file:OptIn(ExperimentalQonversionApi::class)
+
+package com.qonversion.android.sdk.internal.remoteconfig
+
+import com.qonversion.android.sdk.ExperimentalQonversionApi
+import com.qonversion.android.sdk.QRemoteConfigSnapshots
+import com.qonversion.android.sdk.dto.QRemoteConfigFallbackValue
+import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigActivationResult
+import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigFetchResult
+import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigFetchStatus
+import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigSnapshot
+import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigSubscription
+import com.qonversion.android.sdk.listeners.QRemoteConfigUpdateListener
+import com.qonversion.android.sdk.listeners.QonversionRemoteConfigActivationCallback
+import com.qonversion.android.sdk.listeners.QonversionRemoteConfigFetchCallback
+
+/**
+ * Adapts [RemoteConfigV2Manager] to the public [QRemoteConfigSnapshots] surface.
+ *
+ * A `null` [manager] is the dormant configuration: no v2 store, no connection, no scope. There is
+ * no release to read, so [current] is empty; [fallbackRemoteConfigValue] still answers, because the
+ * bundled defaults are an app asset rather than part of the pipeline. Every fetch completes with
+ * [QRemoteConfigFetchStatus.NotConfigured] instead of silently doing nothing.
+ */
+internal class QRemoteConfigSnapshotsImpl(
+ internal val manager: RemoteConfigV2Manager?,
+ private val bundledValueReader: (String) -> QRemoteConfigFallbackValue?,
+ private val mainDispatcher: RemoteConfigMainDispatcher,
+) : QRemoteConfigSnapshots {
+
+ override val current: QRemoteConfigSnapshot
+ get() = manager?.current ?: emptySnapshot()
+
+ override fun fetch(callback: QonversionRemoteConfigFetchCallback) = runFetch(null, callback)
+
+ override fun fetch(timeoutMs: Long, callback: QonversionRemoteConfigFetchCallback) =
+ runFetch(timeoutMs, callback)
+
+ override fun activate(callback: QonversionRemoteConfigActivationCallback) {
+ val target = manager ?: return mainDispatcher.post {
+ callback.onResult(notConfiguredActivation(null))
+ }
+ target.activate { result -> callback.onResult(result) }
+ }
+
+ override fun fetchAndActivate(callback: QonversionRemoteConfigActivationCallback) =
+ runFetchAndActivate(null, callback)
+
+ override fun fetchAndActivate(timeoutMs: Long, callback: QonversionRemoteConfigActivationCallback) =
+ runFetchAndActivate(timeoutMs, callback)
+
+ override fun fallbackRemoteConfigValue(contextKey: String): QRemoteConfigFallbackValue? =
+ bundledValueReader(contextKey)
+
+ override fun subscribeOnConfigUpdate(listener: QRemoteConfigUpdateListener): QRemoteConfigSubscription {
+ val target = manager ?: return QRemoteConfigSubscription { }
+ return target.subscribeOnConfigUpdate { update -> listener.onRemoteConfigUpdated(update) }
+ }
+
+ private fun runFetch(timeoutMs: Long?, callback: QonversionRemoteConfigFetchCallback) {
+ val target = manager ?: return mainDispatcher.post {
+ callback.onResult(QRemoteConfigFetchResult(QRemoteConfigFetchStatus.NotConfigured, emptySnapshot()))
+ }
+ target.fetch(timeoutMs) { result -> callback.onResult(result) }
+ }
+
+ private fun runFetchAndActivate(timeoutMs: Long?, callback: QonversionRemoteConfigActivationCallback) {
+ val target = manager ?: return mainDispatcher.post {
+ callback.onResult(notConfiguredActivation(QRemoteConfigFetchStatus.NotConfigured))
+ }
+ target.fetchAndActivate(timeoutMs) { result -> callback.onResult(result) }
+ }
+
+ private fun notConfiguredActivation(fetchStatus: QRemoteConfigFetchStatus?) = QRemoteConfigActivationResult(
+ changed = false,
+ snapshot = emptySnapshot(),
+ fetchStatus = fetchStatus,
+ )
+
+ private fun emptySnapshot() = QRemoteConfigSnapshot(
+ RemoteConfigSnapshot(primaryRelease = null, previousRelease = null, bundledRelease = null),
+ )
+}
diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigActivationAck.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigActivationAck.kt
new file mode 100644
index 000000000..fd5342cc1
--- /dev/null
+++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigActivationAck.kt
@@ -0,0 +1,524 @@
+package com.qonversion.android.sdk.internal.remoteconfig
+
+import com.qonversion.android.sdk.internal.storage.Cache
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+import com.squareup.moshi.Moshi
+import java.nio.ByteBuffer
+import java.security.MessageDigest
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicLong
+
+internal const val REMOTE_CONFIG_ACK_MAX_ATTEMPTS = 3
+internal const val REMOTE_CONFIG_ACK_INITIAL_RETRY_DELAY_MILLIS = 1_000L
+internal const val REMOTE_CONFIG_ACK_MAXIMUM_RETRY_DELAY_MILLIS = 30_000L
+
+private const val REMOTE_CONFIG_ACK_PREFIX = "qonversion_remote_config_v2_ack_"
+private const val REMOTE_CONFIG_ACK_VERSION = 1
+private const val REMOTE_CONFIG_ACK_MAX_BYTES = 1024
+private const val MILLIS_PER_SECOND = 1_000L
+private const val MINIMUM_RETRY_DELAY_MILLIS = 1L
+private const val SAFE_FALLBACK_JITTER = 0.5
+
+/**
+ * One activation the app owes the gateway an acknowledgement for.
+ *
+ * [activatedAtSeconds] is stamped when the activation happened, NOT when the ack is finally sent:
+ * a queued ack can outlive several retries and a process restart, and the server is being told
+ * when the release started serving.
+ */
+internal data class RemoteConfigActivationAck(
+ val releaseNumber: Long,
+ val activatedAtSeconds: Long,
+)
+
+/**
+ * The durable ack bookkeeping of one identity scope.
+ *
+ * [settledReleaseNumber] is what makes the "exactly one ack per (scope, release)" promise survive a
+ * restart: without it every cold start would re-ack the release it activates from persisted state.
+ * A release is settled once the gateway either accepted the ack or refused it permanently — both
+ * are answers, and neither is worth asking again.
+ */
+internal data class RemoteConfigActivationAckRecord(
+ val pending: RemoteConfigActivationAck?,
+ val settledReleaseNumber: Long,
+)
+
+internal interface RemoteConfigActivationAckStore {
+ fun load(scope: RemoteConfigSnapshotScope): RemoteConfigActivationAckRecord?
+ fun save(scope: RemoteConfigSnapshotScope, record: RemoteConfigActivationAckRecord): Boolean
+ fun clear(scope: RemoteConfigSnapshotScope): Boolean
+}
+
+internal sealed class RemoteConfigAckResponse {
+ /** The gateway accepted the ack (`204`, and any other `2xx`). */
+ data object Delivered : RemoteConfigAckResponse()
+
+ /** Retrying can only repeat the same answer — the ack is abandoned. */
+ data object Permanent : RemoteConfigAckResponse()
+
+ /** A transport fault or a `429`/`5xx`: worth one more bounded attempt. */
+ data object Retryable : RemoteConfigAckResponse()
+
+ /**
+ * The transport no longer addresses the identity the ack was queued for. No attempt was made,
+ * so it costs no retry budget and the queued ack stays durable for the next binding.
+ */
+ data object NotAddressable : RemoteConfigAckResponse()
+}
+
+internal fun interface RemoteConfigAckTransport {
+ fun sendAck(
+ scope: RemoteConfigSnapshotScope,
+ ack: RemoteConfigActivationAck,
+ completion: (RemoteConfigAckResponse) -> Unit,
+ )
+}
+
+/**
+ * Reports every activation that changed the served release, exactly once per (scope, release).
+ *
+ * Hard rules, in the order they matter:
+ * 1. **It can never affect the config data path.** Nothing here calls back into the app, blocks an
+ * activation, or feeds the fetch policy. Every failure is silent; the only externally visible
+ * trace of a lost ack is [droppedAckCount], which is a counter rather than a log line so a
+ * flapping gateway cannot turn into a log storm.
+ * 2. **At most one ack is in flight per scope, and the newest activation wins.** A later activation
+ * supersedes an older queued or in-flight one: the server wants to know which release is serving
+ * now, and re-sending the intermediate ones would be a request storm for no information.
+ * 3. **A queued ack is durable.** It is persisted before the first attempt and cleared only when
+ * delivered, permanently refused, or superseded — so a process death between activation and
+ * delivery does not lose it.
+ * 4. **Retries are bounded per process, not per binding.** [maxAttempts] attempts with
+ * exponentially growing, jittered delays, then delivery of that release is abandoned for the
+ * lifetime of the process — a rebind cannot buy it another three. The durable record survives
+ * the abandonment, so the next process start tries once more, and only a newer release re-arms
+ * delivery inside this one.
+ *
+ * The whole object only exists when the app configured Remote Config v2 (see
+ * [RemoteConfigV2Factory]), which is what keeps the feature dormant otherwise.
+ */
+@Suppress("LongParameterList", "TooManyFunctions")
+internal class RemoteConfigActivationAckSender(
+ private val transport: RemoteConfigAckTransport,
+ private val store: RemoteConfigActivationAckStore,
+ private val clock: RemoteConfigFetchClock,
+ private val random: RemoteConfigFetchRandom,
+ private val scheduler: RemoteConfigFetchScheduler,
+ private val maxAttempts: Int = REMOTE_CONFIG_ACK_MAX_ATTEMPTS,
+ private val initialRetryDelayMillis: Long = REMOTE_CONFIG_ACK_INITIAL_RETRY_DELAY_MILLIS,
+ private val maximumRetryDelayMillis: Long = REMOTE_CONFIG_ACK_MAXIMUM_RETRY_DELAY_MILLIS,
+) {
+ private val lock = Any()
+ private val storeLock = Any()
+ private val dropped = AtomicLong()
+ private var boundScope: RemoteConfigSnapshotScope? = null
+ private var pending: RemoteConfigActivationAck? = null
+ private var settledReleaseNumber = 0L
+ private var generation = 0L
+ private var inFlight = false
+ private var attempt = 0
+ private var retryScheduled = false
+ private var retryTask: RemoteConfigFetchScheduledTask? = null
+ private var writeStamp = 0L
+ private var lastWrittenStamp = 0L
+
+ /**
+ * The (scope, release) whose retry ladder this process already exhausted.
+ *
+ * In memory on purpose: the bound on attempts is per process, so a rebind — an identify that
+ * returns to an identity, a logout and back — must NOT buy the same release three more
+ * attempts against a gateway that is failing. Only a NEWER release re-arms delivery, and a
+ * genuinely new process reads the still-pending record and tries once more.
+ */
+ private var abandonedScope: RemoteConfigSnapshotScope? = null
+ private var abandonedReleaseNumber = 0L
+
+ /** Acks abandoned without delivery, ever. Deliberately a counter and not a log. */
+ val droppedAckCount: Long get() = dropped.get()
+
+ /**
+ * Binds the sender to [scope] and resumes whatever ack that scope still owes.
+ *
+ * This is the restart path: the durable record is the only thing that survives a process, and
+ * this is where it is read back. Binding also fences every in-flight and scheduled attempt of
+ * the previous scope — one identity's session must never vouch for another's activation.
+ */
+ @Suppress("ReturnCount")
+ fun bind(scope: RemoteConfigSnapshotScope?) {
+ synchronized(lock) {
+ if (isDeliveringForLocked(scope)) return
+ invalidateLocked()
+ boundScope = scope
+ pending = null
+ settledReleaseNumber = 0
+ if (scope != null) {
+ val record = loadRecord(scope)
+ pending = record?.pending
+ settledReleaseNumber = record?.settledReleaseNumber ?: 0
+ }
+ }
+ startIfIdle()
+ }
+
+ /**
+ * Queues an ack for the release that just became active in [scope].
+ *
+ * Idempotent by (scope, release): an already delivered release and an already queued one are
+ * both no-ops, so the caller may report the same activation as often as it likes — which is how
+ * an implicit (read-triggered) activation and an explicit `activate()` of the same release
+ * still produce exactly one ack.
+ */
+ @Suppress("ReturnCount")
+ fun recordActivation(scope: RemoteConfigSnapshotScope?, releaseNumber: Long) {
+ if (scope == null || releaseNumber <= 0) return
+ val write = synchronized(lock) {
+ if (scope != boundScope) return
+ if (releaseNumber == settledReleaseNumber) return
+ if (pending?.releaseNumber == releaseNumber) return
+ pending = RemoteConfigActivationAck(releaseNumber, nowSeconds())
+ // The newest activation supersedes an older in-flight or scheduled one.
+ invalidateLocked()
+ prepareWriteLocked(scope)
+ }
+ // Durable BEFORE the first attempt, and outside the lock: the write ends in a synchronous
+ // disk commit, and no other thread may be parked on the sender while it runs.
+ flush(write)
+ startIfIdle()
+ }
+
+ /**
+ * Whether an ack for exactly [scope] is already being delivered.
+ *
+ * Re-binding such an identity changes nothing, and fencing it would re-send an ack whose answer
+ * is merely still in flight — so an identify call that does not actually change the identity
+ * costs no request.
+ */
+ private fun isDeliveringForLocked(scope: RemoteConfigSnapshotScope?): Boolean {
+ if (scope == null || scope != boundScope) return false
+ return inFlight || retryScheduled
+ }
+
+ @Suppress("ReturnCount")
+ private fun startIfIdle() {
+ val started = synchronized(lock) {
+ val scope = boundScope ?: return
+ val ack = pending ?: return
+ if (inFlight || retryScheduled) return
+ if (isAbandonedLocked(scope, ack)) return
+ inFlight = true
+ attempt = 1
+ Attempt(generation, scope, ack)
+ }
+ dispatch(started)
+ }
+
+ private fun isAbandonedLocked(
+ scope: RemoteConfigSnapshotScope,
+ ack: RemoteConfigActivationAck,
+ ): Boolean = scope == abandonedScope && ack.releaseNumber == abandonedReleaseNumber
+
+ private fun dispatch(sending: Attempt) {
+ try {
+ transport.sendAck(sending.scope, sending.ack) { response -> onResponse(sending, response) }
+ } catch (@Suppress("TooGenericExceptionCaught") _: Throwable) {
+ onResponse(sending, RemoteConfigAckResponse.Retryable)
+ }
+ }
+
+ @Suppress("ReturnCount")
+ private fun onResponse(sent: Attempt, response: RemoteConfigAckResponse) {
+ if (!sent.claim()) return
+ var retryDelayMillis: Long? = null
+ val write = synchronized(lock) {
+ // A bind or a newer activation happened while this attempt was on the wire: its answer
+ // says nothing about the state the sender is in now.
+ if (sent.generation != generation || sent.scope != boundScope) return
+ inFlight = false
+ val write = when (response) {
+ // Both outcomes SETTLE the release durably. A permanent refusal is settled rather
+ // than forgotten on purpose: the likeliest one is a gateway that does not serve
+ // /ack at all, and forgetting it would re-queue and re-POST the very same ack on
+ // every process start and every identity binding, forever.
+ RemoteConfigAckResponse.Delivered -> settleLocked(sent)
+ RemoteConfigAckResponse.Permanent -> {
+ dropped.incrementAndGet()
+ settleLocked(sent)
+ }
+ // Not an attempt: the retry budget is untouched and the record stays queued.
+ RemoteConfigAckResponse.NotAddressable -> null
+ RemoteConfigAckResponse.Retryable -> if (attempt >= maxAttempts) {
+ dropped.incrementAndGet()
+ // Owed but abandoned for this process; the durable record is left untouched so
+ // the next process start delivers it.
+ abandonedScope = sent.scope
+ abandonedReleaseNumber = sent.ack.releaseNumber
+ null
+ } else {
+ retryDelayMillis = retryDelayLocked(attempt)
+ null
+ }
+ }
+ retryDelayMillis?.let { scheduleRetryLocked(it) }
+ write
+ }
+ flush(write)
+ }
+
+ private fun settleLocked(sent: Attempt): PendingWrite {
+ settledReleaseNumber = maxOf(settledReleaseNumber, sent.ack.releaseNumber)
+ if (pending?.releaseNumber == sent.ack.releaseNumber) pending = null
+ return prepareWriteLocked(sent.scope)
+ }
+
+ private fun scheduleRetryLocked(delayMillis: Long) {
+ val scheduledGeneration = generation
+ retryScheduled = true
+ retryTask = try {
+ scheduler.schedule(delayMillis) { onRetryDue(scheduledGeneration) }
+ } catch (@Suppress("TooGenericExceptionCaught") _: Throwable) {
+ retryScheduled = false
+ dropped.incrementAndGet()
+ null
+ }
+ }
+
+ @Suppress("ReturnCount")
+ private fun onRetryDue(scheduledGeneration: Long) {
+ val next = synchronized(lock) {
+ if (scheduledGeneration != generation) return
+ retryScheduled = false
+ retryTask = null
+ val scope = boundScope ?: return
+ val ack = pending ?: return
+ if (inFlight) return
+ attempt += 1
+ inFlight = true
+ Attempt(generation, scope, ack)
+ }
+ dispatch(next)
+ }
+
+ /**
+ * Fences everything in flight or scheduled.
+ *
+ * Only the in-memory delivery is invalidated; the durable record is untouched, because the ack
+ * it holds is still owed.
+ */
+ private fun invalidateLocked() {
+ generation++
+ retryScheduled = false
+ try {
+ retryTask?.cancel()
+ } catch (@Suppress("TooGenericExceptionCaught") _: Throwable) {
+ // Generation fencing, not cancellation, is what makes a stale timer harmless.
+ }
+ retryTask = null
+ inFlight = false
+ attempt = 0
+ }
+
+ private fun retryDelayLocked(attemptOrdinal: Int): Long {
+ var cap = initialRetryDelayMillis
+ repeat((attemptOrdinal - 1).coerceAtLeast(0)) {
+ cap = if (cap >= maximumRetryDelayMillis / 2) {
+ maximumRetryDelayMillis
+ } else {
+ (cap * 2).coerceAtMost(maximumRetryDelayMillis)
+ }
+ }
+ val randomValue = try {
+ random.nextDouble()
+ } catch (@Suppress("TooGenericExceptionCaught") _: Throwable) {
+ SAFE_FALLBACK_JITTER
+ }
+ val jitter = randomValue.takeIf { it.isFinite() && it >= 0.0 && it < 1.0 } ?: SAFE_FALLBACK_JITTER
+ // Half the cap plus jitter, not full-downward jitter: the latter can put all three attempts
+ // inside a few milliseconds, which is the storm the bound exists to prevent.
+ val half = cap / 2
+ return (half + (half.toDouble() * jitter).toLong()).coerceAtLeast(MINIMUM_RETRY_DELAY_MILLIS)
+ }
+
+ private fun prepareWriteLocked(scope: RemoteConfigSnapshotScope) = PendingWrite(
+ scope = scope,
+ record = RemoteConfigActivationAckRecord(pending, settledReleaseNumber),
+ stamp = ++writeStamp,
+ )
+
+ /**
+ * Writes a prepared record, outside [lock] so a synchronous disk commit can never park the
+ * thread that is activating or fetching.
+ *
+ * The stamp is what keeps two concurrent writers from committing out of order: a write that
+ * was prepared before the last committed one is dropped rather than allowed to resurrect it.
+ */
+ private fun flush(write: PendingWrite?) {
+ if (write == null) return
+ synchronized(storeLock) {
+ if (write.stamp <= lastWrittenStamp) return
+ lastWrittenStamp = write.stamp
+ try {
+ if (write.record.pending == null && write.record.settledReleaseNumber <= 0) {
+ store.clear(write.scope)
+ } else {
+ store.save(write.scope, write.record)
+ }
+ } catch (@Suppress("TooGenericExceptionCaught") _: Throwable) {
+ // The in-memory record still governs this process; a lost write can at worst cost
+ // one duplicate ack after a restart, which the gateway must tolerate anyway.
+ }
+ }
+ }
+
+ private class PendingWrite(
+ val scope: RemoteConfigSnapshotScope,
+ val record: RemoteConfigActivationAckRecord,
+ val stamp: Long,
+ )
+
+ private fun loadRecord(scope: RemoteConfigSnapshotScope): RemoteConfigActivationAckRecord? = try {
+ store.load(scope)
+ } catch (@Suppress("TooGenericExceptionCaught") _: Throwable) {
+ null
+ }
+
+ /**
+ * The activation timestamp, floored at 1: a zero would be indistinguishable from "absent" in
+ * the durable record and would make the queued ack silently un-persistable.
+ */
+ private fun nowSeconds(): Long = try {
+ clock.nowMillis().coerceAtLeast(0) / MILLIS_PER_SECOND
+ } catch (@Suppress("TooGenericExceptionCaught") _: Throwable) {
+ 0
+ }.coerceAtLeast(1)
+
+ /**
+ * One delivery attempt.
+ *
+ * [claim] makes the completion single-shot independently of the transport: a transport that
+ * both calls back and throws must not be able to advance the retry budget twice.
+ */
+ private class Attempt(
+ val generation: Long,
+ val scope: RemoteConfigSnapshotScope,
+ val ack: RemoteConfigActivationAck,
+ ) {
+ private val answered = AtomicBoolean(false)
+
+ fun claim(): Boolean = answered.compareAndSet(false, true)
+ }
+}
+
+/**
+ * Durable, per-identity-scope ack bookkeeping.
+ *
+ * Mirrors [PersistentRemoteConfigSessionStore]: the storage key is a salted digest of the scope, so
+ * neither the project key nor the canonical user id ever lands in a preference name.
+ */
+internal class PersistentRemoteConfigActivationAckStore(
+ private val cache: Cache,
+ moshi: Moshi,
+) : RemoteConfigActivationAckStore {
+ private val adapter = moshi.adapter(PersistedRemoteConfigActivationAck::class.java)
+
+ @Synchronized
+ @Suppress("ReturnCount")
+ override fun load(scope: RemoteConfigSnapshotScope): RemoteConfigActivationAckRecord? {
+ val storageKey = remoteConfigAckStorageKey(scope)
+ val raw = try {
+ cache.getString(storageKey, null)
+ } catch (_: Exception) {
+ null
+ } ?: return null
+ val persisted = try {
+ raw.takeIf { it.toByteArray(Charsets.UTF_8).size <= REMOTE_CONFIG_ACK_MAX_BYTES }
+ ?.let(adapter::fromJson)
+ } catch (_: Exception) {
+ null
+ }
+ if (persisted == null || !persisted.isValid()) {
+ removeInvalid(storageKey)
+ return null
+ }
+ return RemoteConfigActivationAckRecord(
+ pending = persisted.pendingReleaseNumber
+ .takeIf { it > 0 }
+ ?.let { RemoteConfigActivationAck(it, persisted.pendingActivatedAtSeconds) },
+ settledReleaseNumber = persisted.settledReleaseNumber,
+ )
+ }
+
+ @Synchronized
+ @Suppress("ReturnCount")
+ override fun save(scope: RemoteConfigSnapshotScope, record: RemoteConfigActivationAckRecord): Boolean {
+ val persisted = PersistedRemoteConfigActivationAck(
+ version = REMOTE_CONFIG_ACK_VERSION,
+ pendingReleaseNumber = record.pending?.releaseNumber ?: 0,
+ pendingActivatedAtSeconds = record.pending?.activatedAtSeconds ?: 0,
+ settledReleaseNumber = record.settledReleaseNumber,
+ )
+ if (!persisted.isValid()) return false
+ val raw = try {
+ adapter.toJson(persisted)
+ } catch (_: Exception) {
+ return false
+ }
+ if (raw.toByteArray(Charsets.UTF_8).size > REMOTE_CONFIG_ACK_MAX_BYTES) return false
+ return try {
+ cache.updateStringsDurably(
+ values = mapOf(remoteConfigAckStorageKey(scope) to raw),
+ removedKeys = emptySet(),
+ )
+ } catch (_: Exception) {
+ false
+ }
+ }
+
+ @Synchronized
+ override fun clear(scope: RemoteConfigSnapshotScope): Boolean = try {
+ cache.updateStringsDurably(emptyMap(), setOf(remoteConfigAckStorageKey(scope)))
+ } catch (_: Exception) {
+ false
+ }
+
+ private fun PersistedRemoteConfigActivationAck.isValid(): Boolean =
+ version == REMOTE_CONFIG_ACK_VERSION &&
+ pendingReleaseNumber >= 0 &&
+ settledReleaseNumber >= 0 &&
+ pendingActivatedAtSeconds >= 0 &&
+ (pendingReleaseNumber == 0L || pendingActivatedAtSeconds > 0)
+
+ private fun removeInvalid(key: String) {
+ try {
+ cache.updateStringsDurably(emptyMap(), setOf(key))
+ } catch (_: Exception) {
+ // A malformed record stays untrusted even when best-effort cleanup fails.
+ }
+ }
+}
+
+@JsonClass(generateAdapter = true)
+internal data class PersistedRemoteConfigActivationAck(
+ val version: Int,
+ @Json(name = "pending_release_number")
+ val pendingReleaseNumber: Long,
+ @Json(name = "pending_activated_at")
+ val pendingActivatedAtSeconds: Long,
+ @Json(name = "settled_release_number")
+ val settledReleaseNumber: Long,
+)
+
+private fun remoteConfigAckStorageKey(scope: RemoteConfigSnapshotScope): String {
+ val digest = MessageDigest.getInstance("SHA-256")
+ digest.updateLengthPrefixed("remote-config-activation-ack-v1".encodeToByteArray())
+ digest.updateLengthPrefixed(scope.projectKey.encodeToByteArray())
+ digest.updateLengthPrefixed(scope.environment.encodeToByteArray())
+ digest.updateLengthPrefixed(scope.canonicalUserId.encodeToByteArray())
+ return REMOTE_CONFIG_ACK_PREFIX + digest.digest().joinToString("") { byte -> "%02x".format(byte) }
+}
+
+private fun MessageDigest.updateLengthPrefixed(value: ByteArray) {
+ update(ByteBuffer.allocate(Int.SIZE_BYTES).putInt(value.size).array())
+ update(value)
+}
diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigFetchCoordinator.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigFetchCoordinator.kt
new file mode 100644
index 000000000..dd7d1e7c4
--- /dev/null
+++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigFetchCoordinator.kt
@@ -0,0 +1,642 @@
+package com.qonversion.android.sdk.internal.remoteconfig
+
+import java.util.ArrayDeque
+
+internal enum class RemoteConfigFetchForceReason {
+ Build,
+ Identify,
+ Logout,
+}
+
+internal data class RemoteConfigFetchPolicy(
+ val minimumFetchIntervalMillis: Long,
+ val timeoutMillis: Long? = null,
+ val initialBackoffMillis: Long = 1_000,
+ val maximumBackoffMillis: Long = 60_000,
+) {
+ init {
+ require(minimumFetchIntervalMillis >= 0)
+ require(timeoutMillis == null || timeoutMillis > 0)
+ require(initialBackoffMillis > 0)
+ require(maximumBackoffMillis >= initialBackoffMillis)
+ }
+}
+
+internal data class RemoteConfigFetchPolicyState(
+ val lastSuccessfulFetchAtMillis: Long = 0,
+ val consecutiveRetryableFailures: Int = 0,
+ val nextAllowedFetchAtMillis: Long = 0,
+)
+
+internal data class RemoteConfigFetchPolicyScope(
+ val projectKey: String,
+ val environment: String,
+) {
+ init {
+ require(projectKey.isNotEmpty())
+ require(environment.isNotEmpty())
+ }
+
+ companion object {
+ fun from(scope: RemoteConfigSnapshotScope) = RemoteConfigFetchPolicyScope(
+ projectKey = scope.projectKey,
+ environment = scope.environment,
+ )
+ }
+}
+
+internal interface RemoteConfigFetchPolicyStore {
+ fun load(scope: RemoteConfigFetchPolicyScope): RemoteConfigFetchPolicyState?
+ fun save(scope: RemoteConfigFetchPolicyScope, state: RemoteConfigFetchPolicyState): Boolean
+}
+
+internal fun interface RemoteConfigFetchClock {
+ fun nowMillis(): Long
+}
+
+internal fun interface RemoteConfigFetchRandom {
+ fun nextDouble(): Double
+}
+
+internal fun interface RemoteConfigFetchScheduledTask {
+ fun cancel()
+}
+
+internal fun interface RemoteConfigFetchScheduler {
+ fun schedule(delayMillis: Long, action: () -> Unit): RemoteConfigFetchScheduledTask
+}
+
+internal data class RemoteConfigFetchRequest(
+ val ifNoneMatch: String? = null,
+)
+
+internal sealed class RemoteConfigFetchResponse {
+ /**
+ * [projectId] is the project the transport's session was minted for — the SDK's only source for
+ * it — and is what the envelope's own project id is admitted against.
+ */
+ data class Success(
+ val body: ByteArray,
+ val etag: String,
+ val projectId: Long,
+ ) : RemoteConfigFetchResponse()
+
+ data class NotModified(val etag: String? = null) : RemoteConfigFetchResponse()
+ data class Failure(
+ val statusCode: Int? = null,
+ val retryAfterMillis: Long? = null,
+ ) : RemoteConfigFetchResponse()
+
+ /**
+ * The transport was answered for a different project than the one this installation
+ * established. Permanent until the gateway is fixed, and the refusal costs a bootstrap round
+ * trip every time, so it feeds the failure backoff: forced fetches bypass the minimum interval
+ * but NOT the backoff gate, which is what keeps an identify/logout loop from turning a
+ * misrouted gateway into a request storm.
+ */
+ data object ProjectMismatch : RemoteConfigFetchResponse()
+}
+
+internal fun interface RemoteConfigFetchTransport {
+ fun fetch(request: RemoteConfigFetchRequest, completion: (RemoteConfigFetchResponse) -> Unit)
+}
+
+internal sealed class RemoteConfigFetchResult {
+ data class Fetched(val transition: RemoteConfigSnapshotTransitionResult) : RemoteConfigFetchResult()
+ data object NotModified : RemoteConfigFetchResult()
+ data class Failed(val statusCode: Int?) : RemoteConfigFetchResult()
+ data class MinimumInterval(val nextAllowedAtMillis: Long) : RemoteConfigFetchResult()
+ data class Backoff(val nextAllowedAtMillis: Long) : RemoteConfigFetchResult()
+ data class TimedOut(val snapshot: RemoteConfigSnapshot) : RemoteConfigFetchResult()
+ data class PolicyPersistenceFailed(val result: RemoteConfigFetchResult) : RemoteConfigFetchResult()
+ data object InvalidNotModified : RemoteConfigFetchResult()
+ data object Superseded : RemoteConfigFetchResult()
+ data object ProjectMismatch : RemoteConfigFetchResult()
+}
+
+internal class RemoteConfigFetchCoordinator(
+ private val core: RemoteConfigSnapshotCore,
+ private val transport: RemoteConfigFetchTransport,
+ private val policyStore: RemoteConfigFetchPolicyStore,
+ private val clock: RemoteConfigFetchClock,
+ private val random: RemoteConfigFetchRandom,
+ private val scheduler: RemoteConfigFetchScheduler,
+ private val policy: RemoteConfigFetchPolicy,
+ private val policyPersistenceFailureObserver: (RemoteConfigFetchPolicyScope) -> Unit = {},
+) {
+ private val lock = Any()
+ private val operationLock = Any()
+ private val deliveryLock = Any()
+ private val pendingDeliveries = ArrayDeque()
+ private var isDrainingDeliveries = false
+ private var boundScope: RemoteConfigSnapshotScope? = null
+ private var operationGeneration = 0L
+ private var inFlight: InFlight? = null
+ private var policyState = RemoteConfigFetchPolicyState()
+
+ fun transitionTo(nextScope: RemoteConfigSnapshotScope?) {
+ val persistenceFailure = synchronized(operationLock) {
+ synchronized(lock) {
+ operationGeneration = nextGeneration(operationGeneration)
+ boundScope = nextScope
+ core.setScope(nextScope)
+ val loaded = nextScope?.let {
+ loadPolicyState(RemoteConfigFetchPolicyScope.from(it))
+ } ?: LoadedPolicyState(RemoteConfigFetchPolicyState())
+ policyState = loaded.state
+ val superseded = inFlight?.let { operation ->
+ claimWaitersLocked(
+ operation = operation,
+ result = RemoteConfigFetchResult.Superseded,
+ )
+ }.orEmpty()
+ inFlight = null
+ enqueueDeliveriesLocked(superseded)
+ convertQueuedDeliveriesToSupersededLocked(operationGeneration)
+ loaded.persistenceFailure
+ }
+ }
+ persistenceFailure?.let(::observePolicyPersistenceFailure)
+ drainDeliveries()
+ }
+
+ fun fetch(
+ forceReason: RemoteConfigFetchForceReason? = null,
+ callback: (RemoteConfigFetchResult) -> Unit,
+ ) {
+ val decision = synchronized(lock) { decideFetchLocked(forceReason, callback) }
+ decision.immediateResult?.let { result ->
+ enqueueImmediateResult(decision.generation, callback, result)
+ return
+ }
+ val operation = requireNotNull(decision.operation)
+ scheduleTimeout(operation, requireNotNull(decision.waiter))
+ if (decision.shouldStart) startAttempt(operation)
+ }
+
+ @Suppress("ReturnCount")
+ private fun decideFetchLocked(
+ forceReason: RemoteConfigFetchForceReason?,
+ callback: (RemoteConfigFetchResult) -> Unit,
+ ): FetchDecision {
+ inFlight?.takeIf { it.waiters.any { waiter -> !waiter.terminalClaimed } }?.let { current ->
+ return FetchDecision.joined(
+ generation = operationGeneration,
+ operation = current,
+ waiter = FetchWaiter(callback).also(current.waiters::add),
+ )
+ }
+ // A request with no live waiters continues in the transport, but a new caller owns a new
+ // admission token. This fences the zombie response without relying on HTTP cancellation.
+ inFlight = null
+ val currentScope = boundScope
+ ?: return FetchDecision.immediate(operationGeneration, RemoteConfigFetchResult.Superseded)
+ fetchGateLocked(forceReason, nowMillis())?.let { gate ->
+ return FetchDecision.immediate(operationGeneration, gate)
+ }
+ val admission = core.beginAdmission(currentScope)
+ ?: return FetchDecision.immediate(
+ operationGeneration,
+ RemoteConfigFetchResult.Failed(statusCode = null),
+ )
+ val operation = InFlight(
+ generation = operationGeneration,
+ scope = currentScope,
+ admission = admission,
+ waiters = mutableListOf(),
+ conditionalValidator = core.conditionalRequestValidator(),
+ )
+ val waiter = FetchWaiter(callback).also(operation.waiters::add)
+ inFlight = operation
+ return FetchDecision.started(operationGeneration, operation, waiter)
+ }
+
+ private fun fetchGateLocked(
+ forceReason: RemoteConfigFetchForceReason?,
+ now: Long,
+ ): RemoteConfigFetchResult? = when {
+ now < policyState.nextAllowedFetchAtMillis ->
+ RemoteConfigFetchResult.Backoff(policyState.nextAllowedFetchAtMillis)
+ shouldApplyMinimumInterval(forceReason, now) -> RemoteConfigFetchResult.MinimumInterval(
+ saturatingAdd(
+ policyState.lastSuccessfulFetchAtMillis,
+ policy.minimumFetchIntervalMillis,
+ ),
+ )
+ else -> null
+ }
+
+ private fun enqueueImmediateResult(
+ generation: Long,
+ callback: (RemoteConfigFetchResult) -> Unit,
+ result: RemoteConfigFetchResult,
+ ) {
+ synchronized(operationLock) {
+ synchronized(lock) {
+ val terminal = result.takeIf { generation == operationGeneration }
+ ?: RemoteConfigFetchResult.Superseded
+ val waiter = FetchWaiter(callback).also { it.terminalClaimed = true }
+ enqueueDeliveriesLocked(listOf(PendingDelivery(generation, waiter, terminal)))
+ }
+ }
+ drainDeliveries()
+ }
+
+ private fun startAttempt(operation: InFlight) {
+ val attemptAndRequest = synchronized(lock) {
+ if (inFlight !== operation || operation.generation != operationGeneration) return
+ ++operation.attemptOrdinal to RemoteConfigFetchRequest(
+ ifNoneMatch = operation.conditionalValidator?.etag,
+ )
+ }
+ val (attempt, request) = attemptAndRequest
+ try {
+ transport.fetch(request) { response -> complete(operation, attempt, response) }
+ } catch (_: Throwable) {
+ complete(operation, attempt, RemoteConfigFetchResponse.Failure())
+ }
+ }
+
+ @Suppress("ComplexMethod", "ReturnCount")
+ private fun complete(
+ operation: InFlight,
+ attemptOrdinal: Long,
+ response: RemoteConfigFetchResponse,
+ ) {
+ var retry = false
+ var persistenceFailure: RemoteConfigFetchPolicyScope? = null
+ synchronized(operationLock) {
+ val notModifiedDisposition = if (response is RemoteConfigFetchResponse.NotModified) {
+ notModifiedDisposition(operation, attemptOrdinal, response)
+ } else {
+ NotModifiedDisposition.NotApplicable
+ }
+ if (notModifiedDisposition == NotModifiedDisposition.Ignore) return
+ if (notModifiedDisposition == NotModifiedDisposition.Retry) {
+ retry = true
+ return@synchronized
+ }
+ val isCurrent = synchronized(lock) { operation.isCurrentLocked(attemptOrdinal) }
+ if (!isCurrent) return
+
+ val outcome = responseOutcome(operation, response, notModifiedDisposition)
+ synchronized(lock) {
+ if (!operation.isCurrentLocked(attemptOrdinal)) return
+ outcome.nextPolicyState?.let { policyState = it }
+ val persisted = outcome.nextPolicyState?.let { state ->
+ savePolicyState(operation.policyScope, state)
+ } ?: true
+ val terminalResult = if (persisted) {
+ outcome.result
+ } else {
+ persistenceFailure = operation.policyScope
+ RemoteConfigFetchResult.PolicyPersistenceFailed(outcome.result)
+ }
+ inFlight = null
+ enqueueDeliveriesLocked(claimWaitersLocked(operation, terminalResult))
+ }
+ }
+ persistenceFailure?.let(::observePolicyPersistenceFailure)
+ if (retry) startAttempt(operation) else drainDeliveries()
+ }
+
+ private fun notModifiedDisposition(
+ operation: InFlight,
+ attemptOrdinal: Long,
+ response: RemoteConfigFetchResponse.NotModified,
+ ): NotModifiedDisposition = synchronized(lock) {
+ if (!operation.isCurrentLocked(attemptOrdinal)) {
+ return@synchronized NotModifiedDisposition.Ignore
+ }
+ val validator = operation.conditionalValidator
+ val responseMatchesRequest = response.etag == null || response.etag == validator?.etag
+ if (validator != null && responseMatchesRequest &&
+ core.isConditionalRequestValidatorCurrent(validator)
+ ) {
+ return@synchronized NotModifiedDisposition.Accept
+ }
+ if (operation.didRetryWithoutETag) return@synchronized NotModifiedDisposition.Reject
+ val refreshedAdmission = core.beginAdmission(operation.scope)
+ ?: return@synchronized NotModifiedDisposition.Reject
+ operation.didRetryWithoutETag = true
+ operation.conditionalValidator = null
+ operation.admission = refreshedAdmission
+ NotModifiedDisposition.Retry
+ }
+
+ private fun responseOutcome(
+ operation: InFlight,
+ response: RemoteConfigFetchResponse,
+ notModifiedDisposition: NotModifiedDisposition,
+ ): ResponseOutcome = when (response) {
+ is RemoteConfigFetchResponse.Success -> {
+ val transition = core.admitCandidate(
+ admissionToken = operation.admission,
+ body = response.body,
+ etag = response.etag,
+ projectId = response.projectId,
+ )
+ val succeeded = transition.status == RemoteConfigSnapshotTransitionStatus.Accepted ||
+ transition.status == RemoteConfigSnapshotTransitionStatus.Activated
+ ResponseOutcome(
+ result = RemoteConfigFetchResult.Fetched(transition),
+ nextPolicyState = RemoteConfigFetchPolicyState(lastSuccessfulFetchAtMillis = nowMillis())
+ .takeIf { succeeded },
+ )
+ }
+ is RemoteConfigFetchResponse.NotModified -> if (notModifiedDisposition == NotModifiedDisposition.Accept) {
+ ResponseOutcome(
+ result = RemoteConfigFetchResult.NotModified,
+ nextPolicyState = RemoteConfigFetchPolicyState(lastSuccessfulFetchAtMillis = nowMillis()),
+ )
+ } else {
+ ResponseOutcome(RemoteConfigFetchResult.InvalidNotModified)
+ }
+ is RemoteConfigFetchResponse.Failure -> ResponseOutcome(
+ result = RemoteConfigFetchResult.Failed(response.statusCode),
+ nextPolicyState = retryableFailureState(response).takeIf { response.isRetryable() },
+ )
+ RemoteConfigFetchResponse.ProjectMismatch -> ResponseOutcome(
+ result = RemoteConfigFetchResult.ProjectMismatch,
+ nextPolicyState = retryableFailureState(RemoteConfigFetchResponse.Failure()),
+ )
+ }
+
+ private fun scheduleTimeout(operation: InFlight, waiter: FetchWaiter) {
+ val timeoutMillis = policy.timeoutMillis ?: return
+ val task = try {
+ scheduler.schedule(timeoutMillis) { timeout(operation, waiter) }
+ } catch (_: Throwable) {
+ return
+ }
+ val retained = synchronized(lock) {
+ if (isLiveWaiterLocked(operation, waiter)) {
+ waiter.timeoutTask = task
+ true
+ } else {
+ false
+ }
+ }
+ if (!retained) task.cancelSafely()
+ }
+
+ private fun timeout(operation: InFlight, waiter: FetchWaiter) {
+ synchronized(operationLock) {
+ synchronized(lock) {
+ if (!isLiveWaiterLocked(operation, waiter) || !operation.waiters.remove(waiter)) {
+ return
+ }
+ val snapshot = core.currentSnapshot()
+ waiter.terminalClaimed = true
+ enqueueDeliveriesLocked(
+ listOf(
+ PendingDelivery(
+ generation = operation.generation,
+ waiter = waiter,
+ result = RemoteConfigFetchResult.TimedOut(snapshot),
+ ),
+ ),
+ )
+ }
+ }
+ drainDeliveries()
+ }
+
+ private fun claimWaitersLocked(
+ operation: InFlight,
+ result: RemoteConfigFetchResult,
+ ): List = operation.waiters.mapNotNull { waiter ->
+ if (waiter.terminalClaimed) {
+ null
+ } else {
+ waiter.terminalClaimed = true
+ PendingDelivery(operation.generation, waiter, result)
+ }
+ }.also { operation.waiters.clear() }
+
+ private fun enqueueDeliveriesLocked(deliveries: Collection) {
+ if (deliveries.isEmpty()) return
+ synchronized(deliveryLock) { pendingDeliveries.addAll(deliveries) }
+ }
+
+ private fun convertQueuedDeliveriesToSupersededLocked(currentGeneration: Long) {
+ synchronized(deliveryLock) {
+ pendingDeliveries.forEach { delivery ->
+ if (delivery.generation != currentGeneration) {
+ delivery.result = RemoteConfigFetchResult.Superseded
+ }
+ }
+ }
+ }
+
+ private fun drainDeliveries() {
+ val ownsDrain = synchronized(deliveryLock) {
+ if (isDrainingDeliveries) {
+ false
+ } else {
+ isDrainingDeliveries = true
+ true
+ }
+ }
+ if (!ownsDrain) return
+ while (true) {
+ val delivery = synchronized(deliveryLock) {
+ pendingDeliveries.pollFirst() ?: run {
+ isDrainingDeliveries = false
+ return
+ }
+ }
+ delivery.waiter.timeoutTask?.cancelSafely()
+ try {
+ delivery.waiter.callback(delivery.result)
+ } catch (_: Throwable) {
+ // One consumer cannot undo a committed transition or starve claimed waiters.
+ }
+ }
+ }
+
+ private fun retryableFailureState(response: RemoteConfigFetchResponse.Failure): RemoteConfigFetchPolicyState {
+ val now = nowMillis()
+ val failureCount = (policyState.consecutiveRetryableFailures + 1).coerceAtMost(MAX_FAILURE_COUNT)
+ val jitterCap = exponentialBackoffCap(failureCount)
+ val randomValue = try {
+ random.nextDouble()
+ } catch (_: Throwable) {
+ SAFE_FALLBACK_JITTER
+ }
+ val jitter = randomValue.takeIf { it.isFinite() && it >= 0.0 && it < 1.0 }
+ ?: SAFE_FALLBACK_JITTER
+ val delay = response.retryAfterMillis
+ ?.takeIf { it >= 0 }
+ ?.coerceAtMost(policy.maximumBackoffMillis)
+ ?: (jitterCap.toDouble() * jitter).toLong().coerceAtLeast(MINIMUM_NONZERO_JITTER_MILLIS)
+ return policyState.copy(
+ consecutiveRetryableFailures = failureCount,
+ nextAllowedFetchAtMillis = saturatingAdd(now, delay),
+ )
+ }
+
+ private fun exponentialBackoffCap(failureCount: Int): Long {
+ var result = policy.initialBackoffMillis
+ repeat((failureCount - 1).coerceAtLeast(0)) {
+ result = if (result >= policy.maximumBackoffMillis / 2) {
+ policy.maximumBackoffMillis
+ } else {
+ (result * 2).coerceAtMost(policy.maximumBackoffMillis)
+ }
+ }
+ return result
+ }
+
+ private fun shouldApplyMinimumInterval(forceReason: RemoteConfigFetchForceReason?, now: Long): Boolean {
+ if (forceReason != null || policyState.lastSuccessfulFetchAtMillis <= 0 ||
+ now < policyState.lastSuccessfulFetchAtMillis
+ ) {
+ return false
+ }
+ return now < saturatingAdd(
+ policyState.lastSuccessfulFetchAtMillis,
+ policy.minimumFetchIntervalMillis,
+ )
+ }
+
+ @Suppress("ReturnCount")
+ private fun loadPolicyState(scope: RemoteConfigFetchPolicyScope): LoadedPolicyState {
+ val loaded = try {
+ policyStore.load(scope)
+ } catch (_: Throwable) {
+ null
+ } ?: return LoadedPolicyState(RemoteConfigFetchPolicyState())
+ val latestBoundedDeadline = saturatingAdd(nowMillis(), policy.maximumBackoffMillis)
+ if (loaded.nextAllowedFetchAtMillis <= latestBoundedDeadline) return LoadedPolicyState(loaded)
+ val sanitized = loaded.copy(nextAllowedFetchAtMillis = latestBoundedDeadline)
+ return LoadedPolicyState(
+ state = sanitized,
+ persistenceFailure = scope.takeUnless { savePolicyState(scope, sanitized) },
+ )
+ }
+
+ private fun savePolicyState(
+ scope: RemoteConfigFetchPolicyScope,
+ state: RemoteConfigFetchPolicyState,
+ ): Boolean = try {
+ policyStore.save(scope, state)
+ } catch (_: Throwable) {
+ false
+ }
+
+ private fun observePolicyPersistenceFailure(scope: RemoteConfigFetchPolicyScope) {
+ try {
+ policyPersistenceFailureObserver(scope)
+ } catch (_: Throwable) {
+ // Telemetry cannot alter the conservative in-process guard.
+ }
+ }
+
+ private fun nowMillis(): Long = try {
+ clock.nowMillis().coerceAtLeast(0)
+ } catch (_: Throwable) {
+ 0
+ }
+
+ private fun RemoteConfigFetchScheduledTask.cancelSafely() {
+ try {
+ cancel()
+ } catch (_: Throwable) {
+ // Terminal claiming is independent of best-effort timer cancellation.
+ }
+ }
+
+ private fun RemoteConfigFetchResponse.Failure.isRetryable(): Boolean =
+ statusCode == HTTP_TOO_MANY_REQUESTS || statusCode in HTTP_SERVER_ERROR_MIN..HTTP_SERVER_ERROR_MAX
+
+ private fun InFlight.isCurrentLocked(attemptOrdinal: Long): Boolean =
+ inFlight === this && generation == operationGeneration && this.attemptOrdinal == attemptOrdinal
+
+ private fun isLiveWaiterLocked(operation: InFlight, waiter: FetchWaiter): Boolean {
+ val operationIsCurrent = inFlight === operation && operation.generation == operationGeneration
+ return operationIsCurrent && !waiter.terminalClaimed && operation.waiters.contains(waiter)
+ }
+
+ private data class ResponseOutcome(
+ val result: RemoteConfigFetchResult,
+ val nextPolicyState: RemoteConfigFetchPolicyState? = null,
+ )
+
+ private data class LoadedPolicyState(
+ val state: RemoteConfigFetchPolicyState,
+ val persistenceFailure: RemoteConfigFetchPolicyScope? = null,
+ )
+
+ private data class FetchDecision(
+ val generation: Long,
+ val immediateResult: RemoteConfigFetchResult? = null,
+ val operation: InFlight? = null,
+ val waiter: FetchWaiter? = null,
+ val shouldStart: Boolean = false,
+ ) {
+ companion object {
+ fun immediate(generation: Long, result: RemoteConfigFetchResult) =
+ FetchDecision(generation = generation, immediateResult = result)
+
+ fun joined(generation: Long, operation: InFlight, waiter: FetchWaiter) = FetchDecision(
+ generation = generation,
+ operation = operation,
+ waiter = waiter,
+ )
+
+ fun started(generation: Long, operation: InFlight, waiter: FetchWaiter) = FetchDecision(
+ generation = generation,
+ operation = operation,
+ waiter = waiter,
+ shouldStart = true,
+ )
+ }
+ }
+
+ private enum class NotModifiedDisposition {
+ NotApplicable,
+ Accept,
+ Retry,
+ Reject,
+ Ignore,
+ }
+
+ private data class InFlight(
+ val generation: Long,
+ val scope: RemoteConfigSnapshotScope,
+ var admission: RemoteConfigSnapshotAdmissionToken,
+ val waiters: MutableList,
+ var conditionalValidator: RemoteConfigConditionalRequestValidator?,
+ var attemptOrdinal: Long = 0,
+ var didRetryWithoutETag: Boolean = false,
+ ) {
+ val policyScope: RemoteConfigFetchPolicyScope = RemoteConfigFetchPolicyScope.from(scope)
+ }
+
+ private class FetchWaiter(
+ val callback: (RemoteConfigFetchResult) -> Unit,
+ var timeoutTask: RemoteConfigFetchScheduledTask? = null,
+ var terminalClaimed: Boolean = false,
+ )
+
+ private data class PendingDelivery(
+ val generation: Long,
+ val waiter: FetchWaiter,
+ var result: RemoteConfigFetchResult,
+ )
+
+ private companion object {
+ const val MAX_FAILURE_COUNT = 63
+ const val HTTP_TOO_MANY_REQUESTS = 429
+ const val HTTP_SERVER_ERROR_MIN = 500
+ const val HTTP_SERVER_ERROR_MAX = 599
+ const val SAFE_FALLBACK_JITTER = 0.5
+ const val MINIMUM_NONZERO_JITTER_MILLIS = 1L
+
+ fun saturatingAdd(left: Long, right: Long): Long =
+ if (right > 0 && left > Long.MAX_VALUE - right) Long.MAX_VALUE else left + right
+
+ fun nextGeneration(current: Long): Long = if (current == Long.MAX_VALUE) 0 else current + 1
+ }
+}
diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewaySession.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewaySession.kt
new file mode 100644
index 000000000..a44fad784
--- /dev/null
+++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewaySession.kt
@@ -0,0 +1,289 @@
+package com.qonversion.android.sdk.internal.remoteconfig
+
+import com.qonversion.android.sdk.internal.storage.Cache
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+import com.squareup.moshi.Moshi
+import java.nio.ByteBuffer
+import java.security.MessageDigest
+
+private const val REMOTE_CONFIG_SESSION_PREFIX = "qonversion_remote_config_v2_session_"
+private const val REMOTE_CONFIG_PROJECT_ID_PREFIX = "qonversion_remote_config_v2_project_"
+private const val REMOTE_CONFIG_PROJECT_ID_MAX_CHARS = 32
+private const val REMOTE_CONFIG_SESSION_VERSION = 1
+private const val REMOTE_CONFIG_SESSION_MAX_BYTES = 4 * 1024
+private const val REMOTE_CONFIG_SESSION_TOKEN_MAX_BYTES = 2 * 1024
+
+/**
+ * A Remote Config v2 gateway session obtained from the bootstrap route.
+ *
+ * The session token authorises snapshot reads for exactly one identity scope. It is
+ * intentionally *not* a device-wide credential: it is stored and looked up per
+ * [RemoteConfigSnapshotScope], so an identity switch can never reuse the previous
+ * identity's token.
+ */
+internal data class RemoteConfigGatewaySession(
+ val token: String,
+ val projectId: Long,
+ val environment: String,
+ val expiresAtMillis: Long,
+) {
+ fun isUsableAt(nowMillis: Long): Boolean =
+ token.isNotEmpty() && projectId > 0 && environment.isNotEmpty() && nowMillis < expiresAtMillis
+}
+
+/**
+ * Addresses one stored session.
+ *
+ * The snapshot [scope] alone is not enough: the gateway mints a session for a specific anonymous
+ * [userUid], and the canonical user id of the scope is a separate notion that can in principle
+ * stay put while the anonymous uid is re-minted. Keying on both means a session can only ever be
+ * replayed for the exact identity it was issued to.
+ */
+internal data class RemoteConfigSessionKey(
+ val scope: RemoteConfigSnapshotScope,
+ val userUid: String,
+)
+
+internal interface RemoteConfigSessionStore {
+ fun load(key: RemoteConfigSessionKey): RemoteConfigGatewaySession?
+ fun save(key: RemoteConfigSessionKey, session: RemoteConfigGatewaySession): Boolean
+ fun clear(key: RemoteConfigSessionKey): Boolean
+}
+
+/**
+ * Durable, per-identity-scope session storage.
+ *
+ * Mirrors [PersistentRemoteConfigFetchPolicyStore]: the storage key is a salted digest of the
+ * scope, so neither the project key nor the canonical user id ever lands in a preference name,
+ * and a scope change simply addresses a different record.
+ */
+internal class PersistentRemoteConfigSessionStore(
+ private val cache: Cache,
+ moshi: Moshi,
+) : RemoteConfigSessionStore {
+ private val adapter = moshi.adapter(PersistedRemoteConfigGatewaySession::class.java)
+
+ @Synchronized
+ @Suppress("ReturnCount")
+ override fun load(key: RemoteConfigSessionKey): RemoteConfigGatewaySession? {
+ val storageKey = remoteConfigSessionStorageKey(key)
+ val raw = try {
+ cache.getString(storageKey, null)
+ } catch (_: Exception) {
+ null
+ } ?: return null
+ val persisted = try {
+ raw.takeIf { it.toByteArray(Charsets.UTF_8).size <= REMOTE_CONFIG_SESSION_MAX_BYTES }
+ ?.let(adapter::fromJson)
+ } catch (_: Exception) {
+ null
+ }
+ if (persisted == null || !persisted.isValid()) {
+ removeInvalid(storageKey)
+ return null
+ }
+ return RemoteConfigGatewaySession(
+ token = persisted.token,
+ projectId = persisted.projectId,
+ environment = persisted.environment,
+ expiresAtMillis = persisted.expiresAtMillis,
+ )
+ }
+
+ @Synchronized
+ @Suppress("ReturnCount")
+ override fun save(key: RemoteConfigSessionKey, session: RemoteConfigGatewaySession): Boolean {
+ val persisted = PersistedRemoteConfigGatewaySession(
+ version = REMOTE_CONFIG_SESSION_VERSION,
+ token = session.token,
+ projectId = session.projectId,
+ environment = session.environment,
+ expiresAtMillis = session.expiresAtMillis,
+ )
+ if (!persisted.isValid()) return false
+ val raw = try {
+ adapter.toJson(persisted)
+ } catch (_: Exception) {
+ return false
+ }
+ if (raw.toByteArray(Charsets.UTF_8).size > REMOTE_CONFIG_SESSION_MAX_BYTES) return false
+ return try {
+ cache.updateStringsDurably(
+ values = mapOf(remoteConfigSessionStorageKey(key) to raw),
+ removedKeys = emptySet(),
+ )
+ } catch (_: Exception) {
+ false
+ }
+ }
+
+ @Synchronized
+ override fun clear(key: RemoteConfigSessionKey): Boolean = try {
+ cache.updateStringsDurably(emptyMap(), setOf(remoteConfigSessionStorageKey(key)))
+ } catch (_: Exception) {
+ false
+ }
+
+ private fun PersistedRemoteConfigGatewaySession.isValid(): Boolean =
+ version == REMOTE_CONFIG_SESSION_VERSION &&
+ token.isNotEmpty() &&
+ token.toByteArray(Charsets.UTF_8).size <= REMOTE_CONFIG_SESSION_TOKEN_MAX_BYTES &&
+ projectId > 0 &&
+ environment.isNotEmpty() &&
+ expiresAtMillis > 0
+
+ private fun removeInvalid(key: String) {
+ try {
+ cache.updateStringsDurably(emptyMap(), setOf(key))
+ } catch (_: Exception) {
+ // A malformed session stays untrusted even when best-effort cleanup fails.
+ }
+ }
+}
+
+@JsonClass(generateAdapter = true)
+internal data class PersistedRemoteConfigGatewaySession(
+ val version: Int,
+ @Json(name = "session_token")
+ val token: String,
+ @Json(name = "project_id")
+ val projectId: Long,
+ val environment: String,
+ @Json(name = "expires_at_millis")
+ val expiresAtMillis: Long,
+)
+
+/**
+ * Outcome of offering a bootstrapped project id to [RemoteConfigProjectIdRegistry].
+ *
+ * There is deliberately no "re-learned" outcome: the project id is the one piece of envelope
+ * addressing that is stable for the lifetime of an installation, so a gateway answering with a
+ * different one is a routing or configuration fault, never a legitimate rotation.
+ */
+internal enum class RemoteConfigProjectIdOutcome {
+ Established,
+
+ /** The offered id disagrees with the one already established. Permanent. */
+ Conflict,
+
+ /** The offered id could never address anything. Says nothing about the established one. */
+ Unusable,
+}
+
+/** Durable storage of the project id a bootstrap established for one project key + environment. */
+internal interface RemoteConfigProjectIdStore {
+ fun load(scope: RemoteConfigSnapshotScope): Long?
+ fun save(scope: RemoteConfigSnapshotScope, projectId: Long): Boolean
+}
+
+/**
+ * Remembers the numeric project id the gateway bootstrapped, so a served snapshot can be checked
+ * against something the SDK learned rather than something the app typed.
+ *
+ * The record is keyed by project key + environment and NOT by the canonical user id: the project id
+ * addresses the app's project, not one identity inside it. Keying it per identity would both forget
+ * the pin on every login and hide the case worth catching — one identity's session being answered
+ * for a different project than another's.
+ *
+ * The first bootstrap establishes the value; every later one must agree with it. The
+ * in-memory pin is authoritative for the process even when the durable write fails, so a storage
+ * failure can never downgrade a conflict into a silent re-learn.
+ */
+internal class RemoteConfigProjectIdRegistry(private val store: RemoteConfigProjectIdStore) {
+ private val established = mutableMapOf()
+
+ @Synchronized
+ fun establish(scope: RemoteConfigSnapshotScope, projectId: Long): RemoteConfigProjectIdOutcome {
+ // Reported apart from a conflict on purpose: an id that addresses nothing is a malformed
+ // answer, not evidence that this installation is talking to the wrong project.
+ if (projectId <= 0) return RemoteConfigProjectIdOutcome.Unusable
+ val known = establishedLocked(scope)
+ return when {
+ known == projectId -> RemoteConfigProjectIdOutcome.Established
+ known != null -> RemoteConfigProjectIdOutcome.Conflict
+ else -> {
+ established[RemoteConfigProjectIdScope.from(scope)] = projectId
+ try {
+ store.save(scope, projectId)
+ } catch (_: Exception) {
+ // The in-process pin still fences this run; the next start re-establishes it.
+ }
+ RemoteConfigProjectIdOutcome.Established
+ }
+ }
+ }
+
+ private fun establishedLocked(scope: RemoteConfigSnapshotScope): Long? {
+ val key = RemoteConfigProjectIdScope.from(scope)
+ return established[key] ?: loadPersisted(scope)?.also { established[key] = it }
+ }
+
+ private fun loadPersisted(scope: RemoteConfigSnapshotScope): Long? = try {
+ store.load(scope)
+ } catch (_: Exception) {
+ null
+ }?.takeIf { it > 0 }
+}
+
+private data class RemoteConfigProjectIdScope(val projectKey: String, val environment: String) {
+ companion object {
+ fun from(scope: RemoteConfigSnapshotScope) =
+ RemoteConfigProjectIdScope(scope.projectKey, scope.environment)
+ }
+}
+
+/**
+ * Cache-backed [RemoteConfigProjectIdStore].
+ *
+ * Mirrors [PersistentRemoteConfigSessionStore]: the storage key is a salted digest, so the project
+ * key never lands in a preference name. The value is a plain decimal, and anything that does not
+ * read back as a positive number is treated as absent rather than trusted.
+ */
+internal class PersistentRemoteConfigProjectIdStore(private val cache: Cache) : RemoteConfigProjectIdStore {
+ @Synchronized
+ override fun load(scope: RemoteConfigSnapshotScope): Long? {
+ val raw = try {
+ cache.getString(remoteConfigProjectIdStorageKey(scope), null)
+ } catch (_: Exception) {
+ null
+ } ?: return null
+ return raw.takeIf { it.length <= REMOTE_CONFIG_PROJECT_ID_MAX_CHARS }?.trim()?.toLongOrNull()?.takeIf { it > 0 }
+ }
+
+ @Synchronized
+ override fun save(scope: RemoteConfigSnapshotScope, projectId: Long): Boolean {
+ if (projectId <= 0) return false
+ return try {
+ cache.updateStringsDurably(
+ values = mapOf(remoteConfigProjectIdStorageKey(scope) to projectId.toString()),
+ removedKeys = emptySet(),
+ )
+ } catch (_: Exception) {
+ false
+ }
+ }
+}
+
+private fun remoteConfigProjectIdStorageKey(scope: RemoteConfigSnapshotScope): String {
+ val digest = MessageDigest.getInstance("SHA-256")
+ digest.updateLengthPrefixed("remote-config-gateway-project-id-v1".encodeToByteArray())
+ digest.updateLengthPrefixed(scope.projectKey.encodeToByteArray())
+ digest.updateLengthPrefixed(scope.environment.encodeToByteArray())
+ return REMOTE_CONFIG_PROJECT_ID_PREFIX + digest.digest().joinToString("") { byte -> "%02x".format(byte) }
+}
+
+private fun remoteConfigSessionStorageKey(key: RemoteConfigSessionKey): String {
+ val digest = MessageDigest.getInstance("SHA-256")
+ digest.updateLengthPrefixed("remote-config-gateway-session-v1".encodeToByteArray())
+ digest.updateLengthPrefixed(key.scope.projectKey.encodeToByteArray())
+ digest.updateLengthPrefixed(key.scope.environment.encodeToByteArray())
+ digest.updateLengthPrefixed(key.scope.canonicalUserId.encodeToByteArray())
+ digest.updateLengthPrefixed(key.userUid.encodeToByteArray())
+ return REMOTE_CONFIG_SESSION_PREFIX + digest.digest().joinToString("") { byte -> "%02x".format(byte) }
+}
+
+private fun MessageDigest.updateLengthPrefixed(value: ByteArray) {
+ update(ByteBuffer.allocate(Int.SIZE_BYTES).putInt(value.size).array())
+ update(value)
+}
diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewayTransport.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewayTransport.kt
new file mode 100644
index 000000000..8c83e8fa7
--- /dev/null
+++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewayTransport.kt
@@ -0,0 +1,992 @@
+@file:OptIn(com.qonversion.android.sdk.ExperimentalQonversionApi::class)
+
+package com.qonversion.android.sdk.internal.remoteconfig
+
+import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigIdentifyAssertionProvider
+import com.qonversion.android.sdk.internal.logger.Logger
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+import com.squareup.moshi.Moshi
+import okhttp3.Call
+import okhttp3.Callback
+import okhttp3.HttpUrl
+import okhttp3.MediaType
+import okhttp3.Request
+import okhttp3.RequestBody
+import okhttp3.Response
+import okhttp3.ResponseBody
+import java.io.IOException
+import java.util.GregorianCalendar
+import java.util.TimeZone
+import java.util.concurrent.atomic.AtomicBoolean
+
+internal const val REMOTE_CONFIG_SESSION_PATH = "v3/remote-config-v2/session"
+internal const val REMOTE_CONFIG_SESSION_IDENTIFY_PATH = "v3/remote-config-v2/session/identify"
+internal const val REMOTE_CONFIG_SNAPSHOT_PATH = "v3/remote-config-v2/snapshot"
+internal const val REMOTE_CONFIG_ACK_PATH = "v3/remote-config-v2/ack"
+internal const val REMOTE_CONFIG_TELEMETRY_PATH = "v3/remote-config-v2/telemetry"
+internal const val REMOTE_CONFIG_SESSION_HEADER = "X-Qonversion-RC-Session"
+internal const val REMOTE_CONFIG_SNAPSHOT_BODY_MAX_BYTES = 8L * 1024 * 1024
+
+private const val REMOTE_CONFIG_USER_UID_MAX_BYTES = 255
+private const val REMOTE_CONFIG_IDENTIFY_ASSERTION_MAX_BYTES = 2 * 1024
+private const val REMOTE_CONFIG_SESSION_TOKEN_HEADER_MAX_BYTES = 512
+private const val REMOTE_CONFIG_CLIENT_CONTEXT_SCALAR_MAX_BYTES = 256
+private const val REMOTE_CONFIG_SESSION_EXPIRY_SKEW_MILLIS = 30_000L
+private const val MILLIS_PER_SECOND = 1_000L
+private const val HTTP_OK = 200
+private const val HTTP_SUCCESS_MIN = 200
+private const val HTTP_SUCCESS_MAX = 299
+private const val HTTP_NOT_MODIFIED = 304
+private const val HTTP_UNAUTHORIZED = 401
+private const val HTTP_TOO_MANY_REQUESTS = 429
+private const val HTTP_SERVER_ERROR_MIN = 500
+private const val HTTP_SERVER_ERROR_MAX = 599
+private const val ASCII_PRINTABLE_MIN = 0x20
+private const val ASCII_PRINTABLE_MAX = 0x7e
+
+/**
+ * Device-scoped facts the gateway needs to evaluate targeting.
+ *
+ * [deviceInstalledAtSeconds] is a DEVICE fact, not an identity fact: the server evaluates
+ * account age as `min(device_installed_at, client.created_at)`, so a fresh anonymous client row
+ * minted after a logout looks "new" and only the preserved device install date keeps a
+ * long-standing user out of "new users" targeting. Producers of this value must therefore read it
+ * from a device-scoped source that is unaffected by identify/logout — see
+ * [DeviceRemoteConfigClientContextProvider], whose constructor deliberately takes no identity.
+ */
+internal data class RemoteConfigClientContext(
+ val platform: String,
+ val appVersion: String,
+ val osVersion: String,
+ val sdkVersion: String,
+ val locale: String,
+ val deviceModel: String,
+ val deviceInstalledAtSeconds: Long,
+) {
+ internal fun isValid(): Boolean = deviceInstalledAtSeconds >= 0 &&
+ scalars().all { it.isNotEmpty() && it.isWithinScalarBudget() }
+
+ private fun scalars() = listOf(platform, appVersion, osVersion, sdkVersion, locale, deviceModel)
+
+ private fun String.isWithinScalarBudget(): Boolean =
+ toByteArray(Charsets.UTF_8).size <= REMOTE_CONFIG_CLIENT_CONTEXT_SCALAR_MAX_BYTES
+}
+
+/**
+ * Supplies the device-scoped client context for every snapshot request.
+ *
+ * Implementations MUST NOT derive [RemoteConfigClientContext.deviceInstalledAtSeconds] from
+ * anything that is reset by an identity change.
+ */
+internal fun interface RemoteConfigClientContextProvider {
+ fun clientContext(): RemoteConfigClientContext?
+}
+
+/**
+ * Everything the transport needs to address one identity: the snapshot [scope] the session is
+ * stored under, the SDK project token used as the bearer credential, and the anonymous SDK uid
+ * the bootstrap route mints a session for.
+ *
+ * [projectToken] is validated as an HTTP header value, not merely as a non-empty string: it is
+ * interpolated into `Authorization`, and OkHttp rejects a non-printable byte by throwing an
+ * `IllegalArgumentException` whose message quotes the offending value — i.e. the credential.
+ */
+internal data class RemoteConfigTransportIdentity(
+ val scope: RemoteConfigSnapshotScope,
+ val projectToken: String,
+ val userUid: String,
+ val externalUserId: String? = null,
+) {
+ internal val sessionKey: RemoteConfigSessionKey get() = RemoteConfigSessionKey(scope, userUid)
+
+ internal fun isValid(): Boolean = projectToken.isNotEmpty() &&
+ projectToken.isHttpHeaderSafe() &&
+ userUid.isNotEmpty() &&
+ userUid.toByteArray(Charsets.UTF_8).size <= REMOTE_CONFIG_USER_UID_MAX_BYTES &&
+ !userUid.contains(UNICODE_REPLACEMENT_CHARACTER)
+
+ private companion object {
+ const val UNICODE_REPLACEMENT_CHARACTER = '�'
+ }
+}
+
+internal fun interface RemoteConfigTransportIdentityProvider {
+ fun currentIdentity(): RemoteConfigTransportIdentity?
+}
+
+/**
+ * Binds [RemoteConfigFetchCoordinator]'s transport seam to the internal Remote Config v2 gateway.
+ *
+ * Responsibilities, in the order the coordinator observes them:
+ * 1. Bootstrap on a missing (or expired) session — `POST {base}/v3/remote-config-v2/session`.
+ * 2. Read the snapshot — `POST {base}/v3/remote-config-v2/snapshot` with the session header and,
+ * when the coordinator holds a conditional validator, the exact `If-None-Match` value.
+ * 3. Re-bootstrap exactly once on a snapshot `401`, then retry the snapshot once. A second `401`
+ * is a typed failure, never another bootstrap — the flow cannot loop.
+ * 4. Hand the response body to the coordinator as the EXACT bytes received, paired with the exact
+ * `ETag` header. Nothing is decoded, re-encoded or charset-converted on the way in.
+ * 5. Publish the project id the session was minted for, which is what the admission check compares
+ * the envelope against. The bootstrap is the SDK's only source for it, so it is established here
+ * (see [RemoteConfigProjectIdRegistry]) rather than configured by the app, and a session whose
+ * project id contradicts the established one is refused as
+ * [RemoteConfigFetchResponse.ProjectMismatch] instead of being used for a read.
+ *
+ * The completion is invoked exactly once on every path, including one that throws on an OkHttp
+ * dispatcher thread: the coordinator parks a waiter on it, and a lost completion would strand that
+ * waiter until its (optional) timeout.
+ *
+ * The same session seam serves the activation ack route — `POST {base}/v3/remote-config-v2/ack` —
+ * see [sendAck], and the client telemetry route — `POST {base}/v3/remote-config-v2/telemetry` —
+ * see [postTelemetry]. Both are strictly out-of-band signals: they share the session, the bootstrap
+ * and the single re-bootstrap-on-401 rule, and nothing else. Neither can admit or invalidate config
+ * data.
+ *
+ * The [callFactory] must NOT carry the legacy `NetworkInterceptor`: this transport owns its
+ * request headers (including `Authorization`) and a second interceptor-provided value would be
+ * appended rather than replaced.
+ *
+ * Neither the project token nor the session token is ever logged.
+ */
+@Suppress("LongParameterList", "TooManyFunctions")
+internal class RemoteConfigGatewayTransport(
+ private val callFactory: Call.Factory,
+ private val baseUrlProvider: () -> String,
+ private val identityProvider: RemoteConfigTransportIdentityProvider,
+ private val clientContextProvider: RemoteConfigClientContextProvider,
+ private val sessionStore: RemoteConfigSessionStore,
+ private val projectIds: RemoteConfigProjectIdRegistry,
+ private val clock: RemoteConfigFetchClock,
+ private val identifyAssertionProvider: QRemoteConfigIdentifyAssertionProvider? = null,
+ moshi: Moshi,
+ private val logger: Logger,
+ private val maxSnapshotBodyBytes: Long = REMOTE_CONFIG_SNAPSHOT_BODY_MAX_BYTES,
+) : RemoteConfigFetchTransport, RemoteConfigAckTransport, RemoteConfigTelemetryTransport {
+ private val bootstrapRequestAdapter = moshi.adapter(RemoteConfigSessionRequest::class.java)
+ private val identifyRequestAdapter = moshi.adapter(RemoteConfigIdentifiedSessionRequest::class.java)
+ private val bootstrapResponseAdapter = moshi.adapter(RemoteConfigSessionResponse::class.java)
+ private val snapshotRequestAdapter = moshi.adapter(RemoteConfigSnapshotRequest::class.java)
+ private val ackRequestAdapter = moshi.adapter(RemoteConfigActivationAckRequest::class.java)
+ private val telemetryRequestAdapter = moshi.adapter(RemoteConfigTelemetryBatchRequest::class.java)
+
+ private val lock = Any()
+ private var cachedKey: RemoteConfigSessionKey? = null
+ private var cachedSession: RemoteConfigGatewaySession? = null
+
+ override fun fetch(
+ request: RemoteConfigFetchRequest,
+ completion: (RemoteConfigFetchResponse) -> Unit,
+ ) {
+ val deliver = SingleDelivery(completion)
+ val identity = identityProvider.currentIdentity()?.takeIf { it.isValid() }
+ val context = clientContextProvider.clientContext()?.takeIf { it.isValid() }
+ if (identity == null || context == null) {
+ logger.debug("Remote Config v2 transport is not addressable yet")
+ deliver(RemoteConfigFetchResponse.Failure())
+ return
+ }
+ val session = loadUsableSession(identity.sessionKey)
+ if (session == null) {
+ // Bootstrap-on-missing-session. The snapshot that follows a fresh mint may not
+ // re-bootstrap on 401 — that is what keeps the flow finite.
+ mint(identity) { minted ->
+ when (minted) {
+ is MintResult.Minted ->
+ requestSnapshot(identity, context, minted.session, request, deliver, allowReBootstrap = false)
+ is MintResult.Refused -> deliver(minted.fetchResponse)
+ }
+ }
+ } else {
+ establishProjectId(identity, session)?.let { refusal -> deliver(refusal) }
+ ?: requestSnapshot(identity, context, session, request, deliver, allowReBootstrap = true)
+ }
+ }
+
+ /**
+ * Reports one activation out of band — `POST {base}/v3/remote-config-v2/ack`.
+ *
+ * The [scope] the ack was queued for is compared against the identity the transport currently
+ * addresses: an identity change between queueing and sending must never let one identity's
+ * session vouch for another identity's activation, so the attempt is refused as
+ * [RemoteConfigAckResponse.NotAddressable] (which costs no retry budget) rather than sent.
+ *
+ * Failure classification is deliberately coarse, because the gateway answers opaquely:
+ * `2xx` is delivered, `429`/`5xx`/transport faults are retryable, and everything else —
+ * including a `401` that survives one re-bootstrap and a `404` — is permanent.
+ */
+ override fun sendAck(
+ scope: RemoteConfigSnapshotScope,
+ ack: RemoteConfigActivationAck,
+ completion: (RemoteConfigAckResponse) -> Unit,
+ ) {
+ val deliver = SingleDelivery(completion)
+ val identity = identityProvider.currentIdentity()
+ ?.takeIf { it.isValid() && it.scope == scope }
+ if (identity == null) {
+ deliver(RemoteConfigAckResponse.NotAddressable)
+ return
+ }
+ val session = loadUsableSession(identity.sessionKey)
+ if (session == null) {
+ mint(identity) { minted ->
+ when (minted) {
+ is MintResult.Minted ->
+ postAck(identity, minted.session, ack, deliver, allowReBootstrap = false)
+ is MintResult.Refused -> deliver(minted.ackResponse)
+ }
+ }
+ } else {
+ val refusal = establishProjectId(identity, session)
+ if (refusal != null) {
+ deliver(refusal.toAckResponse())
+ } else {
+ postAck(identity, session, ack, deliver, allowReBootstrap = true)
+ }
+ }
+ }
+
+ private fun postAck(
+ identity: RemoteConfigTransportIdentity,
+ session: RemoteConfigGatewaySession,
+ ack: RemoteConfigActivationAck,
+ deliver: SingleDelivery,
+ allowReBootstrap: Boolean,
+ ) {
+ val body = try {
+ ackRequestAdapter.toJson(
+ RemoteConfigActivationAckRequest(
+ releaseNumber = ack.releaseNumber,
+ activatedAt = ack.activatedAtSeconds,
+ ),
+ )
+ } catch (_: Throwable) {
+ null
+ }
+ val httpRequest = body?.let {
+ buildRequest(REMOTE_CONFIG_ACK_PATH, identity, it) { builder ->
+ builder.header(REMOTE_CONFIG_SESSION_HEADER, session.token)
+ }
+ }
+ if (httpRequest == null) {
+ deliver(RemoteConfigAckResponse.Permanent)
+ return
+ }
+ enqueue(httpRequest, onThrow = { deliver(RemoteConfigAckResponse.Retryable) }) { outcome ->
+ onAckOutcome(identity, ack, deliver, allowReBootstrap, outcome)
+ }
+ }
+
+ @Suppress("LongParameterList")
+ private fun onAckOutcome(
+ identity: RemoteConfigTransportIdentity,
+ ack: RemoteConfigActivationAck,
+ deliver: SingleDelivery,
+ allowReBootstrap: Boolean,
+ outcome: HttpOutcome?,
+ ) {
+ when {
+ outcome == null -> deliver(RemoteConfigAckResponse.Retryable)
+ outcome.code in HTTP_SUCCESS_MIN..HTTP_SUCCESS_MAX -> deliver(RemoteConfigAckResponse.Delivered)
+ outcome.code == HTTP_UNAUTHORIZED -> {
+ // Deliberately NOT forgetSession(): the stored session is shared with the config
+ // read path, and an out-of-band signal may not invalidate it. Minting simply
+ // replaces it if it really is dead, and the read path applies its own 401 rule.
+ if (!allowReBootstrap) {
+ deliver(RemoteConfigAckResponse.Permanent)
+ return
+ }
+ mint(identity) { minted ->
+ when (minted) {
+ is MintResult.Minted ->
+ postAck(identity, minted.session, ack, deliver, allowReBootstrap = false)
+ is MintResult.Refused -> deliver(minted.ackResponse)
+ }
+ }
+ }
+ outcome.isRetryableStatus() -> deliver(RemoteConfigAckResponse.Retryable)
+ else -> deliver(RemoteConfigAckResponse.Permanent)
+ }
+ }
+
+ /**
+ * Reports one coalesced telemetry batch out of band — `POST {base}/v3/remote-config-v2/telemetry`.
+ *
+ * Structurally identical to [sendAck], and for the same reasons: the [scope] the batch was
+ * buffered for is compared against the identity the transport currently addresses, so an
+ * identity change between buffering and sending refuses the attempt as
+ * [RemoteConfigAckResponse.NotAddressable] (which costs no retry budget) instead of letting one
+ * identity's session vouch for another identity's events.
+ *
+ * An empty batch is refused as [RemoteConfigAckResponse.Permanent] rather than sent: the
+ * gateway requires 1..50 events and would answer a terminal 400.
+ *
+ * Unlike [sendAck] and [fetch], this route NEVER bootstraps a session it does not already have.
+ * Session establishment belongs to the config read path, and a diagnostic signal must not be
+ * the reason an installation contacts the gateway at all: with no session the batch is refused
+ * as [RemoteConfigAckResponse.NotAddressable], which costs no retry budget and leaves the
+ * events buffered for the first flush that follows a real fetch.
+ */
+ @Suppress("ReturnCount")
+ override fun postTelemetry(
+ scope: RemoteConfigSnapshotScope,
+ events: List,
+ completion: (RemoteConfigAckResponse) -> Unit,
+ ) {
+ val deliver = SingleDelivery(completion)
+ if (events.isEmpty() || events.size > REMOTE_CONFIG_TELEMETRY_MAX_BATCH_EVENTS) {
+ deliver(RemoteConfigAckResponse.Permanent)
+ return
+ }
+ val identity = identityProvider.currentIdentity()
+ ?.takeIf { it.isValid() && it.scope == scope }
+ if (identity == null) {
+ deliver(RemoteConfigAckResponse.NotAddressable)
+ return
+ }
+ val session = loadUsableSession(identity.sessionKey)
+ if (session == null) {
+ deliver(RemoteConfigAckResponse.NotAddressable)
+ return
+ }
+ val refusal = establishProjectId(identity, session)
+ if (refusal != null) {
+ deliver(refusal.toAckResponse())
+ } else {
+ postTelemetryBatch(identity, session, events, deliver, allowReBootstrap = true)
+ }
+ }
+
+ @Suppress("LongParameterList")
+ private fun postTelemetryBatch(
+ identity: RemoteConfigTransportIdentity,
+ session: RemoteConfigGatewaySession,
+ events: List,
+ deliver: SingleDelivery,
+ allowReBootstrap: Boolean,
+ ) {
+ val body = try {
+ telemetryRequestAdapter.toJson(RemoteConfigTelemetryBatchRequest(events.map { it.toWire() }))
+ } catch (_: Throwable) {
+ null
+ }
+ val httpRequest = body?.let {
+ buildRequest(REMOTE_CONFIG_TELEMETRY_PATH, identity, it) { builder ->
+ builder.header(REMOTE_CONFIG_SESSION_HEADER, session.token)
+ }
+ }
+ if (httpRequest == null) {
+ deliver(RemoteConfigAckResponse.Permanent)
+ return
+ }
+ enqueue(httpRequest, onThrow = { deliver(RemoteConfigAckResponse.Retryable) }) { outcome ->
+ onTelemetryOutcome(identity, events, deliver, allowReBootstrap, outcome)
+ }
+ }
+
+ @Suppress("LongParameterList")
+ private fun onTelemetryOutcome(
+ identity: RemoteConfigTransportIdentity,
+ events: List,
+ deliver: SingleDelivery,
+ allowReBootstrap: Boolean,
+ outcome: HttpOutcome?,
+ ) {
+ when {
+ outcome == null -> deliver(RemoteConfigAckResponse.Retryable)
+ outcome.code in HTTP_SUCCESS_MIN..HTTP_SUCCESS_MAX -> deliver(RemoteConfigAckResponse.Delivered)
+ outcome.code == HTTP_UNAUTHORIZED -> {
+ // Deliberately NOT forgetSession(): the stored session is shared with the config
+ // read path, and an out-of-band signal may not invalidate it. Minting simply
+ // replaces it if it really is dead, and the read path applies its own 401 rule.
+ if (!allowReBootstrap) {
+ deliver(RemoteConfigAckResponse.Permanent)
+ return
+ }
+ mint(identity) { minted ->
+ when (minted) {
+ is MintResult.Minted ->
+ postTelemetryBatch(identity, minted.session, events, deliver, allowReBootstrap = false)
+ is MintResult.Refused -> deliver(minted.ackResponse)
+ }
+ }
+ }
+ outcome.isRetryableStatus() -> deliver(RemoteConfigAckResponse.Retryable)
+ else -> deliver(RemoteConfigAckResponse.Permanent)
+ }
+ }
+
+ /**
+ * Pins the project id this session was minted for, or returns the response that refuses it.
+ *
+ * A refused session is dropped rather than merely skipped for this fetch: it addresses a
+ * project this installation has never read, so keeping it would replay the same refusal on
+ * every later fetch.
+ */
+ private fun establishProjectId(
+ identity: RemoteConfigTransportIdentity,
+ session: RemoteConfigGatewaySession,
+ ): RemoteConfigFetchResponse? {
+ val outcome = projectIds.establish(identity.scope, session.projectId)
+ if (outcome == RemoteConfigProjectIdOutcome.Established) return null
+ forgetSession(identity.sessionKey)
+ return if (outcome == RemoteConfigProjectIdOutcome.Conflict) {
+ logger.error(
+ "Remote Config v2 refused a gateway session: it was minted for a different " +
+ "project than the one this installation established",
+ )
+ RemoteConfigFetchResponse.ProjectMismatch
+ } else {
+ // A malformed answer, not an addressing fault: it stays an ordinary failure.
+ logger.debug("Remote Config v2 refused a gateway session without a usable project id")
+ RemoteConfigFetchResponse.Failure()
+ }
+ }
+
+ @Suppress("LongParameterList")
+ private fun requestSnapshot(
+ identity: RemoteConfigTransportIdentity,
+ context: RemoteConfigClientContext,
+ session: RemoteConfigGatewaySession,
+ request: RemoteConfigFetchRequest,
+ deliver: SingleDelivery,
+ allowReBootstrap: Boolean,
+ ) {
+ val body = try {
+ snapshotRequestAdapter.toJson(RemoteConfigSnapshotRequest(context.toWire()))
+ } catch (_: Throwable) {
+ null
+ }
+ val httpRequest = body?.let {
+ buildRequest(REMOTE_CONFIG_SNAPSHOT_PATH, identity, it) { builder ->
+ builder.header(REMOTE_CONFIG_SESSION_HEADER, session.token)
+ request.ifNoneMatch
+ ?.takeIf { validator -> validator.isNotEmpty() && validator.isHttpHeaderSafe() }
+ ?.let { validator -> builder.header("If-None-Match", validator) }
+ }
+ }
+ if (httpRequest == null) {
+ deliver(RemoteConfigFetchResponse.Failure())
+ return
+ }
+ enqueue(httpRequest, onThrow = { deliver(RemoteConfigFetchResponse.Failure()) }) { outcome ->
+ onSnapshotOutcome(identity, context, session, request, deliver, allowReBootstrap, outcome)
+ }
+ }
+
+ @Suppress("LongParameterList")
+ private fun onSnapshotOutcome(
+ identity: RemoteConfigTransportIdentity,
+ context: RemoteConfigClientContext,
+ session: RemoteConfigGatewaySession,
+ request: RemoteConfigFetchRequest,
+ deliver: SingleDelivery,
+ allowReBootstrap: Boolean,
+ outcome: HttpOutcome?,
+ ) {
+ when {
+ outcome == null -> deliver(RemoteConfigFetchResponse.Failure())
+ // The project id travels with the session that authorised this exact read, so the
+ // admission check compares the envelope against the session it was served for.
+ outcome.code == HTTP_OK -> deliver(outcome.asSuccessOrFailure(session.projectId))
+ outcome.code == HTTP_NOT_MODIFIED ->
+ deliver(RemoteConfigFetchResponse.NotModified(outcome.etag))
+ outcome.code == HTTP_UNAUTHORIZED -> {
+ forgetSession(identity.sessionKey)
+ if (!allowReBootstrap) {
+ logger.debug("Remote Config v2 snapshot stayed unauthorized after re-bootstrap")
+ deliver(RemoteConfigFetchResponse.Failure(statusCode = outcome.code))
+ return
+ }
+ mint(identity) { minted ->
+ when (minted) {
+ is MintResult.Minted ->
+ requestSnapshot(
+ identity,
+ context,
+ minted.session,
+ request,
+ deliver,
+ allowReBootstrap = false,
+ )
+ is MintResult.Refused -> deliver(minted.fetchResponse)
+ }
+ }
+ }
+ else -> deliver(outcome.asFailure())
+ }
+ }
+
+ /**
+ * Bootstraps a session and reports the outcome to [onResult].
+ *
+ * The refusal carries the response the *fetch* path would deliver plus the classification the
+ * *ack* path needs, so both routes share one bootstrap implementation without either of them
+ * re-deriving the other's vocabulary. [onResult] is invoked exactly once on every path.
+ */
+ private fun mint(
+ identity: RemoteConfigTransportIdentity,
+ onResult: (MintResult) -> Unit,
+ ) {
+ val externalUserId = identity.externalUserId
+ if (externalUserId == null) {
+ val body = try {
+ bootstrapRequestAdapter.toJson(RemoteConfigSessionRequest(identity.userUid))
+ } catch (_: Throwable) {
+ null
+ }
+ mintWithBody(identity, REMOTE_CONFIG_SESSION_PATH, body, onResult)
+ return
+ }
+
+ val provider = identifyAssertionProvider
+ if (provider == null) {
+ logger.debug("Remote Config v2 identified session has no assertion provider")
+ onResult(MintResult.refused(RemoteConfigFetchResponse.Failure(), RemoteConfigAckResponse.Permanent))
+ return
+ }
+ val delivered = AtomicBoolean(false)
+ try {
+ provider.requestAssertion(externalUserId) { assertion ->
+ if (!delivered.compareAndSet(false, true)) return@requestAssertion
+ val body = assertion
+ ?.takeIf { it.isValidIdentifyAssertion() }
+ ?.let { valid ->
+ try {
+ identifyRequestAdapter.toJson(RemoteConfigIdentifiedSessionRequest(valid))
+ } catch (_: Throwable) {
+ null
+ }
+ }
+ mintWithBody(identity, REMOTE_CONFIG_SESSION_IDENTIFY_PATH, body, onResult)
+ }
+ } catch (_: Throwable) {
+ if (delivered.compareAndSet(false, true)) {
+ onResult(MintResult.refused(RemoteConfigFetchResponse.Failure(), RemoteConfigAckResponse.Permanent))
+ }
+ }
+ }
+
+ private fun mintWithBody(
+ identity: RemoteConfigTransportIdentity,
+ path: String,
+ body: String?,
+ onResult: (MintResult) -> Unit,
+ ) {
+ val httpRequest = body?.let { buildRequest(path, identity, it) }
+ if (httpRequest == null) {
+ onResult(MintResult.refused(RemoteConfigFetchResponse.Failure(), RemoteConfigAckResponse.Permanent))
+ return
+ }
+ enqueue(
+ httpRequest,
+ onThrow = {
+ onResult(MintResult.refused(RemoteConfigFetchResponse.Failure(), RemoteConfigAckResponse.Retryable))
+ },
+ ) { outcome ->
+ val session = outcome
+ ?.takeIf { it.code == HTTP_OK }
+ ?.body
+ ?.let { readSession(it) }
+ if (session == null) {
+ logger.debug("Remote Config v2 session bootstrap failed with code ${outcome?.code}")
+ // A 200 that does not carry a usable session is a contract violation, not a
+ // status the fetch policy should reason about.
+ val fetchResponse = if (outcome?.code == HTTP_OK) {
+ RemoteConfigFetchResponse.Failure()
+ } else {
+ outcome.asFailure()
+ }
+ onResult(MintResult.refused(fetchResponse, outcome.asAckResponse()))
+ return@enqueue
+ }
+ // Established BEFORE the session is remembered: a session minted for a project this
+ // installation has never read must not survive the fetch that revealed the conflict.
+ establishProjectId(identity, session)?.let { refusal ->
+ onResult(MintResult.refused(refusal, refusal.toAckResponse()))
+ return@enqueue
+ }
+ rememberSession(identity.sessionKey, session)
+ onResult(MintResult.Minted(session))
+ }
+ }
+
+ private fun String.isValidIdentifyAssertion(): Boolean =
+ isNotEmpty() &&
+ toByteArray(Charsets.UTF_8).size <= REMOTE_CONFIG_IDENTIFY_ASSERTION_MAX_BYTES &&
+ all { character ->
+ character in 'A'..'Z' || character in 'a'..'z' || character in '0'..'9' ||
+ character == '-' || character == '.' || character == '_' || character == '~'
+ }
+
+ /**
+ * Builds a request, returning `null` instead of throwing. `Request.Builder.header` rejects
+ * non-printable values by throwing, and this is reached from OkHttp callback threads.
+ */
+ private fun buildRequest(
+ path: String,
+ identity: RemoteConfigTransportIdentity,
+ body: String,
+ configure: (Request.Builder) -> Unit = {},
+ ): Request? = try {
+ val url = HttpUrl.parse(baseUrlProvider())?.newBuilder()?.addPathSegments(path)?.build()
+ url?.let {
+ Request.Builder()
+ .url(it)
+ .header("Authorization", "Bearer ${identity.projectToken}")
+ .header("Content-Type", JSON_CONTENT_TYPE)
+ .header("Accept", "application/json")
+ // The gateway answers `private, no-store`; declaring it on the request as well
+ // keeps a shared OkHttp cache from ever synthesising a body the parser never saw.
+ .header("Cache-Control", "no-store")
+ .post(RequestBody.create(JSON_MEDIA_TYPE, body.toByteArray(Charsets.UTF_8)))
+ .also(configure)
+ .build()
+ }
+ } catch (_: Throwable) {
+ null
+ }
+
+ /**
+ * Every exit of this method must end in exactly one completion: the coordinator parks a waiter
+ * on the callback, so a swallowed throw on an OkHttp dispatcher thread would strand it until
+ * its timeout instead of failing fast. [onThrow] is that last-resort completion.
+ */
+ private fun enqueue(request: Request, onThrow: () -> Unit, onOutcome: (HttpOutcome?) -> Unit) {
+ fun handle(outcome: HttpOutcome?) = try {
+ onOutcome(outcome)
+ } catch (_: Throwable) {
+ try {
+ onThrow()
+ } catch (_: Throwable) {
+ // The caller's own last-resort completion is best effort by definition.
+ }
+ }
+
+ val call = try {
+ callFactory.newCall(request)
+ } catch (_: Throwable) {
+ handle(null)
+ return
+ }
+ val callback = object : Callback {
+ override fun onFailure(call: Call, e: IOException) = handle(null)
+
+ override fun onResponse(call: Call, response: Response) {
+ val outcome = try {
+ response.use { it.toOutcome() }
+ } catch (_: Throwable) {
+ null
+ }
+ handle(outcome)
+ }
+ }
+ try {
+ call.enqueue(callback)
+ } catch (_: Throwable) {
+ handle(null)
+ }
+ }
+
+ private fun Response.toOutcome(): HttpOutcome = HttpOutcome(
+ code = code(),
+ body = if (code() == HTTP_NOT_MODIFIED) null else body()?.readBounded(maxSnapshotBodyBytes),
+ etag = header("ETag"),
+ retryAfterMillis = header("Retry-After").parseRetryAfterMillis(),
+ )
+
+ /**
+ * Reads at most [max] bytes as the raw octet stream: no charset decode, no re-encode, no JSON
+ * round trip. A body over budget yields `null` rather than an unbounded allocation.
+ */
+ private fun ResponseBody.readBounded(max: Long): ByteArray? {
+ val source = source()
+ source.request(max + 1)
+ return if (source.buffer().size > max) null else source.readByteArray()
+ }
+
+ @Suppress("ReturnCount")
+ private fun loadUsableSession(key: RemoteConfigSessionKey): RemoteConfigGatewaySession? {
+ val now = nowMillis()
+ val cached = synchronized(lock) { cachedSession.takeIf { cachedKey == key } }
+ if (cached != null && cached.isUsable(now)) return cached
+ // A dead in-memory slot must not shadow the durable record, and a dead durable record must
+ // be dropped rather than re-read on every fetch.
+ val persisted = try {
+ sessionStore.load(key)
+ } catch (_: Throwable) {
+ null
+ }
+ if (persisted == null || !persisted.isUsable(now)) {
+ forgetSession(key)
+ return null
+ }
+ synchronized(lock) {
+ cachedKey = key
+ cachedSession = persisted
+ }
+ return persisted
+ }
+
+ private fun rememberSession(key: RemoteConfigSessionKey, session: RemoteConfigGatewaySession) {
+ synchronized(lock) {
+ cachedKey = key
+ cachedSession = session
+ }
+ // A session whose expiry could not be trusted is used for this fetch only: persisting it
+ // would hand a later cold start a credential we cannot reason about.
+ if (session.expiresAtMillis <= nowMillis()) return
+ try {
+ sessionStore.save(key, session)
+ } catch (_: Throwable) {
+ // The in-memory session still serves this process; the next cold start re-bootstraps.
+ }
+ }
+
+ private fun forgetSession(key: RemoteConfigSessionKey) {
+ synchronized(lock) {
+ if (cachedKey == key) {
+ cachedSession = null
+ cachedKey = null
+ }
+ }
+ try {
+ sessionStore.clear(key)
+ } catch (_: Throwable) {
+ // A stale record is re-validated (and dropped again) on the next load.
+ }
+ }
+
+ @Suppress("ReturnCount")
+ private fun readSession(body: ByteArray): RemoteConfigGatewaySession? {
+ val parsed = try {
+ bootstrapResponseAdapter.fromJson(body.toString(Charsets.UTF_8))
+ } catch (_: Throwable) {
+ null
+ } ?: return null
+ val token = parsed.sessionToken ?: return null
+ if (!token.isUsableSessionToken()) return null
+ val projectId = parsed.projectId?.takeIf { it > 0 } ?: return null
+ val environment = parsed.environment?.takeIf { it.isNotEmpty() } ?: return null
+ return RemoteConfigGatewaySession(
+ token = token,
+ projectId = projectId,
+ // The session environment ("prod") lives in a different namespace than the snapshot
+ // scope environment uid, so it is recorded rather than compared.
+ environment = environment,
+ expiresAtMillis = parsed.expiresAt.parseRfc3339Millis() ?: 0,
+ )
+ }
+
+ private fun RemoteConfigGatewaySession.isUsable(nowMillis: Long): Boolean =
+ isUsableAt(nowMillis + REMOTE_CONFIG_SESSION_EXPIRY_SKEW_MILLIS)
+
+ private fun nowMillis(): Long = try {
+ clock.nowMillis().coerceAtLeast(0)
+ } catch (_: Throwable) {
+ 0
+ }
+
+ /**
+ * `logical_key` is nullable rather than empty-by-default because the gateway requires it to be
+ * present iff the kind is `decode_failure`. Moshi omits a null field, which is exactly "absent".
+ */
+ private fun RemoteConfigTelemetryEvent.toWire() = RemoteConfigTelemetryEventWire(
+ kind = kind.wireName,
+ logicalKey = logicalKey.takeIf { kind.carriesLogicalKey && it.isNotEmpty() },
+ releaseNumber = releaseNumber,
+ count = count,
+ lastOccurredAt = lastOccurredAtSeconds,
+ )
+
+ private fun RemoteConfigClientContext.toWire() = RemoteConfigClientContextWire(
+ platform = platform,
+ appVersion = appVersion,
+ osVersion = osVersion,
+ sdkVersion = sdkVersion,
+ locale = locale,
+ deviceModel = deviceModel,
+ deviceInstalledAt = deviceInstalledAtSeconds,
+ )
+
+ private class SingleDelivery(
+ private val completion: (T) -> Unit,
+ ) : (T) -> Unit {
+ private val delivered = AtomicBoolean(false)
+
+ override fun invoke(response: T) {
+ if (delivered.compareAndSet(false, true)) completion(response)
+ }
+ }
+
+ private sealed class MintResult {
+ class Minted(val session: RemoteConfigGatewaySession) : MintResult()
+
+ class Refused(
+ val fetchResponse: RemoteConfigFetchResponse,
+ val ackResponse: RemoteConfigAckResponse,
+ ) : MintResult()
+
+ companion object {
+ fun refused(
+ fetchResponse: RemoteConfigFetchResponse,
+ ackResponse: RemoteConfigAckResponse,
+ ) = Refused(fetchResponse, ackResponse)
+ }
+ }
+
+ private class HttpOutcome(
+ val code: Int,
+ val body: ByteArray?,
+ val etag: String?,
+ val retryAfterMillis: Long?,
+ ) {
+ fun asSuccessOrFailure(projectId: Long): RemoteConfigFetchResponse {
+ val bytes = body?.takeIf { it.isNotEmpty() }
+ val validator = etag?.takeIf { it.isNotEmpty() }
+ // An empty body, an over-budget body, a 200 without a strong validator or a session
+ // carrying no usable project id cannot be admitted, and none of them is retryable.
+ return if (bytes != null && validator != null && projectId > 0) {
+ RemoteConfigFetchResponse.Success(bytes, validator, projectId)
+ } else {
+ RemoteConfigFetchResponse.Failure()
+ }
+ }
+ }
+
+ private companion object {
+ const val JSON_CONTENT_TYPE = "application/json; charset=utf-8"
+ val JSON_MEDIA_TYPE: MediaType? = MediaType.parse(JSON_CONTENT_TYPE)
+
+ fun HttpOutcome?.asFailure() = RemoteConfigFetchResponse.Failure(
+ statusCode = this?.code,
+ retryAfterMillis = this?.retryAfterMillis,
+ )
+
+ fun HttpOutcome.isRetryableStatus(): Boolean =
+ code == HTTP_TOO_MANY_REQUESTS || code in HTTP_SERVER_ERROR_MIN..HTTP_SERVER_ERROR_MAX
+
+ /** A bootstrap that could not mint a session, seen from the ack route. */
+ fun HttpOutcome?.asAckResponse(): RemoteConfigAckResponse = when {
+ // No outcome at all is a transport fault; a 200 that carried no usable session is a
+ // gateway contract violation that a later attempt may well not repeat.
+ this == null || code == HTTP_OK -> RemoteConfigAckResponse.Retryable
+ isRetryableStatus() -> RemoteConfigAckResponse.Retryable
+ else -> RemoteConfigAckResponse.Permanent
+ }
+
+ /** A session refusal, seen from the ack route. */
+ fun RemoteConfigFetchResponse.toAckResponse(): RemoteConfigAckResponse =
+ if (this is RemoteConfigFetchResponse.ProjectMismatch) {
+ // Permanent until the gateway is fixed: retrying only buys another bootstrap.
+ RemoteConfigAckResponse.Permanent
+ } else {
+ RemoteConfigAckResponse.Retryable
+ }
+ }
+}
+
+internal fun String.isHttpHeaderSafe(): Boolean =
+ all { character -> character.code in ASCII_PRINTABLE_MIN..ASCII_PRINTABLE_MAX }
+
+private fun String.isUsableSessionToken(): Boolean = isNotEmpty() &&
+ trim() == this &&
+ isHttpHeaderSafe() &&
+ toByteArray(Charsets.UTF_8).size <= REMOTE_CONFIG_SESSION_TOKEN_HEADER_MAX_BYTES
+
+private fun String?.parseRetryAfterMillis(): Long? =
+ this?.trim()?.toLongOrNull()?.takeIf { it >= 0 }?.let { seconds ->
+ if (seconds > Long.MAX_VALUE / MILLIS_PER_SECOND) Long.MAX_VALUE else seconds * MILLIS_PER_SECOND
+ }
+
+/**
+ * RFC 3339 timestamps, as a Go gateway emits them.
+ *
+ * `time.Time` marshals as RFC3339Nano with trailing zeros stripped, so the fraction is 0-9 digits
+ * wide rather than the 3 a `SimpleDateFormat` pattern can express, and RFC 3339 §5.6 allows a
+ * lowercase `t`/`z`. Both are parsed here; anything else yields `null`, which the caller treats as
+ * "expiry unknown" (usable for this fetch, never persisted).
+ */
+@Suppress("MagicNumber", "ReturnCount")
+private fun String?.parseRfc3339Millis(): Long? {
+ val match = RFC3339_PATTERN.matchEntire(this?.trim().orEmpty()) ?: return null
+ val (year, month, day, hour, minute, second, fraction, sign, offsetHour, offsetMinute) =
+ match.destructured
+ val calendar = GregorianCalendar(TimeZone.getTimeZone("UTC")).apply {
+ isLenient = false
+ clear()
+ set(year.toInt(), month.toInt() - 1, day.toInt(), hour.toInt(), minute.toInt(), second.toInt())
+ }
+ val epochMillis = try {
+ calendar.timeInMillis
+ } catch (_: IllegalArgumentException) {
+ return null
+ }
+ val fractionMillis = fraction.takeIf { it.isNotEmpty() }
+ ?.padEnd(3, '0')?.substring(0, 3)?.toLong() ?: 0
+ val offsetMillis = if (sign.isEmpty()) {
+ 0
+ } else {
+ val magnitude = (offsetHour.toLong() * 60 + offsetMinute.toLong()) * 60 * MILLIS_PER_SECOND
+ if (sign == "-") -magnitude else magnitude
+ }
+ return epochMillis + fractionMillis - offsetMillis
+}
+
+private val RFC3339_PATTERN = Regex(
+ "(\\d{4})-(\\d{2})-(\\d{2})[Tt](\\d{2}):(\\d{2}):(\\d{2})(?:\\.(\\d{1,9}))?" +
+ "(?:[Zz]|([+\\-])(\\d{2}):(\\d{2}))",
+)
+
+@JsonClass(generateAdapter = true)
+internal data class RemoteConfigSessionRequest(
+ @Json(name = "user_uid") val userUid: String,
+)
+
+@JsonClass(generateAdapter = true)
+internal data class RemoteConfigIdentifiedSessionRequest(
+ @Json(name = "assertion") val assertion: String,
+)
+
+@JsonClass(generateAdapter = true)
+internal data class RemoteConfigSessionResponse(
+ @Json(name = "session_token") val sessionToken: String?,
+ @Json(name = "project_id") val projectId: Long?,
+ @Json(name = "environment") val environment: String?,
+ @Json(name = "expires_at") val expiresAt: String?,
+)
+
+@JsonClass(generateAdapter = true)
+internal data class RemoteConfigActivationAckRequest(
+ @Json(name = "release_number") val releaseNumber: Long,
+ @Json(name = "activated_at") val activatedAt: Long,
+)
+
+@JsonClass(generateAdapter = true)
+internal data class RemoteConfigTelemetryBatchRequest(
+ @Json(name = "events") val events: List,
+)
+
+@JsonClass(generateAdapter = true)
+internal data class RemoteConfigTelemetryEventWire(
+ @Json(name = "kind") val kind: String,
+ @Json(name = "logical_key") val logicalKey: String?,
+ @Json(name = "release_number") val releaseNumber: Long,
+ @Json(name = "count") val count: Long,
+ @Json(name = "last_occurred_at") val lastOccurredAt: Long,
+)
+
+@JsonClass(generateAdapter = true)
+internal data class RemoteConfigSnapshotRequest(
+ @Json(name = "client_context") val clientContext: RemoteConfigClientContextWire,
+)
+
+@JsonClass(generateAdapter = true)
+internal data class RemoteConfigClientContextWire(
+ @Json(name = "platform") val platform: String,
+ @Json(name = "app_version") val appVersion: String,
+ @Json(name = "os_version") val osVersion: String,
+ @Json(name = "sdk_version") val sdkVersion: String,
+ @Json(name = "locale") val locale: String,
+ @Json(name = "device_model") val deviceModel: String,
+ @Json(name = "device_installed_at") val deviceInstalledAt: Long,
+)
diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigIdentityBridge.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigIdentityBridge.kt
new file mode 100644
index 000000000..1379cc2ff
--- /dev/null
+++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigIdentityBridge.kt
@@ -0,0 +1,36 @@
+package com.qonversion.android.sdk.internal.remoteconfig
+
+/**
+ * The two moments the v1 Remote Config pipeline owns and the v2 pipeline must observe.
+ *
+ * It exists as its own object rather than as fields on `QRemoteConfigManager` so the optional v2
+ * subsystem adds one collaborator to that class instead of more surface, and so the "an optional
+ * subsystem can never break the v1 transition" rule is written once, here.
+ */
+internal class RemoteConfigIdentityBridge {
+
+ /**
+ * The canonical uid changed (logout, or an identify that minted a new one). The v2 scope must
+ * switch immediately, dropping the previous identity's release.
+ */
+ var onIdentityScopeChanged: ((externalUserId: String?) -> Unit)? = null
+
+ /**
+ * The targeting inputs changed while the identity stayed the same — an identify that only
+ * attached an external id, a user-property batch, an experiment attach/detach, or an explicit
+ * cache invalidation. The v2 pipeline must re-read targeting but keep serving its release.
+ */
+ var onTargetingInvalidated: ((externalUserId: String?) -> Unit)? = null
+
+ fun identityScopeChanged(externalUserId: String? = null) = notifyIdentity(onIdentityScopeChanged, externalUserId)
+
+ fun targetingInvalidated(externalUserId: String? = null) = notifyIdentity(onTargetingInvalidated, externalUserId)
+
+ private fun notifyIdentity(observer: ((String?) -> Unit)?, externalUserId: String?) {
+ try {
+ observer?.invoke(externalUserId)
+ } catch (@Suppress("TooGenericExceptionCaught", "SwallowedException") _: RuntimeException) {
+ // An optional subsystem can never break the v1 identity transition or invalidation.
+ }
+ }
+}
diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigReadGuard.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigReadGuard.kt
new file mode 100644
index 000000000..da44948e4
--- /dev/null
+++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigReadGuard.kt
@@ -0,0 +1,396 @@
+package com.qonversion.android.sdk.internal.remoteconfig
+
+import com.qonversion.android.sdk.internal.storage.RemoteConfigSnapshotLoadResult
+import com.qonversion.android.sdk.internal.storage.RemoteConfigSnapshotLoadStatus
+import com.qonversion.android.sdk.internal.storage.RemoteConfigSnapshotStore
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.Executor
+import java.util.concurrent.atomic.AtomicBoolean
+
+internal const val REMOTE_CONFIG_READ_BEFORE_ACTIVATE_MESSAGE =
+ "Remote Config was read before activate(). Call activate() after SDK initialization and before reading current values."
+
+internal enum class RemoteConfigReadBuildMode {
+ Debug,
+ Release,
+}
+
+internal fun interface RemoteConfigReadAssertion {
+ fun fail(message: String)
+}
+
+internal enum class RemoteConfigReadGuardEvent {
+ ReadBeforeActivate,
+ ImplicitActivation,
+ PreloadNotReady,
+ PreloadFailed,
+ PreloadCorrupt,
+ ActivationPersistenceFailed,
+}
+
+internal fun interface RemoteConfigReadTelemetry {
+ fun report(event: RemoteConfigReadGuardEvent)
+}
+
+internal enum class RemoteConfigReadPreloadStatus {
+ Ready,
+ Failed,
+ Corrupt,
+ PersistenceFailed,
+}
+
+internal data class RemoteConfigReadPreloadResult(
+ val status: RemoteConfigReadPreloadStatus,
+ val baseState: RemoteConfigSnapshotState? = null,
+ val preparedActivationState: RemoteConfigSnapshotState? = null,
+) {
+ init {
+ when (status) {
+ RemoteConfigReadPreloadStatus.Ready -> require(baseState != null)
+ RemoteConfigReadPreloadStatus.PersistenceFailed -> {
+ require(baseState != null && preparedActivationState == null)
+ }
+ RemoteConfigReadPreloadStatus.Failed,
+ RemoteConfigReadPreloadStatus.Corrupt,
+ -> require(baseState == null && preparedActivationState == null)
+ }
+ }
+}
+
+internal interface RemoteConfigReadPreloader {
+ fun preload(
+ scope: RemoteConfigSnapshotScope,
+ prepareImplicitActivation: Boolean,
+ completion: (RemoteConfigReadPreloadResult) -> Unit,
+ )
+}
+
+internal class PersistentRemoteConfigReadPreloader(
+ private val store: RemoteConfigSnapshotStore,
+ private val executor: Executor,
+) : RemoteConfigReadPreloader {
+ override fun preload(
+ scope: RemoteConfigSnapshotScope,
+ prepareImplicitActivation: Boolean,
+ completion: (RemoteConfigReadPreloadResult) -> Unit,
+ ) {
+ val delivered = AtomicBoolean(false)
+ fun deliver(result: RemoteConfigReadPreloadResult) {
+ if (!delivered.compareAndSet(false, true)) return
+ try {
+ completion(result)
+ } catch (_: Exception) {
+ // The preload is terminal even if its internal lifecycle callback throws.
+ }
+ }
+ try {
+ executor.execute {
+ deliver(load(scope, prepareImplicitActivation))
+ }
+ } catch (_: Exception) {
+ deliver(RemoteConfigReadPreloadResult(RemoteConfigReadPreloadStatus.Failed))
+ }
+ }
+
+ @Suppress("ReturnCount")
+ private fun load(
+ scope: RemoteConfigSnapshotScope,
+ prepareImplicitActivation: Boolean,
+ ): RemoteConfigReadPreloadResult {
+ val loaded = try {
+ store.load(scope)
+ } catch (_: Exception) {
+ RemoteConfigSnapshotLoadResult(RemoteConfigSnapshotLoadStatus.Failed)
+ }
+ val baseState = when (loaded.status) {
+ RemoteConfigSnapshotLoadStatus.Found -> requireNotNull(loaded.state)
+ RemoteConfigSnapshotLoadStatus.Missing -> RemoteConfigSnapshotState()
+ RemoteConfigSnapshotLoadStatus.Failed -> {
+ return RemoteConfigReadPreloadResult(RemoteConfigReadPreloadStatus.Failed)
+ }
+ RemoteConfigSnapshotLoadStatus.Corrupt -> {
+ return RemoteConfigReadPreloadResult(RemoteConfigReadPreloadStatus.Corrupt)
+ }
+ }
+ if (!prepareImplicitActivation) {
+ return RemoteConfigReadPreloadResult(
+ status = RemoteConfigReadPreloadStatus.Ready,
+ baseState = baseState,
+ )
+ }
+ val preparedState = baseState.preparedActivationState()
+ return RemoteConfigReadPreloadResult(
+ status = RemoteConfigReadPreloadStatus.Ready,
+ baseState = baseState,
+ preparedActivationState = preparedState,
+ )
+ }
+}
+
+@Suppress("ReturnCount")
+internal fun RemoteConfigSnapshotState.preparedActivationState(): RemoteConfigSnapshotState {
+ val nextCandidate = candidate
+ if (nextCandidate == null) return if (didActivate) this else copy(didActivate = true)
+ if (didActivate && active?.admissionToken == nextCandidate.admissionToken) return this
+ return RemoteConfigSnapshotState(
+ candidate = nextCandidate,
+ active = nextCandidate,
+ previous = active,
+ didActivate = true,
+ latestAdmissionToken = latestAdmissionToken,
+ )
+}
+
+internal class RemoteConfigReadGuard(
+ private val core: RemoteConfigSnapshotCore,
+ private val preloader: RemoteConfigReadPreloader,
+ private val buildMode: RemoteConfigReadBuildMode,
+ private val assertion: RemoteConfigReadAssertion,
+ private val telemetry: RemoteConfigReadTelemetry,
+) {
+ private val lock = Any()
+ private var preloadToken: RemoteConfigScopePreloadToken? = null
+ private var preloadResult: RemoteConfigReadPreloadResult? = null
+ private var firstReadHandled = false
+ private var didConsumeImplicitActivation = false
+ private var firstReadCommitBarrier: CountDownLatch? = null
+ private var firstReadCommitOwner: Thread? = null
+ private val reportedEvents = mutableSetOf()
+
+ fun transitionScopeBeforeSdkReady(
+ scope: RemoteConfigSnapshotScope?,
+ onReady: () -> Unit = {},
+ ) {
+ val prepareImplicitActivation = synchronized(lock) {
+ buildMode == RemoteConfigReadBuildMode.Release && !didConsumeImplicitActivation
+ }
+ val token = core.beginScopePreload(
+ scope = scope,
+ armFirstReadActivation = prepareImplicitActivation,
+ ) { boundToken ->
+ synchronized(lock) {
+ firstReadHandled = false
+ preloadResult = null
+ reportedEvents.clear()
+ preloadToken = boundToken
+ }
+ }
+ if (scope == null || token == null) {
+ safelyInvoke(onReady)
+ return
+ }
+ preloader.preload(
+ scope = scope,
+ prepareImplicitActivation = prepareImplicitActivation,
+ ) { result ->
+ completePreload(token, result, onReady)
+ }
+ }
+
+ fun currentSnapshot(): RemoteConfigSnapshot {
+ val events = mutableListOf()
+ var decision = FirstReadDecision()
+ var waitForCommit: CountDownLatch?
+ do {
+ waitForCommit = null
+ synchronized(lock) {
+ val currentBarrier = firstReadCommitBarrier
+ if (currentBarrier != null && firstReadCommitOwner !== Thread.currentThread()) {
+ waitForCommit = currentBarrier
+ } else {
+ decision = claimFirstReadLocked(events)
+ }
+ }
+ waitForCommit?.awaitUninterruptibly()
+ } while (waitForCommit != null)
+ val claimedToken = decision.claimedToken
+ if (claimedToken != null) {
+ val barrier = requireNotNull(decision.claimedBarrier)
+ val transition = try {
+ core.commitPrepersistedActivationWithoutDelivery(claimedToken)
+ } finally {
+ completeFirstReadCommit(barrier)
+ }
+ core.deliverPendingUpdates()
+ if (transition.status == RemoteConfigSnapshotTransitionStatus.Activated) {
+ events += RemoteConfigReadGuardEvent.ImplicitActivation
+ }
+ }
+ val snapshot = core.currentSnapshot()
+ report(events)
+ decision.assertionMessage?.let { message -> assertion.fail(message) }
+ return snapshot
+ }
+
+ private data class FirstReadDecision(
+ val assertionMessage: String? = null,
+ val claimedToken: RemoteConfigScopePreloadToken? = null,
+ val claimedBarrier: CountDownLatch? = null,
+ )
+
+ private fun claimFirstReadLocked(
+ events: MutableList,
+ ): FirstReadDecision = if (firstReadHandled) {
+ FirstReadDecision()
+ } else {
+ firstReadHandled = true
+ claimEvent(RemoteConfigReadGuardEvent.ReadBeforeActivate, events)
+ when {
+ buildMode == RemoteConfigReadBuildMode.Debug -> {
+ FirstReadDecision(assertionMessage = REMOTE_CONFIG_READ_BEFORE_ACTIVATE_MESSAGE)
+ }
+ didConsumeImplicitActivation -> FirstReadDecision()
+ else -> claimImplicitActivationLocked(events)
+ }
+ }
+
+ private fun claimImplicitActivationLocked(
+ events: MutableList,
+ ): FirstReadDecision {
+ val token = preloadToken
+ return when (core.claimImplicitActivationOpportunity(token)) {
+ RemoteConfigImplicitActivationClaimStatus.Stale -> FirstReadDecision()
+ RemoteConfigImplicitActivationClaimStatus.AlreadyConsumed -> {
+ didConsumeImplicitActivation = true
+ FirstReadDecision()
+ }
+ RemoteConfigImplicitActivationClaimStatus.Claimed -> {
+ didConsumeImplicitActivation = true
+ if (preloadResult == null) {
+ claimEvent(RemoteConfigReadGuardEvent.PreloadNotReady, events)
+ }
+ val barrier = CountDownLatch(1)
+ firstReadCommitBarrier = barrier
+ firstReadCommitOwner = Thread.currentThread()
+ FirstReadDecision(
+ claimedToken = requireNotNull(token),
+ claimedBarrier = barrier,
+ )
+ }
+ }
+ }
+
+ fun activate(): RemoteConfigSnapshotTransitionResult {
+ val token = synchronized(lock) {
+ firstReadHandled = true
+ preloadToken
+ }
+ when (core.claimImplicitActivationOpportunity(token)) {
+ RemoteConfigImplicitActivationClaimStatus.Stale -> Unit
+ RemoteConfigImplicitActivationClaimStatus.AlreadyConsumed,
+ RemoteConfigImplicitActivationClaimStatus.Claimed,
+ -> synchronized(lock) { didConsumeImplicitActivation = true }
+ }
+ if (token != null) {
+ val prepared = core.commitPrepersistedActivation(token)
+ if (prepared.status != RemoteConfigSnapshotTransitionStatus.Ignored) return prepared
+ }
+ return core.activateForPreloadToken(token)
+ }
+
+ private fun completePreload(
+ token: RemoteConfigScopePreloadToken,
+ result: RemoteConfigReadPreloadResult,
+ onReady: () -> Unit,
+ ) {
+ val events = mutableListOf()
+ val isCurrent = synchronized(lock) {
+ if (preloadToken !== token) return@synchronized false
+ if (!firstReadHandled) {
+ var effectiveResult = result
+ when (result.status) {
+ RemoteConfigReadPreloadStatus.Ready,
+ RemoteConfigReadPreloadStatus.PersistenceFailed,
+ -> {
+ val baseState = requireNotNull(result.baseState)
+ when (
+ core.installPreloadedScope(
+ token,
+ baseState,
+ result.preparedActivationState,
+ )
+ ) {
+ RemoteConfigScopePreloadInstallStatus.Ignored -> return@synchronized false
+ RemoteConfigScopePreloadInstallStatus.PersistenceFailed -> {
+ effectiveResult = RemoteConfigReadPreloadResult(
+ status = RemoteConfigReadPreloadStatus.PersistenceFailed,
+ baseState = baseState,
+ )
+ }
+ RemoteConfigScopePreloadInstallStatus.Superseded -> Unit
+ RemoteConfigScopePreloadInstallStatus.Installed -> Unit
+ }
+ }
+ RemoteConfigReadPreloadStatus.Failed,
+ RemoteConfigReadPreloadStatus.Corrupt,
+ -> Unit
+ }
+ preloadResult = effectiveResult
+ when (effectiveResult.status) {
+ RemoteConfigReadPreloadStatus.Failed -> {
+ claimEvent(RemoteConfigReadGuardEvent.PreloadFailed, events)
+ }
+ RemoteConfigReadPreloadStatus.Corrupt -> {
+ claimEvent(RemoteConfigReadGuardEvent.PreloadCorrupt, events)
+ }
+ RemoteConfigReadPreloadStatus.PersistenceFailed -> {
+ claimEvent(RemoteConfigReadGuardEvent.ActivationPersistenceFailed, events)
+ }
+ RemoteConfigReadPreloadStatus.Ready -> Unit
+ }
+ }
+ true
+ }
+ if (!isCurrent) return
+ report(events)
+ safelyInvoke(onReady)
+ }
+
+ private fun claimEvent(
+ event: RemoteConfigReadGuardEvent,
+ claimed: MutableList,
+ ) {
+ if (reportedEvents.add(event)) claimed += event
+ }
+
+ private fun report(events: List) {
+ events.forEach { event ->
+ try {
+ telemetry.report(event)
+ } catch (_: Exception) {
+ // Telemetry can never affect serving state.
+ }
+ }
+ }
+
+ private fun safelyInvoke(callback: () -> Unit) {
+ try {
+ callback()
+ } catch (_: Exception) {
+ // SDK readiness is terminal even if an internal lifecycle callback throws.
+ }
+ }
+
+ private fun completeFirstReadCommit(barrier: CountDownLatch) {
+ synchronized(lock) {
+ if (firstReadCommitBarrier === barrier) {
+ firstReadCommitBarrier = null
+ firstReadCommitOwner = null
+ }
+ }
+ barrier.countDown()
+ }
+
+ private fun CountDownLatch.awaitUninterruptibly() {
+ var interrupted = false
+ while (true) {
+ try {
+ await()
+ break
+ } catch (_: InterruptedException) {
+ interrupted = true
+ }
+ }
+ if (interrupted) Thread.currentThread().interrupt()
+ }
+}
diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshot.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshot.kt
new file mode 100644
index 000000000..0e2a0614a
--- /dev/null
+++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshot.kt
@@ -0,0 +1,471 @@
+package com.qonversion.android.sdk.internal.remoteconfig
+
+import com.qonversion.android.sdk.internal.services.BUNDLED_REMOTE_CONFIG_DEFAULT_VALUE_MAX_BYTES
+import com.qonversion.android.sdk.internal.services.BundledRemoteConfigDefaultsDocument
+import com.qonversion.android.sdk.internal.services.isPortableRemoteConfigJson
+import java.nio.ByteBuffer
+import java.security.MessageDigest
+import java.util.Collections
+
+private const val REMOTE_CONFIG_METADATA_MAX_BYTES = 4 * 1024
+private const val REMOTE_CONFIG_MAX_KEYS = 1_000
+private const val REMOTE_CONFIG_MAX_RELEASE_BYTES = 4 * 1024 * 1024
+private const val REMOTE_CONFIG_LOGICAL_KEY_MAX_BYTES = 256
+private const val REMOTE_CONFIG_SCOPE_IDENTIFIER_MAX_BYTES = 256
+private const val REMOTE_CONFIG_ENVIRONMENT_MAX_CODE_POINTS = 36
+private const val REMOTE_CONFIG_UID_MAX_CODE_POINTS = 36
+private const val PORTABLE_JSON_MAX_INTEGER = 9_007_199_254_740_991L
+private val LOWERCASE_SHA256_PATTERN = Regex("^[0-9a-f]{64}$")
+
+internal enum class RemoteConfigSnapshotApplyPolicy {
+ OnNextActivate,
+ Immediate,
+}
+
+internal enum class RemoteConfigSnapshotValueSource {
+ Server,
+ Cache,
+ Fallback,
+}
+
+internal data class RemoteConfigSnapshotScope(
+ val projectKey: String,
+ val environment: String,
+ val canonicalUserId: String,
+) {
+ init {
+ require(
+ projectKey.isNotEmpty() && projectKey.hasValidSurrogatePairs() &&
+ projectKey.toByteArray(Charsets.UTF_8).size <= REMOTE_CONFIG_SCOPE_IDENTIFIER_MAX_BYTES,
+ )
+ require(
+ environment.isNotEmpty() && environment.hasValidSurrogatePairs() &&
+ environment.codePointCount(0, environment.length) <= REMOTE_CONFIG_ENVIRONMENT_MAX_CODE_POINTS,
+ )
+ require(
+ canonicalUserId.isNotEmpty() && canonicalUserId.hasValidSurrogatePairs() &&
+ canonicalUserId.toByteArray(Charsets.UTF_8).size <= REMOTE_CONFIG_SCOPE_IDENTIFIER_MAX_BYTES,
+ )
+ }
+}
+
+internal class RemoteConfigSnapshotEntry private constructor(
+ val key: String,
+ rawValue: ByteArray?,
+ val variationUid: String?,
+ val applyPolicy: RemoteConfigSnapshotApplyPolicy,
+ metadata: ByteArray?,
+) {
+ private val storedRawValue = rawValue?.clone()
+ private val storedMetadata = metadata?.clone()
+
+ val isTombstone: Boolean get() = storedRawValue == null
+ val rawValueBytes: ByteArray? get() = storedRawValue?.clone()
+ val metadataBytes: ByteArray? get() = storedMetadata?.clone()
+ internal val budgetBytes: Long
+ get() = key.toByteArray().size.toLong() +
+ (variationUid?.toByteArray()?.size ?: 0) +
+ (storedRawValue?.size ?: 0) +
+ (storedMetadata?.size ?: 0)
+
+ init {
+ require(
+ key.isNotEmpty() && key.hasValidSurrogatePairs() &&
+ key.toByteArray().size <= REMOTE_CONFIG_LOGICAL_KEY_MAX_BYTES,
+ )
+ if (storedRawValue == null) {
+ require(variationUid == null)
+ require(storedMetadata == null)
+ } else {
+ require(
+ !variationUid.isNullOrEmpty() && variationUid.hasValidUidLength() &&
+ variationUid.hasValidSurrogatePairs(),
+ )
+ require(storedRawValue.size <= BUNDLED_REMOTE_CONFIG_DEFAULT_VALUE_MAX_BYTES)
+ require(isPortableRemoteConfigJson(storedRawValue))
+ require(storedMetadata == null || (
+ storedMetadata.size <= REMOTE_CONFIG_METADATA_MAX_BYTES &&
+ isPortableRemoteConfigJson(storedMetadata)
+ ))
+ }
+ }
+
+ internal fun contentEquals(other: RemoteConfigSnapshotEntry?): Boolean =
+ other != null &&
+ key == other.key &&
+ storedRawValue.contentEqualsNullable(other.storedRawValue) &&
+ variationUid == other.variationUid &&
+ applyPolicy == other.applyPolicy &&
+ storedMetadata.contentEqualsNullable(other.storedMetadata)
+
+ companion object {
+ fun value(
+ key: String,
+ rawValue: ByteArray,
+ variationUid: String,
+ applyPolicy: RemoteConfigSnapshotApplyPolicy,
+ metadata: ByteArray?,
+ ) = RemoteConfigSnapshotEntry(key, rawValue, variationUid, applyPolicy, metadata)
+
+ fun tombstone(key: String) = RemoteConfigSnapshotEntry(
+ key = key,
+ rawValue = null,
+ variationUid = null,
+ applyPolicy = RemoteConfigSnapshotApplyPolicy.OnNextActivate,
+ metadata = null,
+ )
+ }
+}
+
+/**
+ * One admitted Remote Config release.
+ *
+ * [contextFingerprint] is **informational**: it records which targeting context the gateway resolved
+ * this response for, which is useful in a bug report or a log line. It is not an admission input.
+ * The fingerprint hashes mutable targeting context (app/OS version, locale, purchases, properties);
+ * it rotates legitimately and MUST NOT be pinned across fetches. Identity isolation is the session's
+ * job.
+ */
+internal class RemoteConfigSnapshotRelease(
+ val releaseUid: String,
+ val releaseNumber: Long,
+ val manifestContentHash: String,
+ entries: Collection,
+ canonicalBody: ByteArray? = null,
+ val strongETag: String? = null,
+ val contextFingerprint: String? = null,
+ val admissionToken: Long = 0,
+) {
+ private val entriesByKey: Map
+ private val storedCanonicalBody = canonicalBody?.clone()
+
+ val entries: Map get() = entriesByKey
+ val canonicalBodyBytes: ByteArray? get() = storedCanonicalBody?.clone()
+ internal val hasCanonicalBody: Boolean get() = storedCanonicalBody != null
+ val bodyDigest: String? get() = strongETag?.removeSurrounding("\"")
+ internal val contentDigest: String by lazy(LazyThreadSafetyMode.PUBLICATION) {
+ calculateContentDigest()
+ }
+ val containsImmediateEntry: Boolean
+ get() = entriesByKey.values.any { it.applyPolicy == RemoteConfigSnapshotApplyPolicy.Immediate }
+
+ init {
+ require(releaseUid.isNotEmpty() && releaseUid.hasValidUidLength() && releaseUid.hasValidSurrogatePairs())
+ require(releaseNumber in 1..PORTABLE_JSON_MAX_INTEGER)
+ require(LOWERCASE_SHA256_PATTERN.matches(manifestContentHash))
+ require(contextFingerprint == null || LOWERCASE_SHA256_PATTERN.matches(contextFingerprint))
+ require(admissionToken >= 0)
+ require(entries.count { !it.isTombstone } <= REMOTE_CONFIG_MAX_KEYS)
+ require(entries.count(RemoteConfigSnapshotEntry::isTombstone) <= REMOTE_CONFIG_MAX_KEYS)
+ require(entries.map { it.key }.distinct().size == entries.size)
+ require((storedCanonicalBody == null) == (strongETag == null))
+ if (storedCanonicalBody != null) {
+ require(storedCanonicalBody.size <= REMOTE_CONFIG_SNAPSHOT_ENVELOPE_MAX_BYTES)
+ require(remoteConfigStrongETagDigest(storedCanonicalBody, requireNotNull(strongETag)) != null)
+ }
+ val aggregateBytes = releaseUid.toByteArray().size.toLong() + manifestContentHash.length +
+ entries.sumOf(RemoteConfigSnapshotEntry::budgetBytes)
+ require(aggregateBytes <= REMOTE_CONFIG_MAX_RELEASE_BYTES)
+ entriesByKey = Collections.unmodifiableMap(entries.associateBy { it.key })
+ }
+
+ fun entry(key: String): RemoteConfigSnapshotEntry? = entriesByKey[key]
+
+ internal fun contentEquals(other: RemoteConfigSnapshotRelease?): Boolean =
+ other != null && releaseUid == other.releaseUid && releaseNumber == other.releaseNumber &&
+ manifestContentHash == other.manifestContentHash && contextFingerprint == other.contextFingerprint &&
+ entriesByKey.size == other.entriesByKey.size &&
+ entriesByKey.all { (key, entry) -> entry.contentEquals(other.entriesByKey[key]) }
+
+ internal fun withAdmissionToken(token: Long): RemoteConfigSnapshotRelease = RemoteConfigSnapshotRelease(
+ releaseUid = releaseUid,
+ releaseNumber = releaseNumber,
+ manifestContentHash = manifestContentHash,
+ entries = entriesByKey.values,
+ canonicalBody = canonicalBodyBytes,
+ strongETag = strongETag,
+ contextFingerprint = contextFingerprint,
+ admissionToken = token,
+ )
+
+ private fun calculateContentDigest(): String {
+ val digest = MessageDigest.getInstance("SHA-256")
+ digest.updateLengthPrefixed("remote-config-snapshot-release-v1".encodeToByteArray())
+ digest.update(ByteBuffer.allocate(Long.SIZE_BYTES).putLong(admissionToken).array())
+ digest.updateLengthPrefixed(releaseUid.encodeToByteArray())
+ digest.update(ByteBuffer.allocate(Long.SIZE_BYTES).putLong(releaseNumber).array())
+ digest.updateLengthPrefixed(manifestContentHash.encodeToByteArray())
+ digest.updateNullable(contextFingerprint?.encodeToByteArray())
+ entriesByKey.toSortedMap().values.forEach { entry ->
+ digest.updateLengthPrefixed(entry.key.encodeToByteArray())
+ digest.update(if (entry.isTombstone) TOMBSTONE_MARKER else VALUE_MARKER)
+ digest.updateNullable(entry.rawValueBytes)
+ digest.updateNullable(entry.variationUid?.encodeToByteArray())
+ digest.update(
+ when (entry.applyPolicy) {
+ RemoteConfigSnapshotApplyPolicy.OnNextActivate -> ON_NEXT_ACTIVATE_MARKER
+ RemoteConfigSnapshotApplyPolicy.Immediate -> IMMEDIATE_MARKER
+ },
+ )
+ digest.updateNullable(entry.metadataBytes)
+ }
+ return digest.digest().toLowercaseHex()
+ }
+}
+
+internal class RemoteConfigScopedBundledRelease(
+ val projectKey: String,
+ val environment: String,
+ val release: RemoteConfigSnapshotRelease,
+) {
+ init {
+ RemoteConfigSnapshotScope(projectKey, environment, "bundle-scope-validation")
+ }
+
+ fun releaseFor(scope: RemoteConfigSnapshotScope?): RemoteConfigSnapshotRelease? = when {
+ scope == null -> release
+ scope.projectKey == projectKey && scope.environment == environment -> release
+ else -> null
+ }
+}
+
+internal data class RemoteConfigSnapshotState(
+ val candidate: RemoteConfigSnapshotRelease? = null,
+ val active: RemoteConfigSnapshotRelease? = null,
+ val previous: RemoteConfigSnapshotRelease? = null,
+ val didActivate: Boolean = false,
+ val latestAdmissionToken: Long = maxOf(
+ candidate?.admissionToken ?: 0,
+ active?.admissionToken ?: 0,
+ previous?.admissionToken ?: 0,
+ ),
+) {
+ init {
+ val highestSlotToken = maxOf(
+ candidate?.admissionToken ?: 0,
+ active?.admissionToken ?: 0,
+ previous?.admissionToken ?: 0,
+ )
+ require(active != null || previous == null)
+ require(didActivate || (active == null && previous == null))
+ require(latestAdmissionToken >= highestSlotToken)
+ require(candidate == null || active == null || candidate.admissionToken >= active.admissionToken)
+ require(
+ candidate == null || active == null || candidate.admissionToken != active.admissionToken ||
+ candidate.contentEquals(active),
+ )
+ require(previous == null || active == null || previous.admissionToken < active.admissionToken)
+ }
+}
+
+private fun MessageDigest.updateLengthPrefixed(bytes: ByteArray) {
+ update(ByteBuffer.allocate(Int.SIZE_BYTES).putInt(bytes.size).array())
+ update(bytes)
+}
+
+private fun MessageDigest.updateNullable(bytes: ByteArray?) {
+ if (bytes == null) {
+ update(NULL_MARKER)
+ } else {
+ update(PRESENT_MARKER)
+ updateLengthPrefixed(bytes)
+ }
+}
+
+private fun ByteArray.toLowercaseHex(): String = joinToString(separator = "") { byte ->
+ val value = byte.toInt() and BYTE_MASK
+ "${HEX[value ushr NIBBLE_SHIFT]}${HEX[value and LOW_NIBBLE_MASK]}"
+}
+
+private const val NULL_MARKER: Byte = 0
+private const val PRESENT_MARKER: Byte = 1
+private const val TOMBSTONE_MARKER: Byte = 2
+private const val VALUE_MARKER: Byte = 3
+private const val ON_NEXT_ACTIVATE_MARKER: Byte = 4
+private const val IMMEDIATE_MARKER: Byte = 5
+private const val BYTE_MASK = 0xff
+private const val LOW_NIBBLE_MASK = 0x0f
+private const val NIBBLE_SHIFT = 4
+private const val HEX = "0123456789abcdef"
+
+internal class RemoteConfigResolvedValue(
+ val value: T,
+ val source: RemoteConfigSnapshotValueSource,
+ val variationUid: String,
+ val applyPolicy: RemoteConfigSnapshotApplyPolicy,
+ metadata: ByteArray?,
+) {
+ private val storedMetadata = metadata?.clone()
+ val metadataBytes: ByteArray? get() = storedMetadata?.clone()
+}
+
+/**
+ * Told that a typed read of [logicalKey] could not decode the value release [releaseNumber] served.
+ *
+ * The observer is a pure side channel: it is invoked AFTER the resolution ladder has already fallen
+ * through to the next rung, it may not throw into the read, and it must do nothing but enqueue —
+ * see [RemoteConfigTelemetrySender.recordDecodeFailure].
+ */
+internal fun interface RemoteConfigDecodeFailureObserver {
+ fun onDecodeFailure(logicalKey: String, releaseNumber: Long)
+}
+
+internal class RemoteConfigSnapshot(
+ private val primaryRelease: RemoteConfigSnapshotRelease?,
+ private val previousRelease: RemoteConfigSnapshotRelease?,
+ private val bundledRelease: RemoteConfigSnapshotRelease?,
+ private val decodeFailureObserver: RemoteConfigDecodeFailureObserver = NO_DECODE_FAILURE_OBSERVER,
+) {
+ val releaseUid: String get() = primaryRelease?.releaseUid.orEmpty()
+ val releaseNumber: Long get() = primaryRelease?.releaseNumber ?: 0
+ val manifestContentHash: String get() = primaryRelease?.manifestContentHash.orEmpty()
+ val allKeys: Set = Collections.unmodifiableSet(
+ buildSet {
+ primaryRelease?.entries?.keys?.let(::addAll)
+ bundledRelease?.entries?.keys?.let(::addAll)
+ },
+ )
+ val readableKeys: Set = Collections.unmodifiableSet(
+ buildSet {
+ primaryRelease?.entries?.values
+ ?.filterNot(RemoteConfigSnapshotEntry::isTombstone)
+ ?.mapTo(this, RemoteConfigSnapshotEntry::key)
+ bundledRelease?.entries?.values
+ ?.filterNot(RemoteConfigSnapshotEntry::isTombstone)
+ ?.mapTo(this, RemoteConfigSnapshotEntry::key)
+ },
+ )
+
+ @Suppress("ReturnCount")
+ fun rawValue(key: String): RemoteConfigResolvedValue? {
+ val candidate = primaryRelease?.entry(key)?.takeUnless { it.isTombstone }
+ ?.let { it to RemoteConfigSnapshotValueSource.Server }
+ ?: bundledRelease?.entry(key)?.takeUnless { it.isTombstone }
+ ?.let { it to RemoteConfigSnapshotValueSource.Fallback }
+ ?: return null
+ val raw = candidate.first.rawValueBytes ?: return null
+ return candidate.first.resolve(raw, candidate.second)
+ }
+
+ @Suppress("ReturnCount")
+ fun value(key: String, decoder: (ByteArray) -> T?): RemoteConfigResolvedValue? {
+ val primary = primaryRelease?.entry(key)
+ if (primary != null && !primary.isTombstone) {
+ primary.decode(decoder, RemoteConfigSnapshotValueSource.Server)?.let { return it }
+ // The served release carries the key but the app's decoder refused its value — the one
+ // failure mode the ladder hides completely, which is why it is reported here and only
+ // here. Reported strictly after the decode and strictly before the ladder continues:
+ // the value the caller receives is byte-for-byte what it would be without telemetry.
+ reportDecodeFailure(key)
+ previousRelease?.entry(key)
+ ?.takeUnless { it.isTombstone }
+ ?.decode(decoder, RemoteConfigSnapshotValueSource.Cache)
+ ?.let { return it }
+ }
+ return bundledRelease?.entry(key)
+ ?.takeUnless { it.isTombstone }
+ ?.decode(decoder, RemoteConfigSnapshotValueSource.Fallback)
+ }
+
+ fun metadataForKey(key: String): ByteArray? {
+ val entry = primaryRelease?.entry(key)?.takeUnless { it.isTombstone }
+ ?: bundledRelease?.entry(key)?.takeUnless { it.isTombstone }
+ return entry?.metadataBytes
+ }
+
+ internal fun effectiveEntry(key: String): RemoteConfigSnapshotEntry? =
+ primaryRelease?.entry(key)?.takeUnless { it.isTombstone }
+ ?: bundledRelease?.entry(key)?.takeUnless { it.isTombstone }
+
+ private fun reportDecodeFailure(key: String) {
+ try {
+ decodeFailureObserver.onDecodeFailure(key, primaryRelease?.releaseNumber ?: 0)
+ } catch (@Suppress("TooGenericExceptionCaught") _: Throwable) {
+ // Telemetry can never affect a read.
+ }
+ }
+
+ private fun RemoteConfigSnapshotEntry.decode(
+ decoder: (ByteArray) -> T?,
+ source: RemoteConfigSnapshotValueSource,
+ ): RemoteConfigResolvedValue? {
+ val decoded = try {
+ rawValueBytes?.let(decoder)
+ } catch (_: Exception) {
+ null
+ } ?: return null
+ return resolve(decoded, source)
+ }
+
+ private fun RemoteConfigSnapshotEntry.resolve(
+ value: T,
+ source: RemoteConfigSnapshotValueSource,
+ ) = RemoteConfigResolvedValue(
+ value = value,
+ source = source,
+ variationUid = requireNotNull(variationUid),
+ applyPolicy = applyPolicy,
+ metadata = metadataBytes,
+ )
+
+ private companion object {
+ val NO_DECODE_FAILURE_OBSERVER = RemoteConfigDecodeFailureObserver { _, _ -> }
+ }
+}
+
+internal class RemoteConfigSnapshotUpdate(
+ val snapshot: RemoteConfigSnapshot,
+ changedKeys: Set,
+ metadataByKey: Map,
+) {
+ val changedKeys: Set = Collections.unmodifiableSet(changedKeys.toSet())
+ private val storedMetadata = metadataByKey.mapValues { (_, value) -> value.clone() }
+
+ fun metadataForKey(key: String): ByteArray? = storedMetadata[key]?.clone()
+}
+
+internal fun BundledRemoteConfigDefaultsDocument.toScopedRemoteConfigSnapshotRelease(projectKey: String) =
+ RemoteConfigScopedBundledRelease(
+ projectKey = projectKey,
+ environment = environmentUid,
+ release = RemoteConfigSnapshotRelease(
+ releaseUid = releaseUid,
+ releaseNumber = releaseNumber,
+ manifestContentHash = manifestContentHash,
+ entries = allDefaults().map { value ->
+ RemoteConfigSnapshotEntry.value(
+ key = value.key,
+ rawValue = value.rawJsonBytes,
+ variationUid = value.variationUid,
+ applyPolicy = RemoteConfigSnapshotApplyPolicy.OnNextActivate,
+ metadata = null,
+ )
+ },
+ ),
+ )
+
+private fun String.hasValidUidLength(): Boolean =
+ codePointCount(0, length) <= REMOTE_CONFIG_UID_MAX_CODE_POINTS
+
+@Suppress("ReturnCount")
+private fun String.hasValidSurrogatePairs(): Boolean {
+ var index = 0
+ while (index < length) {
+ val character = this[index]
+ when {
+ character.isHighSurrogate() -> {
+ if (index + 1 >= length || !this[index + 1].isLowSurrogate()) return false
+ index += 2
+ }
+ character.isLowSurrogate() -> return false
+ else -> index += 1
+ }
+ }
+ return true
+}
+
+private fun ByteArray?.contentEqualsNullable(other: ByteArray?): Boolean = when {
+ this == null -> other == null
+ other == null -> false
+ else -> contentEquals(other)
+}
diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshotCore.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshotCore.kt
new file mode 100644
index 000000000..d47c6009d
--- /dev/null
+++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshotCore.kt
@@ -0,0 +1,781 @@
+package com.qonversion.android.sdk.internal.remoteconfig
+
+import com.qonversion.android.sdk.internal.storage.RemoteConfigSnapshotLoadResult
+import com.qonversion.android.sdk.internal.storage.RemoteConfigSnapshotLoadStatus
+import com.qonversion.android.sdk.internal.storage.RemoteConfigSnapshotStore
+import java.util.ArrayDeque
+import java.util.UUID
+import java.util.concurrent.locks.ReentrantLock
+import kotlin.concurrent.withLock
+
+internal enum class RemoteConfigSnapshotTransitionStatus {
+ Accepted,
+ Activated,
+ Ignored,
+ PersistenceFailed,
+ Rejected,
+ Unchanged,
+}
+
+internal data class RemoteConfigSnapshotTransitionResult(
+ val status: RemoteConfigSnapshotTransitionStatus,
+ val changed: Boolean = false,
+ val update: RemoteConfigSnapshotUpdate? = null,
+)
+
+internal class RemoteConfigSnapshotAdmissionToken private constructor(
+ private val ownerNonce: UUID,
+ private val admission: BoundRemoteConfigSnapshotAdmission,
+) {
+ internal fun resolve(ownerNonce: UUID): BoundRemoteConfigSnapshotAdmission? =
+ admission.takeIf { this.ownerNonce == ownerNonce }
+
+ internal companion object {
+ fun issue(
+ ownerNonce: UUID,
+ ordinal: Long,
+ scope: RemoteConfigSnapshotScope,
+ scopeGeneration: Long,
+ ) = RemoteConfigSnapshotAdmissionToken(
+ ownerNonce = ownerNonce,
+ admission = BoundRemoteConfigSnapshotAdmission(
+ ordinal = ordinal,
+ scope = scope,
+ scopeGeneration = scopeGeneration,
+ ),
+ )
+ }
+}
+
+internal class RemoteConfigScopePreloadToken private constructor(
+ private val ownerNonce: UUID,
+ internal val scope: RemoteConfigSnapshotScope,
+ internal val scopeGeneration: Long,
+ internal val installEpoch: Long,
+) {
+ internal fun belongsTo(ownerNonce: UUID): Boolean = this.ownerNonce == ownerNonce
+
+ internal companion object {
+ fun issue(
+ ownerNonce: UUID,
+ scope: RemoteConfigSnapshotScope,
+ scopeGeneration: Long,
+ installEpoch: Long,
+ ) = RemoteConfigScopePreloadToken(ownerNonce, scope, scopeGeneration, installEpoch)
+ }
+}
+
+internal enum class RemoteConfigScopePreloadInstallStatus {
+ Installed,
+ PersistenceFailed,
+ Superseded,
+ Ignored,
+}
+
+internal enum class RemoteConfigImplicitActivationClaimStatus {
+ Claimed,
+ Stale,
+ AlreadyConsumed,
+}
+
+internal data class BoundRemoteConfigSnapshotAdmission(
+ val ordinal: Long,
+ val scope: RemoteConfigSnapshotScope,
+ val scopeGeneration: Long,
+)
+
+internal data class RemoteConfigConditionalRequestValidator(
+ val etag: String,
+ val headAdmissionToken: Long,
+ val headContentDigest: String,
+)
+
+internal class RemoteConfigSnapshotCore(
+ private val store: RemoteConfigSnapshotStore,
+ private val bundledRelease: RemoteConfigScopedBundledRelease?,
+ private val envelopeParser: RemoteConfigSnapshotEnvelopeDecoder = RemoteConfigSnapshotEnvelopeParser(),
+ private val deliveryQueueObservedEmpty: (() -> Unit)? = null,
+ private val scopePreloadMutatedBeforeBinding: (() -> Unit)? = null,
+ /**
+ * Handed to every snapshot this core hands out, so a decode failure is reported from the read
+ * site that actually saw it. It is a side channel only: it can neither change a resolved value
+ * nor be observed by the app.
+ */
+ private val decodeFailureObserver: RemoteConfigDecodeFailureObserver = RemoteConfigDecodeFailureObserver { _, _ -> },
+) {
+ private val lock = Any()
+ private val deliveryLock = ReentrantLock()
+ private val deliveryBoundaryChanged = deliveryLock.newCondition()
+ private val admissionOwnerNonce = UUID.randomUUID()
+ private val preloadOwnerNonce = UUID.randomUUID()
+ private var currentScope: RemoteConfigSnapshotScope? = null
+ private var state = RemoteConfigSnapshotState()
+ private var prepersistedFirstReadActivation: PrepersistedFirstReadActivation? = null
+ private var firstReadActivationArmed = false
+ private var implicitActivationOpportunityConsumed = false
+ private var scopeLoadFailed = false
+ private var scopeGeneration = 0L
+ private var stateMutationEpoch = 0L
+ private var nextAdmissionToken = 0L
+ private var nextObserverToken = 0L
+ private val observers = linkedMapOf Unit>()
+ private val pendingDeliveries = ArrayDeque()
+ private var isDrainingDeliveries = false
+ private var deliveryOwnerThread: Thread? = null
+ private var scopeTransitionInProgress = false
+
+ fun setScope(scope: RemoteConfigSnapshotScope?) {
+ withDeliveryBoundary {
+ synchronized(lock) {
+ if (currentScope == scope && !(scope != null && scopeLoadFailed)) return
+ if (currentScope != scope) {
+ currentScope = scope
+ state = RemoteConfigSnapshotState()
+ prepersistedFirstReadActivation = null
+ firstReadActivationArmed = false
+ scopeLoadFailed = false
+ nextAdmissionToken = 0L
+ scopeGeneration++
+ stateMutationEpoch++
+ }
+ scope?.let(::loadScopeState)
+ }
+ }
+ }
+
+ fun beginScopePreload(
+ scope: RemoteConfigSnapshotScope?,
+ armFirstReadActivation: Boolean,
+ onBound: (RemoteConfigScopePreloadToken?) -> Unit = {},
+ ): RemoteConfigScopePreloadToken? = withDeliveryBoundary {
+ val token = synchronized(lock) {
+ currentScope = scope
+ state = RemoteConfigSnapshotState()
+ prepersistedFirstReadActivation = null
+ firstReadActivationArmed =
+ armFirstReadActivation && !implicitActivationOpportunityConsumed
+ scopeLoadFailed = scope != null
+ nextAdmissionToken = 0L
+ scopeGeneration++
+ stateMutationEpoch++
+ scope?.let {
+ RemoteConfigScopePreloadToken.issue(
+ preloadOwnerNonce,
+ it,
+ scopeGeneration,
+ stateMutationEpoch,
+ )
+ }
+ }
+ scopePreloadMutatedBeforeBinding?.invoke()
+ onBound(token)
+ token
+ }
+
+ fun installPreloadedScope(
+ token: RemoteConfigScopePreloadToken,
+ preloadedState: RemoteConfigSnapshotState,
+ preparedActivationState: RemoteConfigSnapshotState?,
+ ): RemoteConfigScopePreloadInstallStatus = synchronized(lock) {
+ if (!isCurrentPreloadToken(token)) {
+ return@synchronized RemoteConfigScopePreloadInstallStatus.Ignored
+ }
+ if (token.installEpoch != stateMutationEpoch) {
+ return@synchronized RemoteConfigScopePreloadInstallStatus.Superseded
+ }
+ val preparedState = preparedActivationState.takeIf { firstReadActivationArmed }
+ val preparedStateWasPersisted = preparedState == null ||
+ preparedState === preloadedState || saveCurrentScope(preparedState)
+ state = preloadedState
+ prepersistedFirstReadActivation = if (preparedStateWasPersisted) {
+ preparedState?.let { PrepersistedFirstReadActivation(preloadedState, it) }
+ } else {
+ null
+ }
+ nextAdmissionToken = preloadedState.latestAdmissionToken
+ scopeLoadFailed = false
+ stateMutationEpoch++
+ if (preparedStateWasPersisted) {
+ RemoteConfigScopePreloadInstallStatus.Installed
+ } else {
+ RemoteConfigScopePreloadInstallStatus.PersistenceFailed
+ }
+ }
+
+ fun commitPrepersistedActivation(
+ token: RemoteConfigScopePreloadToken,
+ ): RemoteConfigSnapshotTransitionResult {
+ val result = commitPrepersistedActivationWithoutDelivery(token)
+ deliverPendingUpdates()
+ return result
+ }
+
+ fun commitPrepersistedActivationWithoutDelivery(
+ token: RemoteConfigScopePreloadToken,
+ ): RemoteConfigSnapshotTransitionResult {
+ val delivery = synchronized(lock) {
+ if (!isCurrentPreloadToken(token)) return@synchronized TransitionDelivery.ignored()
+ implicitActivationOpportunityConsumed = true
+ firstReadActivationArmed = false
+ if (scopeLoadFailed) return@synchronized TransitionDelivery.ignored()
+ val activation = prepersistedFirstReadActivation
+ prepersistedFirstReadActivation = null
+ if (activation == null || state !== activation.expectedState) {
+ return@synchronized TransitionDelivery.ignored()
+ }
+ val preparedState = activation.preparedState
+ val expectedState = activation.expectedState
+ if (preparedState === expectedState) return@synchronized TransitionDelivery.unchanged()
+ val oldSnapshot = snapshotFor(state.active, state.previous)
+ state = preparedState
+ stateMutationEpoch++
+ nextAdmissionToken = maxOf(nextAdmissionToken, preparedState.latestAdmissionToken)
+ val update = buildUpdate(oldSnapshot, snapshotFor(preparedState.active, preparedState.previous))
+ TransitionDelivery
+ .activated(update, observers.values.toList(), scopeGeneration)
+ .also(::enqueueDeliveryLocked)
+ }
+ return delivery.result
+ }
+
+ fun deliverPendingUpdates() {
+ drainDeliveries()
+ }
+
+ private data class PrepersistedFirstReadActivation(
+ val expectedState: RemoteConfigSnapshotState,
+ val preparedState: RemoteConfigSnapshotState,
+ )
+
+ fun claimImplicitActivationOpportunity(
+ token: RemoteConfigScopePreloadToken?,
+ ): RemoteConfigImplicitActivationClaimStatus = synchronized(lock) {
+ if (token == null || !isCurrentPreloadToken(token)) {
+ return@synchronized RemoteConfigImplicitActivationClaimStatus.Stale
+ }
+ if (implicitActivationOpportunityConsumed) {
+ return@synchronized RemoteConfigImplicitActivationClaimStatus.AlreadyConsumed
+ }
+ implicitActivationOpportunityConsumed = true
+ RemoteConfigImplicitActivationClaimStatus.Claimed
+ }
+
+ private fun isCurrentPreloadToken(token: RemoteConfigScopePreloadToken): Boolean =
+ token.belongsTo(preloadOwnerNonce) && token.scope == currentScope &&
+ token.scopeGeneration == scopeGeneration
+
+ fun currentSnapshot(): RemoteConfigSnapshot = synchronized(lock) {
+ snapshotFor(state.active, state.previous)
+ }
+
+ fun lastFetchedSnapshot(): RemoteConfigSnapshot? = synchronized(lock) {
+ state.candidate?.let { candidate ->
+ val previous = if (candidate.isSameRelease(state.active)) state.previous else state.active
+ snapshotFor(candidate, previous)
+ }
+ }
+
+ fun conditionalRequestValidator(): RemoteConfigConditionalRequestValidator? = synchronized(lock) {
+ conditionalHeadLocked()?.toConditionalRequestValidator()
+ }
+
+ fun isConditionalRequestValidatorCurrent(validator: RemoteConfigConditionalRequestValidator): Boolean =
+ synchronized(lock) {
+ conditionalHeadLocked()?.toConditionalRequestValidator() == validator
+ }
+
+ private fun conditionalHeadLocked(): RemoteConfigSnapshotRelease? = (state.candidate ?: state.active)
+ ?.takeIf { it.strongETag != null && it.hasCanonicalBody }
+
+ private fun RemoteConfigSnapshotRelease.toConditionalRequestValidator() =
+ RemoteConfigConditionalRequestValidator(
+ etag = requireNotNull(strongETag),
+ headAdmissionToken = admissionToken,
+ headContentDigest = contentDigest,
+ )
+
+ fun addUpdateObserver(observer: (RemoteConfigSnapshotUpdate) -> Unit): Long = synchronized(lock) {
+ val token = ++nextObserverToken
+ observers[token] = observer
+ token
+ }
+
+ fun removeUpdateObserver(token: Long) {
+ synchronized(lock) { observers.remove(token) }
+ }
+
+ /**
+ * Claims the right to admit the next release for [scope].
+ *
+ * The envelope expectation is deliberately NOT taken here: its environment uid is the scope's
+ * own, and its project id is only known once the transport has bootstrapped a session — which
+ * happens after this claim is made. It is therefore supplied to [admitCandidate], the step that
+ * actually has the response in hand.
+ */
+ fun beginAdmission(scope: RemoteConfigSnapshotScope): RemoteConfigSnapshotAdmissionToken? =
+ synchronized(lock) {
+ val admission = issueAdmissionLocked(scope) ?: return@synchronized null
+ RemoteConfigSnapshotAdmissionToken.issue(
+ ownerNonce = admissionOwnerNonce,
+ ordinal = admission.ordinal,
+ scope = scope,
+ scopeGeneration = admission.scopeGeneration,
+ )
+ }
+
+ private fun issueAdmissionLocked(scope: RemoteConfigSnapshotScope): IssuedAdmission? {
+ if (scope != currentScope || !ensureCurrentScopeLoaded() || nextAdmissionToken == Long.MAX_VALUE) {
+ return null
+ }
+ return IssuedAdmission(
+ ordinal = ++nextAdmissionToken,
+ scopeGeneration = scopeGeneration,
+ )
+ }
+
+ private data class IssuedAdmission(
+ val ordinal: Long,
+ val scopeGeneration: Long,
+ )
+
+ private fun issueAdmission(scope: RemoteConfigSnapshotScope): IssuedAdmission? = synchronized(lock) {
+ issueAdmissionLocked(scope)
+ }
+
+ private fun failedDirectAdmission(scope: RemoteConfigSnapshotScope) = synchronized(lock) {
+ RemoteConfigSnapshotTransitionResult(
+ if (scope == currentScope) {
+ RemoteConfigSnapshotTransitionStatus.PersistenceFailed
+ } else {
+ RemoteConfigSnapshotTransitionStatus.Ignored
+ },
+ )
+ }
+
+ fun acceptCandidate(
+ scope: RemoteConfigSnapshotScope,
+ release: RemoteConfigSnapshotRelease,
+ ): RemoteConfigSnapshotTransitionResult {
+ val admission = issueAdmission(scope) ?: return failedDirectAdmission(scope)
+ return acceptCandidate(
+ scope = scope,
+ release = release,
+ admissionOrdinal = admission.ordinal,
+ admissionScopeGeneration = admission.scopeGeneration,
+ authoritativeComplete = false,
+ )
+ }
+
+ /**
+ * Admits [body] under a claim taken by [beginAdmission].
+ *
+ * [projectId] is the project the response was served for, as established by the gateway session
+ * that authorised the read. The envelope must name exactly it — and the admitting scope's
+ * environment — or it is [RemoteConfigSnapshotTransitionStatus.Rejected].
+ */
+ @Suppress("ReturnCount")
+ fun admitCandidate(
+ admissionToken: RemoteConfigSnapshotAdmissionToken,
+ body: ByteArray,
+ etag: String,
+ projectId: Long,
+ ): RemoteConfigSnapshotTransitionResult {
+ val admission = admissionToken.resolve(admissionOwnerNonce)
+ ?: return RemoteConfigSnapshotTransitionResult(RemoteConfigSnapshotTransitionStatus.Rejected)
+ val tokenIsCurrent = synchronized(lock) {
+ admission.scope == currentScope &&
+ admission.scopeGeneration == scopeGeneration &&
+ admission.ordinal == nextAdmissionToken &&
+ admission.ordinal > state.latestAdmissionToken
+ }
+ if (!tokenIsCurrent) {
+ return RemoteConfigSnapshotTransitionResult(RemoteConfigSnapshotTransitionStatus.Rejected)
+ }
+ val expectation = RemoteConfigSnapshotEnvelopeExpectation(
+ projectId = projectId,
+ environmentUid = admission.scope.environment,
+ )
+ val envelope = envelopeParser.parse(body, etag, expectation)
+ ?: return RemoteConfigSnapshotTransitionResult(RemoteConfigSnapshotTransitionStatus.Rejected)
+ return acceptCandidate(
+ scope = admission.scope,
+ release = envelope.release,
+ admissionOrdinal = admission.ordinal,
+ admissionScopeGeneration = admission.scopeGeneration,
+ authoritativeComplete = true,
+ )
+ }
+
+ @Suppress("ComplexMethod", "LongMethod")
+ private fun acceptCandidate(
+ scope: RemoteConfigSnapshotScope,
+ release: RemoteConfigSnapshotRelease,
+ admissionOrdinal: Long,
+ admissionScopeGeneration: Long,
+ authoritativeComplete: Boolean,
+ ): RemoteConfigSnapshotTransitionResult {
+ val delivery = synchronized(lock) {
+ if (scope != currentScope || admissionScopeGeneration != scopeGeneration) {
+ return@synchronized if (authoritativeComplete) {
+ TransitionDelivery.rejected()
+ } else {
+ TransitionDelivery.ignored()
+ }
+ }
+ if (!ensureCurrentScopeLoaded()) return@synchronized TransitionDelivery.persistenceFailed()
+ if (admissionOrdinal != nextAdmissionToken || admissionOrdinal <= state.latestAdmissionToken) {
+ return@synchronized if (authoritativeComplete) {
+ TransitionDelivery.rejected()
+ } else {
+ TransitionDelivery.ignored()
+ }
+ }
+ val releaseNumberFloor = maxOf(
+ state.candidate?.releaseNumber ?: 0,
+ state.active?.releaseNumber ?: 0,
+ )
+ if (release.releaseNumber < releaseNumberFloor) {
+ return@synchronized if (authoritativeComplete) {
+ TransitionDelivery.rejected()
+ } else {
+ TransitionDelivery.ignored()
+ }
+ }
+ val tokenizedRelease = release.withAdmissionToken(admissionOrdinal)
+ val admittedRelease = if (authoritativeComplete) {
+ tokenizedRelease.withMissingActiveKeysTombstoned(state.active)
+ ?: return@synchronized TransitionDelivery.rejected()
+ } else {
+ tokenizedRelease
+ }
+
+ val oldSnapshot = snapshotFor(state.active, state.previous)
+ val nextState = if (admittedRelease.containsImmediateEntry) {
+ RemoteConfigSnapshotState(
+ candidate = admittedRelease,
+ active = admittedRelease,
+ previous = state.active,
+ didActivate = true,
+ latestAdmissionToken = admissionOrdinal,
+ )
+ } else {
+ state.copy(candidate = admittedRelease, latestAdmissionToken = admissionOrdinal)
+ }
+ val preparedFirstReadState = if (firstReadActivationArmed) {
+ nextState.preparedActivationState()
+ } else {
+ null
+ }
+ val persistedState = preparedFirstReadState ?: nextState
+ if (!saveCurrentScope(persistedState)) return@synchronized TransitionDelivery.persistenceFailed()
+ state = nextState
+ stateMutationEpoch++
+ if (firstReadActivationArmed && preparedFirstReadState != null) {
+ prepersistedFirstReadActivation = PrepersistedFirstReadActivation(
+ expectedState = nextState,
+ preparedState = preparedFirstReadState,
+ )
+ }
+ if (admittedRelease.containsImmediateEntry) {
+ val update = buildUpdate(oldSnapshot, snapshotFor(nextState.active, nextState.previous))
+ TransitionDelivery(
+ result = RemoteConfigSnapshotTransitionResult(
+ status = RemoteConfigSnapshotTransitionStatus.Activated,
+ changed = update.changedKeys.isNotEmpty(),
+ update = update,
+ ),
+ update = update,
+ observers = observers.values.toList(),
+ scopeGeneration = scopeGeneration,
+ admissionOrdinal = admissionOrdinal,
+ ).also(::enqueueDeliveryLocked)
+ } else {
+ TransitionDelivery(
+ RemoteConfigSnapshotTransitionResult(RemoteConfigSnapshotTransitionStatus.Accepted),
+ )
+ }
+ }
+ drainDeliveries()
+ return delivery.result
+ }
+
+ fun activate(): RemoteConfigSnapshotTransitionResult = activateForPreloadToken(null)
+
+ fun activateForPreloadToken(
+ expectedToken: RemoteConfigScopePreloadToken?,
+ ): RemoteConfigSnapshotTransitionResult {
+ val delivery = synchronized(lock) {
+ if (expectedToken != null && !isCurrentPreloadToken(expectedToken)) {
+ return@synchronized TransitionDelivery.ignored()
+ }
+ implicitActivationOpportunityConsumed = true
+ if (currentScope == null) return@synchronized TransitionDelivery.ignored()
+ if (!ensureCurrentScopeLoaded()) return@synchronized TransitionDelivery.persistenceFailed()
+ val candidate = state.candidate
+ if (candidate == null) {
+ if (state.didActivate) return@synchronized TransitionDelivery.unchanged()
+ val nextState = state.copy(didActivate = true)
+ if (!saveCurrentScope(nextState)) return@synchronized TransitionDelivery.persistenceFailed()
+ val oldSnapshot = snapshotFor(state.active, state.previous)
+ state = nextState
+ stateMutationEpoch++
+ val update = buildUpdate(oldSnapshot = null, newSnapshot = oldSnapshot)
+ return@synchronized TransitionDelivery
+ .activated(update, observers.values.toList(), scopeGeneration)
+ .also(::enqueueDeliveryLocked)
+ }
+ if (state.didActivate && candidate.isSameRelease(state.active)) {
+ return@synchronized TransitionDelivery.unchanged()
+ }
+
+ val oldSnapshot = snapshotFor(state.active, state.previous)
+ val nextState = RemoteConfigSnapshotState(
+ candidate = candidate,
+ active = candidate,
+ previous = state.active,
+ didActivate = true,
+ latestAdmissionToken = state.latestAdmissionToken,
+ )
+ if (!saveCurrentScope(nextState)) return@synchronized TransitionDelivery.persistenceFailed()
+ state = nextState
+ stateMutationEpoch++
+ val update = buildUpdate(oldSnapshot, snapshotFor(nextState.active, nextState.previous))
+ TransitionDelivery
+ .activated(update, observers.values.toList(), scopeGeneration)
+ .also(::enqueueDeliveryLocked)
+ }
+ drainDeliveries()
+ return delivery.result
+ }
+
+ private fun ensureCurrentScopeLoaded(): Boolean {
+ val scope = currentScope
+ if (scope != null && scopeLoadFailed) loadScopeState(scope)
+ return scope != null && !scopeLoadFailed
+ }
+
+ private fun loadScopeState(scope: RemoteConfigSnapshotScope) {
+ val result = try {
+ store.load(scope)
+ } catch (_: Exception) {
+ RemoteConfigSnapshotLoadResult(RemoteConfigSnapshotLoadStatus.Failed)
+ }
+ when (result.status) {
+ RemoteConfigSnapshotLoadStatus.Found -> {
+ state = requireNotNull(result.state)
+ stateMutationEpoch++
+ nextAdmissionToken = state.latestAdmissionToken
+ scopeLoadFailed = false
+ }
+ RemoteConfigSnapshotLoadStatus.Missing -> {
+ state = RemoteConfigSnapshotState()
+ stateMutationEpoch++
+ nextAdmissionToken = 0L
+ scopeLoadFailed = false
+ }
+ RemoteConfigSnapshotLoadStatus.Failed -> {
+ scopeLoadFailed = true
+ }
+ RemoteConfigSnapshotLoadStatus.Corrupt -> {
+ scopeLoadFailed = true
+ }
+ }
+ }
+
+ private fun saveCurrentScope(nextState: RemoteConfigSnapshotState): Boolean {
+ val scope = currentScope ?: return false
+ return try {
+ store.save(scope, nextState)
+ } catch (_: Exception) {
+ false
+ }
+ }
+
+ private fun snapshotFor(
+ primary: RemoteConfigSnapshotRelease?,
+ previous: RemoteConfigSnapshotRelease?,
+ ) = RemoteConfigSnapshot(
+ primaryRelease = primary,
+ previousRelease = previous,
+ bundledRelease = bundledRelease?.releaseFor(currentScope),
+ decodeFailureObserver = decodeFailureObserver,
+ )
+
+ private fun buildUpdate(
+ oldSnapshot: RemoteConfigSnapshot?,
+ newSnapshot: RemoteConfigSnapshot,
+ ): RemoteConfigSnapshotUpdate {
+ val allKeys = oldSnapshot?.allKeys.orEmpty() + newSnapshot.allKeys
+ val changedKeys = allKeys.filterTo(mutableSetOf()) { key ->
+ val oldEntry = oldSnapshot?.effectiveEntry(key)
+ val newEntry = newSnapshot.effectiveEntry(key)
+ when {
+ oldEntry == null -> newEntry != null
+ else -> !oldEntry.contentEquals(newEntry)
+ }
+ }
+ val metadata = changedKeys.mapNotNull { key ->
+ newSnapshot.metadataForKey(key)?.let { key to it }
+ }.toMap()
+ return RemoteConfigSnapshotUpdate(newSnapshot, changedKeys, metadata)
+ }
+
+ private fun enqueueDeliveryLocked(delivery: TransitionDelivery) {
+ if (delivery.update?.changedKeys?.isNotEmpty() == true) pendingDeliveries.addLast(delivery)
+ }
+
+ private inline fun withDeliveryBoundary(block: () -> T): T {
+ deliveryLock.withLock {
+ val currentThread = Thread.currentThread()
+ while (scopeTransitionInProgress ||
+ (isDrainingDeliveries && deliveryOwnerThread !== currentThread)
+ ) {
+ deliveryBoundaryChanged.awaitUninterruptibly()
+ }
+ scopeTransitionInProgress = true
+ }
+ return try {
+ block()
+ } finally {
+ deliveryLock.withLock {
+ scopeTransitionInProgress = false
+ deliveryBoundaryChanged.signalAll()
+ }
+ }
+ }
+
+ @Suppress("NestedBlockDepth")
+ private fun drainDeliveries() {
+ deliveryLock.withLock {
+ while (scopeTransitionInProgress) deliveryBoundaryChanged.awaitUninterruptibly()
+ if (isDrainingDeliveries) return
+ isDrainingDeliveries = true
+ deliveryOwnerThread = Thread.currentThread()
+ }
+ try {
+ while (true) {
+ val delivery = synchronized(lock) { pollCurrentDeliveryLocked() }
+ if (delivery == null) {
+ deliveryQueueObservedEmpty?.invoke()
+ val racedDelivery = takeRacedDeliveryOrReleaseOwnership()
+ if (racedDelivery == null) return
+ deliverIfCurrent(racedDelivery)
+ } else {
+ deliverIfCurrent(delivery)
+ }
+ }
+ } finally {
+ deliveryLock.withLock {
+ if (isDrainingDeliveries && deliveryOwnerThread === Thread.currentThread()) {
+ isDrainingDeliveries = false
+ deliveryOwnerThread = null
+ deliveryBoundaryChanged.signalAll()
+ }
+ }
+ }
+ }
+
+ private fun takeRacedDeliveryOrReleaseOwnership(): TransitionDelivery? = deliveryLock.withLock {
+ synchronized(lock) { pollCurrentDeliveryLocked() }.also { nextDelivery ->
+ if (nextDelivery == null) {
+ isDrainingDeliveries = false
+ deliveryOwnerThread = null
+ deliveryBoundaryChanged.signalAll()
+ }
+ }
+ }
+
+ private fun pollCurrentDeliveryLocked(): TransitionDelivery? {
+ while (pendingDeliveries.isNotEmpty()) {
+ val delivery = pendingDeliveries.removeFirst()
+ val generationIsCurrent = delivery.scopeGeneration == scopeGeneration
+ val admissionIsCurrent = delivery.admissionOrdinal?.let { it == nextAdmissionToken } ?: true
+ if (generationIsCurrent && admissionIsCurrent) return delivery
+ }
+ return null
+ }
+
+ private fun deliverIfCurrent(delivery: TransitionDelivery) {
+ val update = requireNotNull(delivery.update)
+ val generation = requireNotNull(delivery.scopeGeneration)
+ for (observer in delivery.observers) {
+ val isCurrent = synchronized(lock) { generation == scopeGeneration }
+ if (!isCurrent) break
+ try {
+ observer(update)
+ } catch (_: Exception) {
+ // A committed transition remains successful and other observers still run.
+ }
+ }
+ }
+
+ private fun RemoteConfigSnapshotRelease.isSameRelease(other: RemoteConfigSnapshotRelease?): Boolean =
+ other != null && admissionToken == other.admissionToken
+
+ private data class TransitionDelivery(
+ val result: RemoteConfigSnapshotTransitionResult,
+ val update: RemoteConfigSnapshotUpdate? = null,
+ val observers: List<(RemoteConfigSnapshotUpdate) -> Unit> = emptyList(),
+ val scopeGeneration: Long? = null,
+ val admissionOrdinal: Long? = null,
+ ) {
+ companion object {
+ fun ignored() = TransitionDelivery(
+ RemoteConfigSnapshotTransitionResult(RemoteConfigSnapshotTransitionStatus.Ignored),
+ )
+
+ fun persistenceFailed() = TransitionDelivery(
+ RemoteConfigSnapshotTransitionResult(RemoteConfigSnapshotTransitionStatus.PersistenceFailed),
+ )
+
+ fun rejected() = TransitionDelivery(
+ RemoteConfigSnapshotTransitionResult(RemoteConfigSnapshotTransitionStatus.Rejected),
+ )
+
+ fun unchanged() = TransitionDelivery(
+ RemoteConfigSnapshotTransitionResult(RemoteConfigSnapshotTransitionStatus.Unchanged),
+ )
+
+ fun activated(
+ update: RemoteConfigSnapshotUpdate,
+ observers: List<(RemoteConfigSnapshotUpdate) -> Unit>,
+ scopeGeneration: Long,
+ ) = TransitionDelivery(
+ result = RemoteConfigSnapshotTransitionResult(
+ status = RemoteConfigSnapshotTransitionStatus.Activated,
+ changed = update.changedKeys.isNotEmpty(),
+ update = update,
+ ),
+ update = update,
+ observers = observers,
+ scopeGeneration = scopeGeneration,
+ )
+ }
+ }
+}
+
+private fun RemoteConfigSnapshotRelease.withMissingActiveKeysTombstoned(
+ active: RemoteConfigSnapshotRelease?,
+): RemoteConfigSnapshotRelease? {
+ val missingActiveKeys = active?.entries?.values.orEmpty()
+ .asSequence()
+ .filterNot(RemoteConfigSnapshotEntry::isTombstone)
+ .map(RemoteConfigSnapshotEntry::key)
+ .filterNot(entries::containsKey)
+ .toList()
+ if (missingActiveKeys.isEmpty()) return this
+ return try {
+ RemoteConfigSnapshotRelease(
+ releaseUid = releaseUid,
+ releaseNumber = releaseNumber,
+ manifestContentHash = manifestContentHash,
+ entries = entries.values + missingActiveKeys.map(RemoteConfigSnapshotEntry::tombstone),
+ canonicalBody = canonicalBodyBytes,
+ strongETag = strongETag,
+ contextFingerprint = contextFingerprint,
+ admissionToken = admissionToken,
+ )
+ } catch (_: IllegalArgumentException) {
+ null
+ }
+}
diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshotEnvelopeParser.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshotEnvelopeParser.kt
new file mode 100644
index 000000000..ee9a4a2d1
--- /dev/null
+++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshotEnvelopeParser.kt
@@ -0,0 +1,563 @@
+package com.qonversion.android.sdk.internal.remoteconfig
+
+import com.squareup.moshi.JsonReader
+import okio.Buffer
+import java.math.BigInteger
+import java.nio.ByteBuffer
+import java.nio.charset.CodingErrorAction
+import java.nio.charset.StandardCharsets
+import java.security.MessageDigest
+
+internal const val REMOTE_CONFIG_SNAPSHOT_ENVELOPE_MAX_BYTES = 8 * 1024 * 1024
+
+private const val REMOTE_CONFIG_SNAPSHOT_SCHEMA_VERSION = 1
+private const val REMOTE_CONFIG_SNAPSHOT_MAX_KEYS = 1_000
+private const val REMOTE_CONFIG_SNAPSHOT_VALUE_MAX_BYTES = 64 * 1024
+private const val REMOTE_CONFIG_SNAPSHOT_METADATA_MAX_BYTES = 4 * 1024
+private const val REMOTE_CONFIG_SNAPSHOT_LOGICAL_KEY_MAX_BYTES = 256
+private const val REMOTE_CONFIG_SNAPSHOT_UID_MAX_CODE_POINTS = 36
+private const val REMOTE_CONFIG_SNAPSHOT_JSON_MAX_DEPTH = 64
+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}$")
+
+/**
+ * The addressing an envelope must match to be admitted: exactly the environment the SDK was
+ * configured for and the project its gateway session was minted for.
+ *
+ * [projectId] is learned, not configured: the session bootstrap is the SDK's only source for it,
+ * the first bootstrap of a scope pins it, and a later disagreement is refused before a snapshot is
+ * ever read (see [RemoteConfigProjectIdRegistry]).
+ *
+ * The targeting context is deliberately absent. The fingerprint hashes mutable targeting context
+ * (app/OS version, locale, purchases, properties); it rotates legitimately and MUST NOT be pinned
+ * across fetches. Identity isolation is the session's job — the snapshot read travels on a session
+ * token minted for one identity and the gateway routes on it — so the parser validates the
+ * fingerprint's *shape* and carries it through as an opaque per-response tag, and nothing anywhere
+ * compares it against a previous response's value.
+ */
+internal data class RemoteConfigSnapshotEnvelopeExpectation(
+ val projectId: Long,
+ val environmentUid: String,
+)
+
+internal class RemoteConfigSnapshotEnvelope internal constructor(
+ val projectId: Long,
+ val environmentUid: String,
+ val contextFingerprint: String,
+ val release: RemoteConfigSnapshotRelease,
+ val etag: String,
+ val bodyDigest: String,
+ canonicalBody: ByteArray,
+) {
+ private val storedCanonicalBody = canonicalBody.clone()
+ val canonicalBodyBytes: ByteArray get() = storedCanonicalBody.clone()
+}
+
+internal fun interface RemoteConfigSnapshotEnvelopeDecoder {
+ fun parse(
+ body: ByteArray,
+ etag: String,
+ expectation: RemoteConfigSnapshotEnvelopeExpectation,
+ ): RemoteConfigSnapshotEnvelope?
+}
+
+internal class RemoteConfigSnapshotEnvelopeParser : RemoteConfigSnapshotEnvelopeDecoder {
+ override fun parse(
+ body: ByteArray,
+ etag: String,
+ expectation: RemoteConfigSnapshotEnvelopeExpectation,
+ ): RemoteConfigSnapshotEnvelope? {
+ if (!expectation.isValid()) return null
+ return parseBoundBody(body, etag)?.takeIf { envelope ->
+ envelope.projectId == expectation.projectId &&
+ envelope.environmentUid == expectation.environmentUid
+ }
+ }
+
+ @Suppress("ComplexCondition", "ComplexMethod", "ReturnCount", "SwallowedException")
+ internal fun parseBoundBody(
+ body: ByteArray,
+ etag: String,
+ ): RemoteConfigSnapshotEnvelope? {
+ if (body.isEmpty() || body.size > REMOTE_CONFIG_SNAPSHOT_ENVELOPE_MAX_BYTES) return null
+ val bodyDigest = remoteConfigStrongETagDigest(body, etag) ?: return null
+ if (!body.isStrictUtf8()) return null
+
+ return try {
+ val decoded = SnapshotJsonReader(body).readEnvelope()
+ if (decoded.schemaVersion != REMOTE_CONFIG_SNAPSHOT_SCHEMA_VERSION ||
+ decoded.projectId !in 1..PORTABLE_JSON_MAX_INTEGER ||
+ !decoded.environmentUid.isValidUid() ||
+ !LOWERCASE_SHA256_PATTERN.matches(decoded.contextFingerprint) ||
+ !decoded.completeKeySet ||
+ !decoded.releaseUid.isValidUid() ||
+ decoded.releaseNumber !in 1..PORTABLE_JSON_MAX_INTEGER ||
+ !LOWERCASE_SHA256_PATTERN.matches(decoded.manifestContentHash) ||
+ decoded.manifestContentHash.all { it == '0' }
+ ) {
+ return null
+ }
+ val release = RemoteConfigSnapshotRelease(
+ releaseUid = decoded.releaseUid,
+ releaseNumber = decoded.releaseNumber,
+ manifestContentHash = decoded.manifestContentHash,
+ entries = decoded.values.map { value ->
+ RemoteConfigSnapshotEntry.value(
+ key = value.key,
+ rawValue = value.raw,
+ variationUid = value.variationUid,
+ applyPolicy = value.applyPolicy,
+ metadata = value.metadata,
+ )
+ },
+ canonicalBody = body,
+ strongETag = etag,
+ contextFingerprint = decoded.contextFingerprint,
+ )
+ RemoteConfigSnapshotEnvelope(
+ projectId = decoded.projectId,
+ environmentUid = decoded.environmentUid,
+ contextFingerprint = decoded.contextFingerprint,
+ release = release,
+ etag = etag,
+ bodyDigest = bodyDigest,
+ canonicalBody = body,
+ )
+ } catch (_: Exception) {
+ null
+ }
+ }
+}
+
+private data class DecodedSnapshotEnvelope(
+ val schemaVersion: Int,
+ val projectId: Long,
+ val environmentUid: String,
+ val releaseUid: String,
+ val releaseNumber: Long,
+ val manifestContentHash: String,
+ val completeKeySet: Boolean,
+ val contextFingerprint: String,
+ val values: List,
+)
+
+private data class DecodedSnapshotValue(
+ val key: String,
+ val raw: ByteArray,
+ val variationUid: String,
+ val applyPolicy: RemoteConfigSnapshotApplyPolicy,
+ val metadata: ByteArray,
+)
+
+internal data class RemoteConfigPortableJsonScanResult(
+ val accepted: Boolean,
+ val consumedBytes: Int,
+)
+
+internal fun scanPortableRemoteConfigJson(
+ bytes: ByteArray,
+ maxBytes: Int,
+): RemoteConfigPortableJsonScanResult {
+ require(maxBytes > 0)
+ val reader = SnapshotJsonReader(bytes)
+ val accepted = try {
+ reader.scanPortableJson(maxBytes)
+ true
+ } catch (_: Exception) {
+ false
+ }
+ return RemoteConfigPortableJsonScanResult(accepted, reader.cursorOffset)
+}
+
+private class SnapshotJsonReader(private val bytes: ByteArray) {
+ private var index = 0
+ private var cursorLimit = bytes.size
+ val cursorOffset: Int get() = index
+
+ fun scanPortableJson(maxBytes: Int): ByteArray {
+ val value = readPortableJsonBytes(maxBytes)
+ require(index == bytes.size) { "trailing JSON data" }
+ return value
+ }
+
+ @Suppress("ComplexMethod")
+ fun readEnvelope(): DecodedSnapshotEnvelope {
+ var schemaVersion: Int? = null
+ var projectId: Long? = null
+ var environmentUid: String? = null
+ var releaseUid: String? = null
+ var releaseNumber: Long? = null
+ var manifestContentHash: String? = null
+ var completeKeySet: Boolean? = null
+ var contextFingerprint: String? = null
+ var values: List? = null
+ val members = mutableSetOf()
+
+ skipWhitespace()
+ expect('{')
+ skipWhitespace()
+ if (!consume('}')) {
+ while (true) {
+ val name = readString()
+ require(members.add(name)) { "duplicate envelope member" }
+ skipWhitespace()
+ expect(':')
+ when (name) {
+ "schema_version" -> schemaVersion = readExactInt()
+ "project_id" -> projectId = readExactLong()
+ "environment_uid" -> environmentUid = readStringValue()
+ "release_uid" -> releaseUid = readStringValue()
+ "release_number" -> releaseNumber = readExactLong()
+ "manifest_content_hash" -> manifestContentHash = readStringValue()
+ "complete_key_set" -> completeKeySet = readBooleanValue()
+ "context_fingerprint" -> contextFingerprint = readStringValue()
+ "values" -> values = readValues()
+ else -> error("unknown envelope member")
+ }
+ skipWhitespace()
+ if (consume('}')) break
+ expect(',')
+ skipWhitespace()
+ }
+ }
+ skipWhitespace()
+ require(index == bytes.size) { "trailing envelope data" }
+ return DecodedSnapshotEnvelope(
+ schemaVersion = requireNotNull(schemaVersion),
+ projectId = requireNotNull(projectId),
+ environmentUid = requireNotNull(environmentUid),
+ releaseUid = requireNotNull(releaseUid),
+ releaseNumber = requireNotNull(releaseNumber),
+ manifestContentHash = requireNotNull(manifestContentHash),
+ completeKeySet = requireNotNull(completeKeySet),
+ contextFingerprint = requireNotNull(contextFingerprint),
+ values = requireNotNull(values),
+ )
+ }
+
+ private fun readValues(): List {
+ val values = mutableListOf()
+ val keys = mutableSetOf()
+ skipWhitespace()
+ expect('{')
+ skipWhitespace()
+ if (consume('}')) return values
+ while (true) {
+ require(values.size < REMOTE_CONFIG_SNAPSHOT_MAX_KEYS) { "too many values" }
+ val key = readString()
+ require(key.isNotEmpty() && key.toByteArray(StandardCharsets.UTF_8).size <=
+ REMOTE_CONFIG_SNAPSHOT_LOGICAL_KEY_MAX_BYTES) { "invalid logical key" }
+ require(keys.add(key)) { "duplicate logical key" }
+ skipWhitespace()
+ expect(':')
+ values += readSnapshotValue(key)
+ skipWhitespace()
+ if (consume('}')) break
+ expect(',')
+ skipWhitespace()
+ }
+ return values
+ }
+
+ @Suppress("ComplexMethod")
+ private fun readSnapshotValue(key: String): DecodedSnapshotValue {
+ var raw: ByteArray? = null
+ var variationUid: String? = null
+ var applyPolicy: RemoteConfigSnapshotApplyPolicy? = null
+ var metadata: ByteArray? = null
+ val members = mutableSetOf()
+ skipWhitespace()
+ expect('{')
+ skipWhitespace()
+ require(!consume('}')) { "empty snapshot value" }
+ while (true) {
+ val name = readString()
+ require(members.add(name)) { "duplicate snapshot value member" }
+ skipWhitespace()
+ expect(':')
+ when (name) {
+ "raw" -> raw = readPortableJsonBytes(REMOTE_CONFIG_SNAPSHOT_VALUE_MAX_BYTES)
+ "variation_uid" -> variationUid = readStringValue()
+ "apply_policy" -> applyPolicy = when (readStringValue()) {
+ "on_next_activate" -> RemoteConfigSnapshotApplyPolicy.OnNextActivate
+ "immediate" -> RemoteConfigSnapshotApplyPolicy.Immediate
+ else -> error("unsupported apply policy")
+ }
+ "metadata" -> metadata = readPortableJsonBytes(REMOTE_CONFIG_SNAPSHOT_METADATA_MAX_BYTES)
+ else -> error("unknown snapshot value member")
+ }
+ skipWhitespace()
+ if (consume('}')) break
+ expect(',')
+ skipWhitespace()
+ }
+ return DecodedSnapshotValue(
+ key = key,
+ raw = requireNotNull(raw),
+ variationUid = requireNotNull(variationUid).also { require(it.isValidUid()) },
+ applyPolicy = requireNotNull(applyPolicy),
+ metadata = requireNotNull(metadata),
+ )
+ }
+
+ private fun readPortableJsonBytes(maxBytes: Int): ByteArray {
+ val start = index
+ val enclosingLimit = cursorLimit
+ val valueLimit = minOf(enclosingLimit, start + maxBytes)
+ cursorLimit = valueLimit
+ val value = try {
+ skipWhitespace()
+ readPortableJsonValue(depth = 1)
+ skipWhitespace()
+ require(index > start) { "empty JSON value" }
+ bytes.copyOfRange(start, index)
+ } finally {
+ cursorLimit = enclosingLimit
+ }
+ if (index == valueLimit && valueLimit < enclosingLimit) {
+ require(peekCharacter() == ',' || peekCharacter() == '}' || peekCharacter() == ']') {
+ "JSON value exceeds byte limit"
+ }
+ }
+ return value
+ }
+
+ @Suppress("ReturnCount")
+ private fun readPortableJsonValue(depth: Int) {
+ when (peekCharacter()) {
+ '{' -> {
+ require(depth <= REMOTE_CONFIG_SNAPSHOT_JSON_MAX_DEPTH) { "JSON is too deep" }
+ expect('{')
+ skipWhitespace()
+ val members = mutableSetOf()
+ if (consume('}')) return
+ while (true) {
+ val name = readString()
+ require(members.add(name)) { "duplicate JSON member" }
+ skipWhitespace()
+ expect(':')
+ skipWhitespace()
+ readPortableJsonValue(depth + 1)
+ skipWhitespace()
+ if (consume('}')) return
+ expect(',')
+ skipWhitespace()
+ }
+ }
+ '[' -> {
+ require(depth <= REMOTE_CONFIG_SNAPSHOT_JSON_MAX_DEPTH) { "JSON is too deep" }
+ expect('[')
+ skipWhitespace()
+ if (consume(']')) return
+ while (true) {
+ readPortableJsonValue(depth + 1)
+ skipWhitespace()
+ if (consume(']')) return
+ expect(',')
+ skipWhitespace()
+ }
+ }
+ '"' -> readString()
+ 't' -> expectLiteral("true")
+ 'f' -> expectLiteral("false")
+ 'n' -> expectLiteral("null")
+ else -> validatePortableNumber(readNumber())
+ }
+ }
+
+ private fun readStringValue(): String {
+ skipWhitespace()
+ return readString()
+ }
+
+ private fun readBooleanValue(): Boolean {
+ skipWhitespace()
+ return when (peekCharacter()) {
+ 't' -> true.also { expectLiteral("true") }
+ 'f' -> false.also { expectLiteral("false") }
+ else -> error("expected boolean")
+ }
+ }
+
+ private fun readExactInt(): Int {
+ val value = readExactLong()
+ require(value in Int.MIN_VALUE..Int.MAX_VALUE)
+ return value.toInt()
+ }
+
+ private fun readExactLong(): Long {
+ skipWhitespace()
+ val token = readNumber()
+ require(token.none { it == '.' || it == 'e' || it == 'E' }) { "expected integer" }
+ require(token.length <= PORTABLE_JSON_MAX_INTEGER_TOKEN_LENGTH) { "integer token is too long" }
+ val integer = BigInteger(token)
+ require(integer >= PORTABLE_JSON_MIN_INTEGER_BIG && integer <= PORTABLE_JSON_MAX_INTEGER_BIG)
+ return integer.toLong()
+ }
+
+ @Suppress("NestedBlockDepth")
+ private fun readString(): String {
+ val start = index
+ expect('"')
+ var escaped = false
+ while (index < cursorLimit) {
+ val byte = bytes[index].toInt() and BYTE_MASK
+ index++
+ if (escaped) {
+ when (byte.toChar()) {
+ '"', '\\', '/', 'b', 'f', 'n', 'r', 't' -> Unit
+ 'u' -> repeat(JSON_UNICODE_ESCAPE_HEX_DIGITS) {
+ require(index < cursorLimit && (bytes[index].toInt() and BYTE_MASK).isHexDigit())
+ index++
+ }
+ else -> error("invalid JSON escape")
+ }
+ escaped = false
+ } else {
+ when {
+ byte == '"'.code -> {
+ val quoted = bytes.copyOfRange(start, index)
+ val reader = JsonReader.of(Buffer().write(quoted)).apply { isLenient = false }
+ return reader.nextString().also {
+ require(reader.peek() == JsonReader.Token.END_DOCUMENT)
+ require(it.hasValidSurrogatePairs()) { "unpaired JSON string surrogate" }
+ }
+ }
+ byte == '\\'.code -> escaped = true
+ byte < JSON_CONTROL_CHARACTER_LIMIT -> error("unescaped JSON control character")
+ }
+ }
+ }
+ error("unterminated JSON string")
+ }
+
+ private fun readNumber(): String {
+ val start = index
+ consume('-')
+ when {
+ consume('0') -> require(!peekCharacterOrNull().isDigit()) { "leading zero" }
+ peekCharacter() in '1'..'9' -> while (peekCharacterOrNull().isDigit()) index++
+ else -> error("invalid JSON number")
+ }
+ if (consume('.')) {
+ require(peekCharacterOrNull().isDigit()) { "missing fraction" }
+ while (peekCharacterOrNull().isDigit()) index++
+ }
+ if (peekCharacterOrNull() == 'e' || peekCharacterOrNull() == 'E') {
+ index++
+ if (peekCharacterOrNull() == '+' || peekCharacterOrNull() == '-') index++
+ require(peekCharacterOrNull().isDigit()) { "missing exponent" }
+ while (peekCharacterOrNull().isDigit()) index++
+ }
+ return bytes.copyOfRange(start, index).toString(StandardCharsets.US_ASCII)
+ }
+
+ private fun expectLiteral(literal: String) {
+ for (character in literal) expect(character)
+ }
+
+ private fun expect(character: Char) {
+ require(index < cursorLimit && bytes[index].toInt() and BYTE_MASK == character.code) {
+ "expected $character"
+ }
+ index++
+ }
+
+ private fun consume(character: Char): Boolean {
+ if (index >= cursorLimit || bytes[index].toInt() and BYTE_MASK != character.code) return false
+ index++
+ return true
+ }
+
+ private fun skipWhitespace() {
+ while (index < cursorLimit && when (bytes[index].toInt() and BYTE_MASK) {
+ ' '.code, '\t'.code, '\r'.code, '\n'.code -> true
+ else -> false
+ }
+ ) {
+ index++
+ }
+ }
+
+ private fun peekCharacter(): Char = peekCharacterOrNull() ?: error("unexpected end of JSON")
+
+ private fun peekCharacterOrNull(): Char? =
+ if (index < cursorLimit) (bytes[index].toInt() and BYTE_MASK).toChar() else null
+}
+
+private fun RemoteConfigSnapshotEnvelopeExpectation.isValid(): Boolean =
+ projectId in 1..PORTABLE_JSON_MAX_INTEGER && environmentUid.isValidUid()
+
+private fun String.isValidUid(): Boolean =
+ isNotEmpty() && hasValidSurrogatePairs() && codePointCount(0, length) <= REMOTE_CONFIG_SNAPSHOT_UID_MAX_CODE_POINTS
+
+@Suppress("ReturnCount")
+private fun String.hasValidSurrogatePairs(): Boolean {
+ var index = 0
+ while (index < length) {
+ when {
+ this[index].isHighSurrogate() -> {
+ if (index + 1 >= length || !this[index + 1].isLowSurrogate()) return false
+ index += 2
+ }
+ this[index].isLowSurrogate() -> return false
+ else -> index++
+ }
+ }
+ return true
+}
+
+private fun validatePortableNumber(token: String) {
+ if (token.none { it == '.' || it == 'e' || it == 'E' }) {
+ require(token.length <= PORTABLE_JSON_MAX_INTEGER_TOKEN_LENGTH) { "integer token is too long" }
+ val integer = BigInteger(token)
+ require(integer >= PORTABLE_JSON_MIN_INTEGER_BIG && integer <= PORTABLE_JSON_MAX_INTEGER_BIG) {
+ "JSON integer is outside the portable range"
+ }
+ }
+ require(token.toDouble().isFinite()) { "JSON number is not finite binary64" }
+}
+
+internal fun remoteConfigStrongETagDigest(body: ByteArray, etag: String): String? {
+ val digest = etag
+ .takeIf { it.length == SHA256_ETAG_LENGTH && it.first() == '"' && it.last() == '"' }
+ ?.substring(1, etag.length - 1)
+ ?.takeIf(LOWERCASE_SHA256_PATTERN::matches)
+ ?: return null
+ return digest.takeIf { body.sha256Hex() == it }
+}
+
+private fun ByteArray.isStrictUtf8(): Boolean = try {
+ StandardCharsets.UTF_8.newDecoder()
+ .onMalformedInput(CodingErrorAction.REPORT)
+ .onUnmappableCharacter(CodingErrorAction.REPORT)
+ .decode(ByteBuffer.wrap(this))
+ true
+} catch (_: Exception) {
+ false
+}
+
+private fun ByteArray.sha256Hex(): String = MessageDigest.getInstance("SHA-256").digest(this).toLowercaseHex()
+
+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 Char?.isDigit(): Boolean = this != null && this in '0'..'9'
+private fun Int.isHexDigit(): Boolean = this in '0'.code..'9'.code || this in 'a'.code..'f'.code ||
+ this in 'A'.code..'F'.code
+
+private const val BYTE_MASK = 0xff
+private const val JSON_CONTROL_CHARACTER_LIMIT = 0x20
+private const val HEX_HIGH_NIBBLE_SHIFT = 4
+private const val HEX_NIBBLE_MASK = 0x0f
+private const val HEX = "0123456789abcdef"
+private const val SHA256_ETAG_LENGTH = 66
+private const val PORTABLE_JSON_MAX_INTEGER_TOKEN_LENGTH = 17
+private const val JSON_UNICODE_ESCAPE_HEX_DIGITS = 4
diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigTelemetry.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigTelemetry.kt
new file mode 100644
index 000000000..475b63e45
--- /dev/null
+++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigTelemetry.kt
@@ -0,0 +1,1005 @@
+package com.qonversion.android.sdk.internal.remoteconfig
+
+import com.qonversion.android.sdk.internal.storage.Cache
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+import com.squareup.moshi.Moshi
+import java.nio.ByteBuffer
+import java.security.MessageDigest
+import java.util.concurrent.Executor
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicLong
+
+internal const val REMOTE_CONFIG_TELEMETRY_MAX_ATTEMPTS = 3
+internal const val REMOTE_CONFIG_TELEMETRY_INITIAL_RETRY_DELAY_MILLIS = 1_000L
+internal const val REMOTE_CONFIG_TELEMETRY_MAXIMUM_RETRY_DELAY_MILLIS = 30_000L
+
+/** The coalescing map is bounded: a distinct entry beyond this is dropped, never queued. */
+internal const val REMOTE_CONFIG_TELEMETRY_MAX_ENTRIES = 64
+internal const val REMOTE_CONFIG_TELEMETRY_FLUSH_THRESHOLD = 10
+internal const val REMOTE_CONFIG_TELEMETRY_MAX_BATCH_EVENTS = 50
+internal const val REMOTE_CONFIG_TELEMETRY_TICK_MILLIS = 30_000L
+internal const val REMOTE_CONFIG_TELEMETRY_LOGICAL_KEY_MAX_BYTES = 200
+internal const val REMOTE_CONFIG_TELEMETRY_MAX_COUNT = 100_000L
+
+/**
+ * Flush hygiene: an event this old, or this far in the future, is dropped when the batch is built.
+ *
+ * The gateway refuses a batch containing one out-of-window timestamp — and a `400` drops the WHOLE
+ * batch permanently. A buffer that survived a month of offline process starts, or a device whose
+ * clock jumped, must therefore not be able to poison every healthy event travelling with it. The
+ * age bound is deliberately inside the server's 30-day window.
+ */
+internal const val REMOTE_CONFIG_TELEMETRY_MAX_AGE_SECONDS = 29L * 24 * 60 * 60
+internal const val REMOTE_CONFIG_TELEMETRY_MAX_SKEW_SECONDS = 30L
+
+/**
+ * The wall clock is not trusted below this (≈ 2020-09).
+ *
+ * A device whose RTC has not been set yet reads somewhere near the epoch, which would make EVERY
+ * buffered event look future-skewed and discard the whole buffer at the first flush. A clock this
+ * implausible suspends pruning AND flushing until it becomes real, rather than being believed.
+ */
+internal const val REMOTE_CONFIG_TELEMETRY_CLOCK_FLOOR_SECONDS = 1_600_000_000L
+
+/**
+ * How many events may be held before any identity is bound.
+ *
+ * The read guard claims each of its events once per process, so an event produced before the first
+ * `identify` — `read_before_activate` above all — is not merely delayed but lost for good if it is
+ * dropped here. A small drop-oldest ring keeps it, without letting an app that never binds grow
+ * memory.
+ */
+internal const val REMOTE_CONFIG_TELEMETRY_MAX_PRE_BIND_EVENTS = 16
+
+/**
+ * The durable record's byte budget.
+ *
+ * Sized for the composite bound the contract pins — the coalescing map
+ * ([REMOTE_CONFIG_TELEMETRY_MAX_ENTRIES]) plus one in-flight batch
+ * ([REMOTE_CONFIG_TELEMETRY_MAX_BATCH_EVENTS]) — with every logical key at the contract's
+ * [REMOTE_CONFIG_TELEMETRY_LOGICAL_KEY_MAX_BYTES] maximum.
+ *
+ * This implementation reaches that durability with a record that never exceeds the MAP, because an
+ * in-flight batch is not removed from the map when it is dispatched: it is settled only when the
+ * gateway answers. Every flush is therefore preceded by a write of a buffer that already contains
+ * the batch, and a crash mid-flight loses nothing beyond the at-least-once redelivery the contract
+ * already accepts. The budget is sized for the pinned bound regardless, because a budget that
+ * silently turns a save into a no-op is the failure mode worth designing out.
+ */
+internal const val REMOTE_CONFIG_TELEMETRY_MAX_BYTES = 64 * 1024
+
+private const val REMOTE_CONFIG_TELEMETRY_PREFIX = "qonversion_remote_config_v2_telemetry_"
+private const val REMOTE_CONFIG_TELEMETRY_VERSION = 1
+private const val MILLIS_PER_SECOND = 1_000L
+private const val MINIMUM_RETRY_DELAY_MILLIS = 1L
+private const val SAFE_FALLBACK_JITTER = 0.5
+
+/**
+ * The closed set of client telemetry kinds, exactly as the gateway spells them.
+ *
+ * The wire name is the contract: an unknown kind makes the gateway refuse the WHOLE batch with a
+ * terminal 400, so the mapping lives here once rather than at every production site.
+ */
+internal enum class RemoteConfigTelemetryKind(val wireName: String) {
+ DecodeFailure("decode_failure"),
+ ReadBeforeActivate("read_before_activate"),
+ ImplicitActivation("implicit_activation"),
+ PreloadFailed("preload_failed"),
+ PreloadCorrupt("preload_corrupt"),
+ ActivationPersistenceFailed("activation_persistence_failed"),
+ ;
+
+ /** Only a decode failure names a key; every other kind MUST omit it. */
+ internal val carriesLogicalKey: Boolean get() = this == DecodeFailure
+
+ internal companion object {
+ fun fromWireName(wireName: String): RemoteConfigTelemetryKind? =
+ values().firstOrNull { it.wireName == wireName }
+ }
+}
+
+/**
+ * What distinguishes one coalescing bucket from another.
+ *
+ * The release number is deliberately NOT part of it: the gateway refuses a whole batch that carries
+ * two events with the same (kind, logical_key), and a release rollover between two flushes is
+ * exactly how a client would otherwise produce that pair. One bucket per (kind, key) therefore
+ * makes the in-batch uniqueness the contract demands structural rather than incidental — the bucket
+ * carries the release of its most recent occurrence instead.
+ */
+internal data class RemoteConfigTelemetryEventIdentity(
+ val kind: RemoteConfigTelemetryKind,
+ val logicalKey: String,
+)
+
+/** One coalesced bucket: how often it happened, when it last did, and under which release. */
+internal data class RemoteConfigTelemetryEvent(
+ val kind: RemoteConfigTelemetryKind,
+ val logicalKey: String,
+ val releaseNumber: Long,
+ val count: Long,
+ val lastOccurredAtSeconds: Long,
+) {
+ internal val identity: RemoteConfigTelemetryEventIdentity
+ get() = RemoteConfigTelemetryEventIdentity(kind, logicalKey)
+
+ /**
+ * Whether the gateway would accept this event.
+ *
+ * Validated locally because the batch is rejected as a whole: one over-long logical key would
+ * cost every other event in the same POST, so an unsendable event is dropped at the source.
+ */
+ internal fun isValid(): Boolean = releaseNumber >= 0 &&
+ count in 1..REMOTE_CONFIG_TELEMETRY_MAX_COUNT &&
+ lastOccurredAtSeconds > 0 &&
+ if (kind.carriesLogicalKey) logicalKey.isSendableLogicalKey() else logicalKey.isEmpty()
+
+ /**
+ * Whether the gateway would accept this string as a `logical_key`.
+ *
+ * Validated on the UTF-8 BYTES, not on UTF-16 units, because that is the unit the server bounds
+ * and rejects on: a key of 150 emoji is 150 units here and 600 bytes there, and a local check in
+ * the wrong unit is exactly how a batch reaches the wire and comes back as a terminal 400.
+ *
+ * A string carrying an unpaired surrogate is refused rather than sent. `toByteArray` transcodes
+ * one to `?` silently, so it would otherwise pass every byte-level check while the value that
+ * reached the server was not the key the app actually read.
+ */
+ private fun String.isSendableLogicalKey(): Boolean {
+ val bytes = toByteArray(Charsets.UTF_8)
+ return bytes.size in 1..REMOTE_CONFIG_TELEMETRY_LOGICAL_KEY_MAX_BYTES &&
+ bytes.none { byte -> byte >= 0 && (byte < CONTROL_BYTE_MAX || byte == DELETE_BYTE) } &&
+ bytes.toString(Charsets.UTF_8) == this
+ }
+
+ /**
+ * Folds [other] — an occurrence of the same (kind, logical_key) — into this bucket.
+ *
+ * Counts add, and the newer occurrence wins the release number and the timestamp: the batch may
+ * carry only one event per (kind, key), and the release the LAST failure was served under is
+ * the one the dashboard has to act on.
+ */
+ internal fun coalescedWith(other: RemoteConfigTelemetryEvent): RemoteConfigTelemetryEvent {
+ val total = (count + other.count).coerceAtMost(REMOTE_CONFIG_TELEMETRY_MAX_COUNT)
+ val newest = if (other.lastOccurredAtSeconds >= lastOccurredAtSeconds) other else this
+ return newest.copy(count = total)
+ }
+
+ private companion object {
+ const val CONTROL_BYTE_MAX: Byte = 0x20
+ const val DELETE_BYTE: Byte = 0x7f
+ }
+}
+
+/** The durable telemetry buffer of one identity scope. */
+internal interface RemoteConfigTelemetryStore {
+ fun load(scope: RemoteConfigSnapshotScope): List
+ fun save(scope: RemoteConfigSnapshotScope, events: List): Boolean
+ fun clear(scope: RemoteConfigSnapshotScope): Boolean
+}
+
+/**
+ * Posts one batch out of band.
+ *
+ * The response vocabulary is [RemoteConfigAckResponse] rather than a private one: the telemetry
+ * route mirrors the activation ack leg exactly — 2xx delivered, 400/404 permanent, 429/5xx
+ * retryable, and "the transport no longer addresses this identity" costs no retry budget.
+ */
+internal fun interface RemoteConfigTelemetryTransport {
+ fun postTelemetry(
+ scope: RemoteConfigSnapshotScope,
+ events: List,
+ completion: (RemoteConfigAckResponse) -> Unit,
+ )
+}
+
+/**
+ * Reports client-side Remote Config health — decode failures and read-guard events — to the gateway.
+ *
+ * Hard rules, in the order they matter:
+ * 1. **It can never touch the config data path.** Every production site only enqueues into an
+ * in-memory map and returns; nothing here decodes a value, blocks a read, delays an activation
+ * or feeds the fetch policy. Every failure is silent, and the only externally visible trace is
+ * [droppedEventCount], a counter rather than a log line.
+ * 2. **Memory is bounded, always.** At most [maxEntries] distinct (kind, key) buckets are held, plus
+ * a [maxPreBindEvents] ring for what happened before the first identity bound; a further distinct
+ * bucket is dropped rather than queued, so a pathological app that misdecodes a thousand keys
+ * costs 64 entries, not a thousand. Every drop, on every path, is counted in
+ * [droppedEventCount] — a silent loss is a bug, a counted one is a measurement.
+ * 3. **A queued batch is durable, and storage is touched as rarely as it can be.** The buffer is
+ * written off the caller's thread and reloaded on the next process start, so events survive a
+ * process death — but a write only happens when the buffer actually CHANGED shape (a new bucket,
+ * a settled batch, a prune). A counter-only bump never reaches storage: the worker doing that
+ * write is the same single thread the snapshot preloader and the manager run on, and a
+ * synchronous preferences commit per config read would starve the config path through its queue.
+ * 4. **Retries are bounded per process.** [maxAttempts] attempts with exponentially growing,
+ * jittered delays, then delivery is abandoned for the lifetime of the process for that identity.
+ * The durable buffer survives the abandonment, so the next process start tries once more.
+ * 5. **A 401 never invalidates the session.** The stored session belongs to the config read path;
+ * an out-of-band signal may not forget it (the transport enforces this, see
+ * `RemoteConfigGatewayTransport.postTelemetry`).
+ *
+ * The whole object only exists when the app configured Remote Config v2 (see
+ * [RemoteConfigV2Factory]), which is what keeps the feature dormant otherwise.
+ */
+@Suppress("LongParameterList", "TooManyFunctions")
+internal class RemoteConfigTelemetrySender(
+ private val transport: RemoteConfigTelemetryTransport,
+ private val store: RemoteConfigTelemetryStore,
+ private val clock: RemoteConfigFetchClock,
+ private val random: RemoteConfigFetchRandom,
+ private val scheduler: RemoteConfigFetchScheduler,
+ private val executor: Executor,
+ private val maxAttempts: Int = REMOTE_CONFIG_TELEMETRY_MAX_ATTEMPTS,
+ private val initialRetryDelayMillis: Long = REMOTE_CONFIG_TELEMETRY_INITIAL_RETRY_DELAY_MILLIS,
+ private val maximumRetryDelayMillis: Long = REMOTE_CONFIG_TELEMETRY_MAXIMUM_RETRY_DELAY_MILLIS,
+ private val maxEntries: Int = REMOTE_CONFIG_TELEMETRY_MAX_ENTRIES,
+ private val flushThreshold: Int = REMOTE_CONFIG_TELEMETRY_FLUSH_THRESHOLD,
+ private val maxBatchEvents: Int = REMOTE_CONFIG_TELEMETRY_MAX_BATCH_EVENTS,
+ private val tickIntervalMillis: Long = REMOTE_CONFIG_TELEMETRY_TICK_MILLIS,
+ private val maxPreBindEvents: Int = REMOTE_CONFIG_TELEMETRY_MAX_PRE_BIND_EVENTS,
+) {
+ private val lock = Any()
+ private val storeLock = Any()
+ private val dropped = AtomicLong()
+ private val drainScheduled = AtomicBoolean(false)
+ private val flushRequested = AtomicBoolean(false)
+ private val writeStamp = AtomicLong()
+
+ /** Insertion-ordered on purpose: the oldest bucket is the one a bounded batch takes first. */
+ private val entries = LinkedHashMap()
+
+ /**
+ * Events produced before any identity was bound, oldest first.
+ *
+ * They cannot be sent — there is no session to send them under — but they must not be discarded
+ * either: the read guard reports each of its events once per process, so a `read_before_activate`
+ * dropped here is a systematic under-count of the exact metric the panel exists for.
+ */
+ private val preBind = ArrayDeque()
+
+ /**
+ * Identities the gateway has permanently refused in this process.
+ *
+ * A `400` is deterministic for a given (kind, logical_key): re-sending it every tick would be an
+ * infinite request loop that also re-poisons every batch it travels in. Bounded and drop-oldest,
+ * because a set that grows with app behaviour is not a set, it is a leak.
+ */
+ private val poisoned = LinkedHashSet()
+
+ private var boundScope: RemoteConfigSnapshotScope? = null
+ private var generation = 0L
+ private var inFlight = false
+ private var attempt = 0
+ private var retryScheduled = false
+ private var retryTask: RemoteConfigFetchScheduledTask? = null
+ private var tickTask: RemoteConfigFetchScheduledTask? = null
+
+ /**
+ * The identity whose retry ladder this process already exhausted.
+ *
+ * In memory on purpose: the bound is per process, so a rebind must not buy the same buffer
+ * another three attempts against a gateway that is failing. A genuinely new process reads the
+ * still-buffered events and tries once more.
+ */
+ private var abandonedScope: RemoteConfigSnapshotScope? = null
+
+ /**
+ * What the durable record of a scope is known to hold, keyed by that scope.
+ *
+ * Per scope rather than globally because the stamp only orders writes that address the SAME
+ * preference key: one identity's newer write must not be able to suppress another identity's
+ * older-but-unwritten one. `events == null` means "a write for this stamp was attempted and
+ * failed", which forces the next identical buffer to try again instead of being skipped as
+ * clean. Guarded by [storeLock].
+ */
+ private val committed = LinkedHashMap()
+
+ /** Events abandoned without delivery, ever. Deliberately a counter and not a log. */
+ val droppedEventCount: Long get() = dropped.get()
+
+ /** How many distinct buckets are currently held. Diagnostics only. */
+ internal val pendingEntryCount: Int get() = synchronized(lock) { entries.size }
+
+ /**
+ * Binds the sender to [scope] and resumes whatever that identity still owes.
+ *
+ * Binding fences every in-flight and scheduled attempt of the previous scope, and replaces the
+ * in-memory buffer with the durable one: an event produced under one identity must never be
+ * reported under another identity's session.
+ */
+ fun bind(scope: RemoteConfigSnapshotScope?) {
+ if (synchronized(lock) { scope == boundScope }) return
+ // Storage is read OUTSIDE the lock, deliberately: a config read takes this same lock, and
+ // loading plus Moshi-parsing a record of up to 64 KiB would park that read behind an
+ // identity change for as long as the disk takes.
+ val persisted = scope?.let { loadEvents(it) }.orEmpty()
+ rememberBaseline(scope, persisted)
+ val resumed = synchronized(lock) {
+ // Re-checked under the lock: the load raced whatever else may have bound meanwhile.
+ if (scope == boundScope) return
+ invalidateLocked()
+ cancelTickLocked()
+ boundScope = scope
+ entries.clear()
+ // Folded rather than assigned: a buffer written by an older build could hold two rows
+ // for one (kind, key), and putting them in a map would silently lose a count.
+ persisted.forEach(::mergeLocked)
+ // Whatever happened before any identity existed belongs to the first one that does.
+ replayPreBindLocked()
+ if (entries.isNotEmpty()) armTickLocked()
+ entries.isNotEmpty()
+ }
+ if (resumed) scheduleDrain(flush = true)
+ }
+
+ /**
+ * Records what storage is known to hold for [scope], so an unchanged buffer is never rewritten.
+ *
+ * Called before the bind installs the loaded events: everything the sender persists afterwards
+ * is compared against this, and a resume that changes nothing costs no disk write at all.
+ */
+ private fun rememberBaseline(scope: RemoteConfigSnapshotScope?, events: List) {
+ if (scope == null) return
+ synchronized(storeLock) { rememberLocked(scope, writeStamp.incrementAndGet(), events) }
+ }
+
+ /** Folds one event into the coalescing map, honouring the bound and the poison set. */
+ private fun mergeLocked(event: RemoteConfigTelemetryEvent) {
+ if (event.identity in poisoned) {
+ dropped.addAndGet(event.count)
+ return
+ }
+ val existing = entries[event.identity]
+ if (existing == null && entries.size >= maxEntries) {
+ dropped.addAndGet(event.count)
+ return
+ }
+ entries[event.identity] = existing?.coalescedWith(event) ?: event
+ }
+
+ private fun replayPreBindLocked() {
+ val buffered = preBind.toList()
+ preBind.clear()
+ buffered.forEach(::mergeLocked)
+ }
+
+ /**
+ * Records one read-guard event.
+ *
+ * Called from the read path (and from the preloader completion): it only touches an in-memory
+ * map and returns. Nothing is persisted or sent on the caller's thread.
+ */
+ fun record(event: RemoteConfigReadGuardEvent) {
+ // Not every guard event is a defect: see [toTelemetryKind].
+ val kind = event.toTelemetryKind() ?: return
+ enqueue(kind, logicalKey = "", releaseNumber = 0)
+ }
+
+ /**
+ * Records that a typed decode of [logicalKey] failed while serving release [releaseNumber].
+ *
+ * Produced from the snapshot read site itself, which is why it may do nothing but enqueue: the
+ * resolution ladder keeps walking and the value the caller receives is unaffected.
+ */
+ fun recordDecodeFailure(logicalKey: String, releaseNumber: Long) =
+ enqueue(RemoteConfigTelemetryKind.DecodeFailure, logicalKey, releaseNumber)
+
+ /**
+ * Records that the fetch policy bookkeeping could not be persisted.
+ *
+ * Folded into `activation_persistence_failed` because the closed wire enum has exactly one
+ * "the SDK could not persist its state" kind, and that is the signal the dashboard acts on.
+ */
+ fun recordPolicyPersistenceFailure() =
+ enqueue(RemoteConfigTelemetryKind.ActivationPersistenceFailed, logicalKey = "", releaseNumber = 0)
+
+ /**
+ * Opportunistic flush after a fetch the gateway answered.
+ *
+ * The connection is warm and the session is known-good, so this is the cheapest moment to
+ * deliver whatever has accumulated.
+ */
+ fun onSuccessfulFetch() {
+ if (pendingEntryCount == 0) return
+ scheduleDrain(flush = true)
+ }
+
+ @Suppress("ReturnCount")
+ private fun enqueue(kind: RemoteConfigTelemetryKind, logicalKey: String, releaseNumber: Long) {
+ var isNewEntry = false
+ val flushDue = synchronized(lock) {
+ val occurrence = RemoteConfigTelemetryEvent(
+ kind = kind,
+ logicalKey = logicalKey,
+ // The bucket carries the release of its MOST RECENT occurrence, which is the same
+ // rule the server applies when it merges an incoming event into a stored row.
+ releaseNumber = releaseNumber.coerceAtLeast(0),
+ count = 1,
+ lastOccurredAtSeconds = nowSeconds(),
+ )
+ // Unsendable by contract, or already refused for good: queueing either would cost the
+ // whole batch a terminal 400 — the second one on every tick, forever.
+ if (!occurrence.isValid() || occurrence.identity in poisoned) {
+ dropped.incrementAndGet()
+ return
+ }
+ // Unbound means un-addressable: there is no session to report under. The event is held
+ // in a bounded ring instead of thrown away, and belongs to whichever identity binds
+ // first — a guard event is produced once per process and has no second chance.
+ if (boundScope == null) {
+ bufferBeforeBindLocked(occurrence)
+ return
+ }
+ val existing = entries[occurrence.identity]
+ if (existing == null && entries.size >= maxEntries) {
+ dropped.incrementAndGet()
+ return
+ }
+ entries[occurrence.identity] = existing?.coalescedWith(occurrence) ?: occurrence
+ isNewEntry = existing == null
+ armTickLocked()
+ // LATCHED on a new bucket. Testing only `size >= flushThreshold` would make every
+ // counter-only bump past the tenth bucket schedule a drain — and therefore a
+ // synchronous preferences commit — on the worker the config path shares.
+ isNewEntry && entries.size >= flushThreshold
+ }
+ if (isNewEntry) scheduleDrain(flush = flushDue)
+ }
+
+ private fun bufferBeforeBindLocked(event: RemoteConfigTelemetryEvent) {
+ while (preBind.size >= maxPreBindEvents) {
+ // Drop-oldest: the newest evidence of a still-broken key is worth more than the oldest.
+ dropped.addAndGet(preBind.removeFirst().count)
+ }
+ preBind.addLast(event)
+ }
+
+ /**
+ * Hands the durable write — and, when one is due, the flush — to [executor].
+ *
+ * Coalesced through [drainScheduled] so a burst of reads that each produce an event still costs
+ * at most one queued task.
+ */
+ private fun scheduleDrain(flush: Boolean) {
+ if (flush) flushRequested.set(true)
+ if (!drainScheduled.compareAndSet(false, true)) return
+ val submitted = try {
+ executor.execute(::drain)
+ true
+ } catch (@Suppress("TooGenericExceptionCaught") _: RuntimeException) {
+ false
+ }
+ // A shut-down worker simply means this drain is not taken; the buffer stays in memory and
+ // the next record (or the next process) persists it.
+ if (!submitted) drainScheduled.set(false)
+ }
+
+ private fun drain() {
+ drainScheduled.set(false)
+ persistCurrentBuffer()
+ if (flushRequested.getAndSet(false)) startIfIdle()
+ }
+
+ private fun persistCurrentBuffer() {
+ val write = synchronized(lock) { prepareWriteLocked() }
+ commit(write)
+ }
+
+ private fun startIfIdle() {
+ val outcome = synchronized(lock) { claimAttemptLocked() }
+ // The prune is committed BEFORE the batch goes out: an all-stale record that is only pruned
+ // in memory comes back on the next start, is pruned again, and inflates the drop counter
+ // once per process for the rest of the installation's life.
+ commit(outcome.write)
+ outcome.attempt?.let(::dispatch)
+ }
+
+ @Suppress("ReturnCount")
+ private fun claimAttemptLocked(): AttemptOutcome {
+ val scope = boundScope ?: return AttemptOutcome()
+ if (inFlight || retryScheduled) return AttemptOutcome()
+ if (scope == abandonedScope) return AttemptOutcome()
+ val draft = prepareBatchLocked()
+ val batch = draft.batch ?: return AttemptOutcome(write = draft.write)
+ inFlight = true
+ attempt = 1
+ return AttemptOutcome(Attempt(generation, scope, batch), draft.write)
+ }
+
+ /**
+ * Builds the next batch, dropping anything the gateway would refuse on sight.
+ *
+ * The pruning is the point: a `400` is terminal for the WHOLE batch, so a single event whose
+ * timestamp fell out of the server's window — a buffer that survived a month of offline starts,
+ * or a device whose clock jumped forward — would take every healthy event with it. What it
+ * removes is handed back as a durable write, because a prune that only happens in memory is a
+ * record that never dies.
+ */
+ private fun prepareBatchLocked(): BatchDraft {
+ val now = nowSeconds()
+ if (now < REMOTE_CONFIG_TELEMETRY_CLOCK_FLOOR_SECONDS) {
+ // An unset RTC would classify EVERY buffered event as future-skewed and throw the whole
+ // buffer away. A clock we cannot believe suspends both the prune and the flush; the
+ // tick keeps asking until it becomes real.
+ if (entries.isNotEmpty()) armTickLocked()
+ return BatchDraft()
+ }
+ var prunedOccurrences = 0L
+ entries.entries.removeAll { (_, event) ->
+ val stale = event.lastOccurredAtSeconds < now - REMOTE_CONFIG_TELEMETRY_MAX_AGE_SECONDS ||
+ event.lastOccurredAtSeconds > now + REMOTE_CONFIG_TELEMETRY_MAX_SKEW_SECONDS
+ if (stale) prunedOccurrences += event.count
+ stale
+ }
+ if (prunedOccurrences > 0) dropped.addAndGet(prunedOccurrences)
+ if (entries.isEmpty()) cancelTickLocked()
+ return BatchDraft(
+ batch = entries.values.take(maxBatchEvents).takeIf { it.isNotEmpty() },
+ write = if (prunedOccurrences > 0) prepareWriteLocked() else null,
+ )
+ }
+
+ private class BatchDraft(
+ val batch: List? = null,
+ val write: PendingWrite? = null,
+ )
+
+ private class AttemptOutcome(
+ val attempt: Attempt? = null,
+ val write: PendingWrite? = null,
+ )
+
+ private fun dispatch(sending: Attempt) {
+ try {
+ transport.postTelemetry(sending.scope, sending.events) { response -> onResponse(sending, response) }
+ } catch (@Suppress("TooGenericExceptionCaught") _: Throwable) {
+ onResponse(sending, RemoteConfigAckResponse.Retryable)
+ }
+ }
+
+ @Suppress("ReturnCount")
+ private fun onResponse(sent: Attempt, response: RemoteConfigAckResponse) {
+ if (!sent.claim()) return
+ var retryDelayMillis: Long? = null
+ var hasMore = false
+ val write = synchronized(lock) {
+ // A bind happened while this batch was on the wire: its answer says nothing about the
+ // identity the sender addresses now.
+ if (sent.generation != generation || sent.scope != boundScope) return
+ inFlight = false
+ val write = when (response) {
+ RemoteConfigAckResponse.Delivered -> settleLocked(sent)
+ // A terminal 400 family answer: the batch can only ever be refused again.
+ RemoteConfigAckResponse.Permanent -> poisonLocked(sent)
+ // Not an attempt: the retry budget is untouched and the buffer stays queued.
+ RemoteConfigAckResponse.NotAddressable -> null
+ RemoteConfigAckResponse.Retryable -> if (attempt >= maxAttempts) {
+ // Owed but abandoned for this process; the durable buffer is left untouched so
+ // the next process start delivers it.
+ abandonedScope = sent.scope
+ null
+ } else {
+ retryDelayMillis = retryDelayLocked(attempt)
+ null
+ }
+ }
+ retryDelayMillis?.let { scheduleRetryLocked(it) }
+ hasMore = write != null && entries.isNotEmpty()
+ write
+ }
+ commit(write)
+ // The map is bounded at 64 and a batch carries 50, so this recurses at most once.
+ if (hasMore) startIfIdle()
+ }
+
+ /**
+ * Removes exactly what the gateway answered for.
+ *
+ * Occurrences that arrived WHILE the batch was on the wire are kept: the bucket is decremented
+ * by the reported count instead of being cleared, so a decode failure that keeps happening is
+ * still reported by the next batch.
+ */
+ private fun settleLocked(sent: Attempt): PendingWrite? {
+ sent.events.forEach { event ->
+ val current = entries[event.identity] ?: return@forEach
+ if (current.count <= event.count) {
+ entries.remove(event.identity)
+ } else {
+ entries[event.identity] = current.copy(count = current.count - event.count)
+ }
+ }
+ if (entries.isEmpty()) cancelTickLocked()
+ return prepareWriteLocked()
+ }
+
+ /**
+ * Retires a permanently refused batch, and everything that shares its identities.
+ *
+ * The bucket is removed WHOLE — including occurrences that accrued while the batch was on the
+ * wire — and its identity is remembered as poisoned. Decrementing instead would leave a residue
+ * that re-flushes on the very next tick and is refused again, forever: a `400` is deterministic
+ * for a given (kind, logical_key), so the only way to stop asking is to stop asking.
+ */
+ private fun poisonLocked(sent: Attempt): PendingWrite? {
+ sent.events.forEach { event ->
+ rememberPoisonLocked(event.identity)
+ val current = entries.remove(event.identity)
+ dropped.addAndGet(current?.count ?: event.count)
+ }
+ if (entries.isEmpty()) cancelTickLocked()
+ return prepareWriteLocked()
+ }
+
+ private fun rememberPoisonLocked(identity: RemoteConfigTelemetryEventIdentity) {
+ // Bounded and drop-oldest: a set that grows with app behaviour is not a set, it is a leak.
+ while (poisoned.size >= maxEntries) {
+ poisoned.remove(poisoned.first())
+ }
+ poisoned += identity
+ }
+
+ private fun scheduleRetryLocked(delayMillis: Long) {
+ val scheduledGeneration = generation
+ retryScheduled = true
+ retryTask = try {
+ scheduler.schedule(delayMillis) { onRetryDue(scheduledGeneration) }
+ } catch (@Suppress("TooGenericExceptionCaught") _: Throwable) {
+ retryScheduled = false
+ null
+ }
+ }
+
+ private fun onRetryDue(scheduledGeneration: Long) {
+ val outcome = synchronized(lock) { claimRetryLocked(scheduledGeneration) }
+ commit(outcome.write)
+ outcome.attempt?.let(::dispatch)
+ }
+
+ @Suppress("ReturnCount")
+ private fun claimRetryLocked(scheduledGeneration: Long): AttemptOutcome {
+ if (scheduledGeneration != generation) return AttemptOutcome()
+ retryScheduled = false
+ retryTask = null
+ val scope = boundScope ?: return AttemptOutcome()
+ if (inFlight) return AttemptOutcome()
+ val draft = prepareBatchLocked()
+ val batch = draft.batch ?: return AttemptOutcome(write = draft.write)
+ attempt += 1
+ inFlight = true
+ return AttemptOutcome(Attempt(generation, scope, batch), draft.write)
+ }
+
+ /**
+ * Arms the periodic flush, but only while something is actually buffered: an idle SDK must not
+ * wake a thread every 30 seconds for an empty batch.
+ */
+ private fun armTickLocked() {
+ if (tickTask != null) return
+ val scheduledGeneration = generation
+ tickTask = try {
+ scheduler.schedule(tickIntervalMillis) { onTickDue(scheduledGeneration) }
+ } catch (@Suppress("TooGenericExceptionCaught") _: Throwable) {
+ null
+ }
+ }
+
+ private fun onTickDue(scheduledGeneration: Long) {
+ val due = synchronized(lock) {
+ if (scheduledGeneration != generation) return
+ tickTask = null
+ if (entries.isEmpty()) return
+ armTickLocked()
+ true
+ }
+ if (due) scheduleDrain(flush = true)
+ }
+
+ private fun cancelTickLocked() {
+ try {
+ tickTask?.cancel()
+ } catch (@Suppress("TooGenericExceptionCaught") _: Throwable) {
+ // Generation fencing, not cancellation, is what makes a stale timer harmless.
+ }
+ tickTask = null
+ }
+
+ /**
+ * Fences everything in flight or scheduled.
+ *
+ * Only the in-memory delivery is invalidated; the durable buffer is untouched, because the
+ * events it holds are still owed by the identity that produced them.
+ */
+ private fun invalidateLocked() {
+ generation++
+ retryScheduled = false
+ try {
+ retryTask?.cancel()
+ } catch (@Suppress("TooGenericExceptionCaught") _: Throwable) {
+ // Generation fencing, not cancellation, is what makes a stale timer harmless.
+ }
+ retryTask = null
+ inFlight = false
+ attempt = 0
+ }
+
+ private fun retryDelayLocked(attemptOrdinal: Int): Long {
+ var cap = initialRetryDelayMillis
+ repeat((attemptOrdinal - 1).coerceAtLeast(0)) {
+ cap = if (cap >= maximumRetryDelayMillis / 2) {
+ maximumRetryDelayMillis
+ } else {
+ (cap * 2).coerceAtMost(maximumRetryDelayMillis)
+ }
+ }
+ val randomValue = try {
+ random.nextDouble()
+ } catch (@Suppress("TooGenericExceptionCaught") _: Throwable) {
+ SAFE_FALLBACK_JITTER
+ }
+ val jitter = randomValue.takeIf { it.isFinite() && it >= 0.0 && it < 1.0 } ?: SAFE_FALLBACK_JITTER
+ // Half the cap plus jitter, not full-downward jitter: the latter can put all three attempts
+ // inside a few milliseconds, which is the storm the bound exists to prevent.
+ val half = cap / 2
+ return (half + (half.toDouble() * jitter).toLong()).coerceAtLeast(MINIMUM_RETRY_DELAY_MILLIS)
+ }
+
+ private fun prepareWriteLocked(): PendingWrite? {
+ val scope = boundScope ?: return null
+ return PendingWrite(scope, entries.values.toList(), writeStamp.incrementAndGet())
+ }
+
+ /**
+ * Writes a prepared buffer, outside [lock] so a synchronous disk commit can never park a thread
+ * that is reading a config value.
+ *
+ * Two guards, both load-bearing:
+ * - the per-scope stamp keeps concurrent writers from committing out of order — a write prepared
+ * before the last committed one for the SAME scope is dropped rather than allowed to
+ * resurrect it;
+ * - the dirty check drops a write whose content the record already holds. That is what makes
+ * "storage is touched only when the buffer changed shape" true in practice, including on the
+ * drain that follows every flush trigger.
+ */
+ @Suppress("ReturnCount")
+ private fun commit(write: PendingWrite?) {
+ if (write == null) return
+ synchronized(storeLock) {
+ val previous = committed[write.scope]
+ if (previous != null && write.stamp <= previous.stamp) return
+ if (previous?.events == write.events) return
+ val written = try {
+ if (write.events.isEmpty()) store.clear(write.scope) else store.save(write.scope, write.events)
+ } catch (@Suppress("TooGenericExceptionCaught") _: Throwable) {
+ // The in-memory buffer still governs this process; a lost write can at worst cost
+ // duplicated counts after a restart, which the aggregate storage tolerates.
+ false
+ }
+ // A failed write records the stamp but NOT the content, so the next identical buffer is
+ // still considered dirty and tries again instead of being skipped as clean.
+ rememberLocked(write.scope, write.stamp, write.events.takeIf { written })
+ }
+ }
+
+ private fun rememberLocked(
+ scope: RemoteConfigSnapshotScope,
+ stamp: Long,
+ events: List?,
+ ) {
+ // Bounded: an app that identifies through many users must not accumulate one record per
+ // identity it has ever seen.
+ while (committed.size >= COMMITTED_HISTORY && !committed.containsKey(scope)) {
+ committed.remove(committed.keys.first())
+ }
+ committed[scope] = CommittedRecord(stamp, events)
+ }
+
+ private class PendingWrite(
+ val scope: RemoteConfigSnapshotScope,
+ val events: List,
+ val stamp: Long,
+ )
+
+ private class CommittedRecord(
+ val stamp: Long,
+ val events: List?,
+ )
+
+ private fun loadEvents(scope: RemoteConfigSnapshotScope): List = try {
+ store.load(scope).filter { it.isValid() }.take(maxEntries)
+ } catch (@Suppress("TooGenericExceptionCaught") _: Throwable) {
+ emptyList()
+ }
+
+ /**
+ * The occurrence timestamp, floored at 1: a zero is indistinguishable from "absent" in the
+ * durable record and would make the buffered event silently un-persistable.
+ */
+ private fun nowSeconds(): Long = try {
+ clock.nowMillis().coerceAtLeast(0) / MILLIS_PER_SECOND
+ } catch (@Suppress("TooGenericExceptionCaught") _: Throwable) {
+ 0
+ }.coerceAtLeast(1)
+
+ /**
+ * One delivery attempt.
+ *
+ * [claim] makes the completion single-shot independently of the transport: a transport that
+ * both calls back and throws must not advance the retry budget twice.
+ */
+ private class Attempt(
+ val generation: Long,
+ val scope: RemoteConfigSnapshotScope,
+ val events: List,
+ ) {
+ private val answered = AtomicBoolean(false)
+
+ fun claim(): Boolean = answered.compareAndSet(false, true)
+ }
+
+ private companion object {
+ /** How many identity scopes keep durable-write bookkeeping. */
+ const val COMMITTED_HISTORY = 8
+ }
+}
+
+/**
+ * The read guard's vocabulary, translated into the closed wire enum — or into nothing.
+ *
+ * `PreloadNotReady` is deliberately NOT reported. It means the first read outran the preload, which
+ * is the ordinary first-launch state of every fresh install rather than a defect; mapping it onto
+ * `preload_failed` would make the one metric the dashboard alarms on fire for every install. Only a
+ * genuine failure to read persisted state — `PreloadFailed` — is a failure.
+ */
+internal fun RemoteConfigReadGuardEvent.toTelemetryKind(): RemoteConfigTelemetryKind? = when (this) {
+ RemoteConfigReadGuardEvent.ReadBeforeActivate -> RemoteConfigTelemetryKind.ReadBeforeActivate
+ RemoteConfigReadGuardEvent.ImplicitActivation -> RemoteConfigTelemetryKind.ImplicitActivation
+ RemoteConfigReadGuardEvent.PreloadNotReady -> null
+ RemoteConfigReadGuardEvent.PreloadFailed -> RemoteConfigTelemetryKind.PreloadFailed
+ RemoteConfigReadGuardEvent.PreloadCorrupt -> RemoteConfigTelemetryKind.PreloadCorrupt
+ RemoteConfigReadGuardEvent.ActivationPersistenceFailed -> RemoteConfigTelemetryKind.ActivationPersistenceFailed
+}
+
+/**
+ * Durable, per-identity-scope telemetry buffer.
+ *
+ * Mirrors [PersistentRemoteConfigActivationAckStore]: the storage key is a salted digest of the
+ * scope, so neither the project key nor the canonical user id ever lands in a preference name.
+ */
+internal class PersistentRemoteConfigTelemetryStore(
+ private val cache: Cache,
+ moshi: Moshi,
+ private val maxBytes: Int = REMOTE_CONFIG_TELEMETRY_MAX_BYTES,
+ private val maxEntries: Int = REMOTE_CONFIG_TELEMETRY_MAX_ENTRIES,
+) : RemoteConfigTelemetryStore {
+ private val adapter = moshi.adapter(PersistedRemoteConfigTelemetry::class.java)
+
+ @Synchronized
+ @Suppress("ReturnCount")
+ override fun load(scope: RemoteConfigSnapshotScope): List {
+ val storageKey = remoteConfigTelemetryStorageKey(scope)
+ val raw = try {
+ cache.getString(storageKey, null)
+ } catch (_: Exception) {
+ null
+ } ?: return emptyList()
+ val persisted = try {
+ raw.takeIf { it.toByteArray(Charsets.UTF_8).size <= maxBytes }
+ ?.let(adapter::fromJson)
+ } catch (_: Exception) {
+ null
+ }
+ if (persisted == null || persisted.version != REMOTE_CONFIG_TELEMETRY_VERSION) {
+ removeInvalid(storageKey)
+ return emptyList()
+ }
+ return persisted.events.mapNotNull { it.toEvent() }.take(maxEntries)
+ }
+
+ @Synchronized
+ @Suppress("ReturnCount")
+ override fun save(scope: RemoteConfigSnapshotScope, events: List): Boolean {
+ // The byte budget is enforced by shrinking the batch rather than by refusing the write: a
+ // partial buffer is strictly better telemetry than none, and the oldest buckets are the
+ // ones the next flush would have sent first anyway.
+ var candidates = events.filter { it.isValid() }.take(maxEntries)
+ while (candidates.isNotEmpty()) {
+ val raw = encode(candidates) ?: return false
+ if (raw.toByteArray(Charsets.UTF_8).size <= maxBytes) {
+ return writeDurably(scope, raw)
+ }
+ candidates = candidates.dropLast(1)
+ }
+ return clear(scope)
+ }
+
+ @Synchronized
+ override fun clear(scope: RemoteConfigSnapshotScope): Boolean = try {
+ cache.updateStringsDurably(emptyMap(), setOf(remoteConfigTelemetryStorageKey(scope)))
+ } catch (_: Exception) {
+ false
+ }
+
+ private fun encode(events: List): String? = try {
+ adapter.toJson(
+ PersistedRemoteConfigTelemetry(
+ version = REMOTE_CONFIG_TELEMETRY_VERSION,
+ events = events.map { it.toPersisted() },
+ ),
+ )
+ } catch (_: Exception) {
+ null
+ }
+
+ private fun writeDurably(scope: RemoteConfigSnapshotScope, raw: String): Boolean = try {
+ cache.updateStringsDurably(
+ values = mapOf(remoteConfigTelemetryStorageKey(scope) to raw),
+ removedKeys = emptySet(),
+ )
+ } catch (_: Exception) {
+ false
+ }
+
+ private fun removeInvalid(key: String) {
+ try {
+ cache.updateStringsDurably(emptyMap(), setOf(key))
+ } catch (_: Exception) {
+ // A malformed record stays untrusted even when best-effort cleanup fails.
+ }
+ }
+
+ private fun RemoteConfigTelemetryEvent.toPersisted() = PersistedRemoteConfigTelemetryEvent(
+ kind = kind.wireName,
+ logicalKey = logicalKey,
+ releaseNumber = releaseNumber,
+ count = count,
+ lastOccurredAt = lastOccurredAtSeconds,
+ )
+
+ private fun PersistedRemoteConfigTelemetryEvent.toEvent(): RemoteConfigTelemetryEvent? {
+ val parsedKind = RemoteConfigTelemetryKind.fromWireName(kind) ?: return null
+ return RemoteConfigTelemetryEvent(
+ kind = parsedKind,
+ logicalKey = logicalKey,
+ releaseNumber = releaseNumber,
+ count = count,
+ lastOccurredAtSeconds = lastOccurredAt,
+ ).takeIf { it.isValid() }
+ }
+}
+
+@JsonClass(generateAdapter = true)
+internal data class PersistedRemoteConfigTelemetry(
+ val version: Int,
+ @Json(name = "events")
+ val events: List,
+)
+
+@JsonClass(generateAdapter = true)
+internal data class PersistedRemoteConfigTelemetryEvent(
+ @Json(name = "kind")
+ val kind: String,
+ @Json(name = "logical_key")
+ val logicalKey: String,
+ @Json(name = "release_number")
+ val releaseNumber: Long,
+ @Json(name = "count")
+ val count: Long,
+ @Json(name = "last_occurred_at")
+ val lastOccurredAt: Long,
+)
+
+private fun remoteConfigTelemetryStorageKey(scope: RemoteConfigSnapshotScope): String {
+ val digest = MessageDigest.getInstance("SHA-256")
+ digest.updateLengthPrefixed("remote-config-telemetry-v1".encodeToByteArray())
+ digest.updateLengthPrefixed(scope.projectKey.encodeToByteArray())
+ digest.updateLengthPrefixed(scope.environment.encodeToByteArray())
+ digest.updateLengthPrefixed(scope.canonicalUserId.encodeToByteArray())
+ return REMOTE_CONFIG_TELEMETRY_PREFIX + digest.digest().joinToString("") { byte -> "%02x".format(byte) }
+}
+
+private fun MessageDigest.updateLengthPrefixed(value: ByteArray) {
+ update(ByteBuffer.allocate(Int.SIZE_BYTES).putInt(value.size).array())
+ update(value)
+}
diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2Factory.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2Factory.kt
new file mode 100644
index 000000000..97dd0499f
--- /dev/null
+++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2Factory.kt
@@ -0,0 +1,315 @@
+@file:OptIn(ExperimentalQonversionApi::class)
+
+package com.qonversion.android.sdk.internal.remoteconfig
+
+import android.app.Application
+import com.qonversion.android.sdk.ExperimentalQonversionApi
+import com.qonversion.android.sdk.dto.QRemoteConfigFallbackValue
+import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigV2Config
+import com.qonversion.android.sdk.internal.InternalConfig
+import com.qonversion.android.sdk.internal.isDebuggable
+import com.qonversion.android.sdk.internal.logger.Logger
+import com.qonversion.android.sdk.internal.services.BundledRemoteConfigDefaults
+import com.qonversion.android.sdk.internal.services.BundledRemoteConfigDefaultsReader
+import com.qonversion.android.sdk.internal.storage.Cache
+import com.qonversion.android.sdk.internal.storage.PersistentRemoteConfigSnapshotStore
+import com.squareup.moshi.Moshi
+import okhttp3.OkHttpClient
+import java.util.concurrent.Executor
+import java.util.concurrent.Executors
+import java.util.concurrent.ScheduledExecutorService
+import java.util.concurrent.ThreadFactory
+import java.util.concurrent.TimeUnit
+import kotlin.random.Random
+
+private const val REMOTE_CONFIG_V2_MINIMUM_FETCH_INTERVAL_MILLIS = 60_000L
+private const val REMOTE_CONFIG_V2_TRANSPORT_TIMEOUT_SECONDS = 15L
+private const val REMOTE_CONFIG_V2_REQUEST_TIMEOUT_MILLIS = 30_000L
+private const val REMOTE_CONFIG_V2_WORKER_THREAD_NAME = "qonversion-remote-config-v2"
+private const val REMOTE_CONFIG_V2_SCHEDULER_THREAD_NAME = "qonversion-remote-config-v2-timer"
+
+/**
+ * Builds the whole Remote Config v2 chain, or nothing at all.
+ *
+ * Nothing is constructed unless the app supplied a [QRemoteConfigV2Config]: no store, no
+ * background threads, no HTTP client, no base URL. This is the single switch that keeps the
+ * feature dormant, and there is deliberately no default endpoint to fall back to.
+ *
+ * The subsystem is assembled here rather than in the Dagger graph for the same reason
+ * `RedemptionManager` is: it owns its dependencies end to end (cache + moshi + logger + its own
+ * OkHttp client), and adding a module for one optional object would put a dormant feature into
+ * every graph build.
+ */
+internal object RemoteConfigV2Factory {
+
+ fun create(
+ application: Application,
+ internalConfig: InternalConfig,
+ cache: Cache,
+ logger: Logger,
+ ): QRemoteConfigSnapshotsImpl {
+ val bundledReader: (String) -> QRemoteConfigFallbackValue? = { contextKey ->
+ BundledRemoteConfigDefaults.value(application, contextKey)
+ }
+ val config = internalConfig.remoteConfigV2Config
+ val mainDispatcher = MainThreadDispatcher()
+ val manager = config?.let {
+ createManager(application, internalConfig, it, cache, logger, mainDispatcher)
+ }
+ return QRemoteConfigSnapshotsImpl(manager, bundledReader, mainDispatcher)
+ }
+
+ @Suppress("LongParameterList")
+ private fun createManager(
+ application: Application,
+ internalConfig: InternalConfig,
+ config: QRemoteConfigV2Config,
+ cache: Cache,
+ logger: Logger,
+ mainDispatcher: RemoteConfigMainDispatcher,
+ ): RemoteConfigV2Manager {
+ val moshi = Moshi.Builder().build()
+ val primaryConfig = internalConfig.primaryConfig
+ val store = PersistentRemoteConfigSnapshotStore(cache, moshi)
+ // One single-threaded worker for BOTH the preloader and the manager: the manager's
+ // ordering contract (preload installs before a scope transition is observed) is
+ // exactly this executor's FIFO ordering.
+ val worker = Executors.newSingleThreadExecutor(daemonThreadFactory(REMOTE_CONFIG_V2_WORKER_THREAD_NAME))
+ val scheduler = scheduler()
+ val scopeHolder = RemoteConfigV2ScopeHolder()
+ val clock = RemoteConfigFetchClock { System.currentTimeMillis() }
+ val random = RemoteConfigFetchRandom { Random.Default.nextDouble() }
+ // One transport for all three routes: the activation ack and the client telemetry batch
+ // ride the very same session, bootstrap and re-bootstrap-once rule as a snapshot read.
+ val transport = transport(application, internalConfig, config, scopeHolder, cache, moshi, logger, clock)
+ val telemetrySender = telemetrySender(transport, cache, moshi, clock, random, scheduler, worker)
+ val core = RemoteConfigSnapshotCore(
+ store = store,
+ bundledRelease = bundledRelease(application, primaryConfig.projectKey),
+ // The only production point for `decode_failure`, and the only one that can exist: the
+ // resolution ladder absorbs a failed decode by design, so nothing downstream of the
+ // read site can tell a mis-typed key from an absent one.
+ decodeFailureObserver = telemetrySender::recordDecodeFailure,
+ )
+ val readGuard = readGuard(application, core, store, worker, telemetrySender, logger)
+ val coordinator = RemoteConfigFetchCoordinator(
+ core = core,
+ transport = transport,
+ policyStore = PersistentRemoteConfigFetchPolicyStore(cache, moshi),
+ clock = clock,
+ random = random,
+ scheduler = scheduler,
+ policy = RemoteConfigFetchPolicy(
+ minimumFetchIntervalMillis = minimumFetchIntervalMillis(config, application.isDebuggable),
+ // A backstop above the per-call waits: it releases waiters that joined a request
+ // the socket timeouts somehow outlived, so one wedged call cannot park later ones.
+ timeoutMillis = REMOTE_CONFIG_V2_REQUEST_TIMEOUT_MILLIS,
+ ),
+ // Bookkeeping the next attempt re-derives, so it never surfaces to the app — but it is
+ // the one persistence failure the read guard cannot see, and the dashboard counts it
+ // with the rest.
+ policyPersistenceFailureObserver = { telemetrySender.recordPolicyPersistenceFailure() },
+ )
+ return RemoteConfigV2Manager(
+ core = core,
+ readGuard = readGuard,
+ coordinator = coordinator,
+ ackSender = ackSender(transport, cache, moshi, clock, random, scheduler, worker),
+ telemetrySender = telemetrySender,
+ options = RemoteConfigV2Options(
+ projectKey = primaryConfig.projectKey,
+ environmentUid = config.environmentUid,
+ ),
+ scopeHolder = scopeHolder,
+ scheduler = scheduler,
+ worker = worker,
+ mainDispatcher = mainDispatcher,
+ logger = logger,
+ )
+ }
+
+ /**
+ * The effective floor between real network fetches.
+ *
+ * `0` in the configuration means "auto": the production default in a release build, and no
+ * floor at all in a debuggable one, so a developer iterating on an environment sees every
+ * change. An explicit positive value wins over auto in both build modes. Forced fetches
+ * already bypass the floor, and failure backoff applies independently of it — an interval of
+ * zero never disables backoff.
+ */
+ fun minimumFetchIntervalMillis(config: QRemoteConfigV2Config, isDebuggable: Boolean): Long = when {
+ config.minFetchIntervalSeconds > 0 -> TimeUnit.SECONDS.toMillis(config.minFetchIntervalSeconds)
+ isDebuggable -> 0L
+ else -> REMOTE_CONFIG_V2_MINIMUM_FETCH_INTERVAL_MILLIS
+ }
+
+ /**
+ * The read guard, with both of its side channels attached.
+ *
+ * The assertion channel shouts in a debug build; the telemetry channel only ever enqueues,
+ * because it is invoked from the app's own read thread.
+ */
+ @Suppress("LongParameterList")
+ private fun readGuard(
+ application: Application,
+ core: RemoteConfigSnapshotCore,
+ store: PersistentRemoteConfigSnapshotStore,
+ worker: Executor,
+ telemetrySender: RemoteConfigTelemetrySender,
+ logger: Logger,
+ ) = RemoteConfigReadGuard(
+ core = core,
+ preloader = PersistentRemoteConfigReadPreloader(store, worker),
+ buildMode = if (application.isDebuggable) {
+ RemoteConfigReadBuildMode.Debug
+ } else {
+ RemoteConfigReadBuildMode.Release
+ },
+ assertion = { message ->
+ logger.error(message)
+ // Only fires when JVM assertions are enabled, so a debug build shouts without
+ // turning a config read into a production crash.
+ assert(false) { message }
+ },
+ telemetry = { event ->
+ logger.debug("Remote Config v2 guard event: $event")
+ telemetrySender.record(event)
+ },
+ )
+
+ /**
+ * The activation ack queue.
+ *
+ * It shares the transport (same session, same bootstrap) and the jitter source with the fetch
+ * path, but its retries are handed to [worker] rather than run on the timer thread: the timer
+ * also releases fetch waiters, and an ack retry does a preferences read and a durable write.
+ */
+ @Suppress("LongParameterList")
+ private fun ackSender(
+ transport: RemoteConfigAckTransport,
+ cache: Cache,
+ moshi: Moshi,
+ clock: RemoteConfigFetchClock,
+ random: RemoteConfigFetchRandom,
+ scheduler: RemoteConfigFetchScheduler,
+ worker: Executor,
+ ) = RemoteConfigActivationAckSender(
+ transport = transport,
+ store = PersistentRemoteConfigActivationAckStore(cache, moshi),
+ clock = clock,
+ random = random,
+ scheduler = { delayMillis, action ->
+ scheduler.schedule(delayMillis) {
+ try {
+ worker.execute(action)
+ } catch (@Suppress("TooGenericExceptionCaught") _: RuntimeException) {
+ // A shut-down worker simply means this retry is not taken; the ack stays
+ // durable for the next process.
+ }
+ }
+ },
+ )
+
+ /**
+ * The client telemetry queue.
+ *
+ * Built exactly like [ackSender] and on purpose: the same transport (same session, same
+ * bootstrap), the same jitter source, and retries handed to [worker] rather than to the timer
+ * thread, which also releases fetch waiters. [worker] is additionally the executor the sender
+ * defers its durable writes to, so a handler invoked from the app's read thread can enqueue an
+ * event and return without ever touching storage.
+ */
+ @Suppress("LongParameterList")
+ private fun telemetrySender(
+ transport: RemoteConfigTelemetryTransport,
+ cache: Cache,
+ moshi: Moshi,
+ clock: RemoteConfigFetchClock,
+ random: RemoteConfigFetchRandom,
+ scheduler: RemoteConfigFetchScheduler,
+ worker: Executor,
+ ) = RemoteConfigTelemetrySender(
+ transport = transport,
+ store = PersistentRemoteConfigTelemetryStore(cache, moshi),
+ clock = clock,
+ random = random,
+ scheduler = { delayMillis, action ->
+ scheduler.schedule(delayMillis) {
+ try {
+ worker.execute(action)
+ } catch (@Suppress("TooGenericExceptionCaught") _: RuntimeException) {
+ // A shut-down worker simply means this flush is not taken; the buffer stays
+ // durable for the next process.
+ }
+ }
+ },
+ executor = worker,
+ )
+
+ @Suppress("LongParameterList")
+ private fun transport(
+ application: Application,
+ internalConfig: InternalConfig,
+ config: QRemoteConfigV2Config,
+ scopeHolder: RemoteConfigV2ScopeHolder,
+ cache: Cache,
+ moshi: Moshi,
+ logger: Logger,
+ clock: RemoteConfigFetchClock,
+ ) = RemoteConfigGatewayTransport(
+ // A dedicated client: the shared one carries the legacy NetworkInterceptor, which would
+ // append a second Authorization header to requests this transport signs itself.
+ callFactory = OkHttpClient.Builder()
+ .connectTimeout(REMOTE_CONFIG_V2_TRANSPORT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
+ .readTimeout(REMOTE_CONFIG_V2_TRANSPORT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
+ .writeTimeout(REMOTE_CONFIG_V2_TRANSPORT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
+ .build(),
+ baseUrlProvider = { config.baseUrl },
+ identityProvider = {
+ scopeHolder.scope?.let { scope ->
+ RemoteConfigTransportIdentity(
+ scope = scope,
+ projectToken = internalConfig.primaryConfig.projectKey,
+ // Read from the scope, not from the live uid: they are the same value by
+ // construction, and reading one source makes it impossible to mint a session
+ // for one identity and admit its snapshot into another identity's store.
+ userUid = scope.canonicalUserId,
+ externalUserId = scopeHolder.externalUserId,
+ )
+ }
+ },
+ clientContextProvider = DeviceRemoteConfigClientContextProvider(
+ context = application,
+ sdkVersion = internalConfig.primaryConfig.sdkVersion,
+ ),
+ sessionStore = PersistentRemoteConfigSessionStore(cache, moshi),
+ // Durable per project key + environment: the project id the first bootstrap established
+ // must outlive both the session that carried it and the process that learned it.
+ projectIds = RemoteConfigProjectIdRegistry(PersistentRemoteConfigProjectIdStore(cache)),
+ clock = clock,
+ identifyAssertionProvider = config.identifyAssertionProvider,
+ moshi = moshi,
+ logger = logger,
+ )
+
+ private fun bundledRelease(application: Application, projectKey: String): RemoteConfigScopedBundledRelease? = try {
+ BundledRemoteConfigDefaultsReader().read(application)?.toScopedRemoteConfigSnapshotRelease(projectKey)
+ } catch (@Suppress("TooGenericExceptionCaught") _: RuntimeException) {
+ // A malformed bundle degrades the ladder to "no fallback", never to a broken SDK.
+ null
+ }
+
+ private fun scheduler(): RemoteConfigFetchScheduler {
+ val executor: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor(
+ daemonThreadFactory(REMOTE_CONFIG_V2_SCHEDULER_THREAD_NAME),
+ )
+ return RemoteConfigFetchScheduler { delayMillis, action ->
+ val future = executor.schedule(action, delayMillis, TimeUnit.MILLISECONDS)
+ RemoteConfigFetchScheduledTask { future.cancel(false) }
+ }
+ }
+
+ private fun daemonThreadFactory(name: String) = ThreadFactory { runnable ->
+ Thread(runnable, name).apply { isDaemon = true }
+ }
+}
diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2Manager.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2Manager.kt
new file mode 100644
index 000000000..3cb31117a
--- /dev/null
+++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2Manager.kt
@@ -0,0 +1,432 @@
+@file:OptIn(ExperimentalQonversionApi::class)
+
+package com.qonversion.android.sdk.internal.remoteconfig
+
+import com.qonversion.android.sdk.ExperimentalQonversionApi
+import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigActivationResult
+import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigFetchResult
+import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigFetchStatus
+import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigSnapshot
+import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigSubscription
+import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigUpdate
+import com.qonversion.android.sdk.internal.logger.Logger
+import java.util.concurrent.Executor
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicReference
+
+internal const val REMOTE_CONFIG_V2_DEFAULT_FETCH_TIMEOUT_MILLIS = 5_000L
+
+/**
+ * Immutable addressing of one Remote Config v2 integration.
+ *
+ * The server-resolved targeting context is deliberately not part of it. The fingerprint hashes
+ * mutable targeting context (app/OS version, locale, purchases, properties); it rotates legitimately
+ * and MUST NOT be pinned across fetches. Identity isolation is the session's job — see
+ * [RemoteConfigGatewaySession] and the per-scope storage keys.
+ *
+ * The numeric project id is not part of it either: the SDK learns it from the session bootstrap
+ * rather than from the app, and it travels with the response it addresses.
+ */
+internal data class RemoteConfigV2Options(
+ val projectKey: String,
+ val environmentUid: String,
+)
+
+/**
+ * The identity scope the transport addresses, published for the transport's identity provider.
+ *
+ * The transport is constructed before any identity is known, so it reads the scope through this
+ * holder instead of capturing one.
+ */
+internal class RemoteConfigV2ScopeHolder {
+ private val current = AtomicReference(null)
+ private val externalIdentity = AtomicReference(null)
+
+ var scope: RemoteConfigSnapshotScope?
+ get() = current.get()
+ set(value) = current.set(value)
+
+ var externalUserId: String?
+ get() = externalIdentity.get()
+ set(value) = externalIdentity.set(value)
+}
+
+internal fun interface RemoteConfigMainDispatcher {
+ /** Runs [action] on the main thread, inline when the caller is already on it. */
+ fun post(action: () -> Unit)
+
+ /**
+ * Runs [action] on the main thread, never inline.
+ *
+ * Used for app-supplied listeners: a snapshot activation can be committed *from* the main
+ * thread (the read guard's implicit activation), and running a listener inline there would
+ * execute app code while the core still holds its delivery-drain ownership — a listener that
+ * touches another Qonversion API from there can deadlock against an identity transition.
+ */
+ fun postDeferred(action: () -> Unit) = post(action)
+}
+
+/**
+ * Binds the Remote Config v2 internals — snapshot core, read guard, fetch coordinator and gateway
+ * transport — into the operations the public [com.qonversion.android.sdk.QRemoteConfigSnapshots] surface
+ * exposes.
+ *
+ * Threading contract:
+ * - every completion is delivered exactly once through [mainDispatcher];
+ * - every operation that can touch durable storage runs on [worker], which MUST be the same
+ * single-threaded executor the read guard's preloader uses. That ordering is what keeps a scope
+ * transition from racing its own preload: the preload task is enqueued first and therefore
+ * installs the loaded state before the coordinator observes the new scope.
+ */
+@Suppress("LongParameterList")
+internal class RemoteConfigV2Manager(
+ private val core: RemoteConfigSnapshotCore,
+ private val readGuard: RemoteConfigReadGuard,
+ private val coordinator: RemoteConfigFetchCoordinator,
+ private val ackSender: RemoteConfigActivationAckSender,
+ private val telemetrySender: RemoteConfigTelemetrySender,
+ private val options: RemoteConfigV2Options,
+ private val scopeHolder: RemoteConfigV2ScopeHolder,
+ private val scheduler: RemoteConfigFetchScheduler,
+ private val worker: Executor,
+ private val mainDispatcher: RemoteConfigMainDispatcher,
+ private val logger: Logger,
+ private val defaultFetchTimeoutMillis: Long = REMOTE_CONFIG_V2_DEFAULT_FETCH_TIMEOUT_MILLIS,
+) {
+ /**
+ * The (scope, release) this manager has already handed to the ack sender, so an ordinary
+ * `current` read costs one reference compare instead of a worker task. It is a cache of the
+ * sender's own idempotency, never a substitute for it: the sender is the only thing that
+ * decides whether an ack is actually owed.
+ *
+ * The scope is part of it precisely because the cache is written from the caller's thread: a
+ * read that started before an identity change can land after it, and a bare release number
+ * would then suppress the new identity's ack for the same release number.
+ */
+ private val notedActivation = AtomicReference(null)
+
+ private data class NotedActivation(
+ val scope: RemoteConfigSnapshotScope,
+ val releaseNumber: Long,
+ )
+
+ /**
+ * Switches the served scope to [canonicalUserId] and kicks off a forced fetch.
+ *
+ * The scope swap is performed synchronously on the calling thread, so the previous identity's
+ * snapshot stops being readable before this call returns — a read that races an identity
+ * change can only ever see the new (initially fallback-only) scope, never the old release.
+ */
+ fun updateIdentity(
+ canonicalUserId: String,
+ forceReason: RemoteConfigFetchForceReason,
+ externalUserId: String? = null,
+ ) {
+ val scope = scopeFor(canonicalUserId)
+ // Order matters: the core stops accepting admissions for the previous scope BEFORE the
+ // transport starts addressing the new one. The reverse order leaves a window in which a
+ // concurrent fetch reads the new identity and admits its snapshot into the old store.
+ readGuard.transitionScopeBeforeSdkReady(scope)
+ scopeHolder.scope = scope
+ scopeHolder.externalUserId = externalUserId
+ val submitted = submit {
+ coordinator.transitionTo(scope)
+ // Binds the ack queue to the new identity — and, on the first identity of a process,
+ // resumes an ack an earlier process activated but never managed to deliver.
+ notedActivation.set(null)
+ ackSender.bind(scope)
+ // Same binding rule as the ack queue: telemetry buffered under one identity is never
+ // reported under another's session, and a cold start resumes whatever is still owed.
+ telemetrySender.bind(scope)
+ if (scope != null) forceFetch(forceReason)
+ }
+ if (!submitted) logger.debug("Remote Config v2 could not apply an identity change")
+ }
+
+ /**
+ * Re-reads targeting for the *same* identity, e.g. after user properties or an attached
+ * experiment changed the evaluation inputs.
+ *
+ * Deliberately not a scope transition: the identity did not change, so the served release must
+ * keep serving until a newer one is fetched and activated.
+ */
+ fun refreshTargeting() = refreshTargeting(null)
+
+ fun refreshTargeting(externalUserId: String?) {
+ if (scopeHolder.scope == null) return
+ if (externalUserId != null) scopeHolder.externalUserId = externalUserId
+ submit { forceFetch(RemoteConfigFetchForceReason.Identify) }
+ }
+
+ private fun forceFetch(forceReason: RemoteConfigFetchForceReason) {
+ // No caller is waiting, so no timeout is armed — the request runs to its own completion.
+ fetch(timeoutMillis = 0, forceReason = forceReason) { result ->
+ if (result.status != QRemoteConfigFetchStatus.Fetched &&
+ result.status != QRemoteConfigFetchStatus.NotModified
+ ) {
+ logger.debug("Remote Config v2 forced fetch ended as ${result.status}")
+ }
+ }
+ }
+
+ val current: QRemoteConfigSnapshot get() {
+ val snapshot = readGuard.currentSnapshot()
+ // A read can itself activate (the guard's one-shot implicit activation in release builds),
+ // and that activation is exactly as ack-worthy as an explicit one.
+ noteActivatedRelease(snapshot.releaseNumber)
+ return QRemoteConfigSnapshot(snapshot)
+ }
+
+ /**
+ * Fetches a release and completes with the best available data.
+ *
+ * [timeoutMillis] bounds the *wait*, not the request: when it elapses the completion fires with
+ * [QRemoteConfigFetchStatus.TimedOut] and the request keeps running, so a slow response is
+ * still admitted and offered to the next activation.
+ */
+ fun fetch(
+ timeoutMillis: Long?,
+ forceReason: RemoteConfigFetchForceReason? = null,
+ callback: (QRemoteConfigFetchResult) -> Unit,
+ ) {
+ val delivery = SingleDelivery(callback)
+ val timeoutTask = scheduleTimeout(timeoutMillis, delivery)
+ val submitted = submit {
+ coordinator.fetch(forceReason) { result ->
+ timeoutTask.cancelSafely()
+ delivery.deliver(result.toPublicResult())
+ // An immediate-policy admission performs the same whole-release activation as
+ // activate(), so it owes the same fleet-distribution ack. Merely returning the
+ // activated snapshot to the fetch caller is not enough: no later current read or
+ // explicit activate is required by this policy and therefore neither can be relied
+ // on to discover the activation for us.
+ if (result.activatedImmediately()) {
+ noteActivatedRelease(core.currentSnapshot().releaseNumber)
+ }
+ // Strictly after the app's completion: the connection is warm and the session is
+ // known-good, which is the cheapest moment to hand over buffered telemetry — but
+ // no caller may ever wait on it.
+ if (result.answeredByGateway()) telemetrySender.onSuccessfulFetch()
+ }
+ }
+ if (!submitted) {
+ timeoutTask.cancelSafely()
+ delivery.deliver(result(QRemoteConfigFetchStatus.Failed))
+ }
+ }
+
+ /** Swaps the last fetched release into [current] atomically, on the worker thread. */
+ fun activate(fetchStatus: QRemoteConfigFetchStatus? = null, callback: (QRemoteConfigActivationResult) -> Unit) {
+ val delivery = SingleDelivery(callback)
+ val submitted = submit {
+ val transition = try {
+ readGuard.activate()
+ } catch (@Suppress("TooGenericExceptionCaught") error: RuntimeException) {
+ logger.debug("Remote Config v2 activation failed: ${error.javaClass.simpleName}")
+ RemoteConfigSnapshotTransitionResult(RemoteConfigSnapshotTransitionStatus.Ignored)
+ }
+ if (transition.status == RemoteConfigSnapshotTransitionStatus.PersistenceFailed) {
+ // The app keeps serving the previously activated release; say so, because
+ // `changed = false` alone is indistinguishable from "there was nothing new".
+ logger.error("Remote Config v2 activation could not be persisted")
+ }
+ val changed = transition.status == RemoteConfigSnapshotTransitionStatus.Activated && transition.changed
+ val snapshot = core.currentSnapshot()
+ delivery.deliver(
+ QRemoteConfigActivationResult(
+ changed = changed,
+ snapshot = QRemoteConfigSnapshot(snapshot),
+ fetchStatus = fetchStatus,
+ ),
+ )
+ // Strictly after the completion is handed off, and on a later worker task: the ack
+ // queue does durable I/O and the activation contract promises none of it.
+ //
+ // `Unchanged` is included deliberately. It means "this release is already the active
+ // one" — which is the shape an activation takes when the read guard activated it
+ // implicitly first, and that release is owed exactly the same ack.
+ if (transition.status == RemoteConfigSnapshotTransitionStatus.Activated ||
+ transition.status == RemoteConfigSnapshotTransitionStatus.Unchanged
+ ) {
+ noteActivatedRelease(snapshot.releaseNumber)
+ }
+ }
+ if (!submitted) {
+ delivery.deliver(
+ QRemoteConfigActivationResult(
+ changed = false,
+ snapshot = QRemoteConfigSnapshot(core.currentSnapshot()),
+ fetchStatus = fetchStatus,
+ ),
+ )
+ }
+ }
+
+ fun fetchAndActivate(timeoutMillis: Long?, callback: (QRemoteConfigActivationResult) -> Unit) {
+ fetch(timeoutMillis) { fetchResult ->
+ activate(fetchResult.status, callback)
+ }
+ }
+
+ fun subscribeOnConfigUpdate(listener: (QRemoteConfigUpdate) -> Unit): QRemoteConfigSubscription {
+ val token = core.addUpdateObserver { update ->
+ mainDispatcher.postDeferred { listener(QRemoteConfigUpdate(update)) }
+ }
+ return QRemoteConfigSubscription { core.removeUpdateObserver(token) }
+ }
+
+ /**
+ * Offers [releaseNumber] to the ack queue, off the caller's thread.
+ *
+ * Always asynchronous, including when it is already called from the worker: queueing an ack
+ * writes to durable storage, and neither `activate()`'s completion nor a `current` read may pay
+ * for that. The scope is captured here rather than inside the task so a task that lands after
+ * an identity change is dropped by the sender instead of acking the wrong identity.
+ */
+ @Suppress("ReturnCount")
+ private fun noteActivatedRelease(releaseNumber: Long) {
+ if (releaseNumber <= 0) return
+ val scope = scopeHolder.scope ?: return
+ val noted = NotedActivation(scope, releaseNumber)
+ if (notedActivation.getAndSet(noted) == noted) return
+ // A rejected submit must not leave the activation marked as handed off, or the ack would be
+ // lost for good; the next read or activation of the same release then offers it again.
+ if (!submit { ackSender.recordActivation(scope, releaseNumber) }) {
+ notedActivation.compareAndSet(noted, null)
+ }
+ }
+
+ private fun scopeFor(canonicalUserId: String): RemoteConfigSnapshotScope? = try {
+ RemoteConfigSnapshotScope(
+ projectKey = options.projectKey,
+ environment = options.environmentUid,
+ canonicalUserId = canonicalUserId,
+ )
+ } catch (_: IllegalArgumentException) {
+ logger.debug("Remote Config v2 identity is not addressable")
+ null
+ }
+
+ private fun scheduleTimeout(
+ timeoutMillis: Long?,
+ delivery: SingleDelivery,
+ ): RemoteConfigFetchScheduledTask? {
+ val effective = (timeoutMillis ?: defaultFetchTimeoutMillis).takeIf { it > 0 } ?: return null
+ return try {
+ scheduler.schedule(effective) {
+ delivery.deliver(result(QRemoteConfigFetchStatus.TimedOut))
+ }
+ } catch (@Suppress("TooGenericExceptionCaught") _: RuntimeException) {
+ null
+ }
+ }
+
+ /**
+ * The snapshot a completion reports: freshly fetched when there is one, otherwise the
+ * activated release, otherwise the bundled defaults.
+ *
+ * It deliberately reads the core rather than the read guard — a completion is an explicit
+ * hand-off of data the app asked for, not an implicit `current` read, so it must not consume
+ * the guard's one-shot read-before-activate opportunity.
+ */
+ private fun bestAvailableSnapshot(): QRemoteConfigSnapshot =
+ QRemoteConfigSnapshot(core.lastFetchedSnapshot() ?: core.currentSnapshot())
+
+ private fun result(status: QRemoteConfigFetchStatus) =
+ QRemoteConfigFetchResult(status, bestAvailableSnapshot())
+
+ /**
+ * Whether the gateway actually answered this fetch.
+ *
+ * A throttled, superseded or timed-out fetch never reached the network, and a failure says the
+ * network is exactly where telemetry should not be sent right now.
+ */
+ private fun RemoteConfigFetchResult.answeredByGateway(): Boolean = when (this) {
+ is RemoteConfigFetchResult.Fetched, RemoteConfigFetchResult.NotModified -> true
+ is RemoteConfigFetchResult.PolicyPersistenceFailed -> result.answeredByGateway()
+ else -> false
+ }
+
+ private fun RemoteConfigFetchResult.activatedImmediately(): Boolean = when (this) {
+ is RemoteConfigFetchResult.Fetched ->
+ transition.status == RemoteConfigSnapshotTransitionStatus.Activated
+ is RemoteConfigFetchResult.PolicyPersistenceFailed -> result.activatedImmediately()
+ else -> false
+ }
+
+ private fun RemoteConfigFetchResult.toPublicResult(): QRemoteConfigFetchResult = when (this) {
+ is RemoteConfigFetchResult.Fetched -> result(transition.toFetchStatus())
+ RemoteConfigFetchResult.NotModified -> result(QRemoteConfigFetchStatus.NotModified)
+ is RemoteConfigFetchResult.Failed -> result(QRemoteConfigFetchStatus.Failed)
+ is RemoteConfigFetchResult.MinimumInterval -> result(QRemoteConfigFetchStatus.Throttled)
+ is RemoteConfigFetchResult.Backoff -> result(QRemoteConfigFetchStatus.Throttled)
+ // The coordinator's backstop timeout reports the activated snapshot only; the public
+ // contract promises the fetched -> cache -> fallback ladder on every completion.
+ is RemoteConfigFetchResult.TimedOut -> result(QRemoteConfigFetchStatus.TimedOut)
+ // The release was admitted (or refused) exactly as any other outcome; only the fetch
+ // bookkeeping could not be persisted, which the next attempt re-derives.
+ is RemoteConfigFetchResult.PolicyPersistenceFailed -> result.toPublicResult()
+ RemoteConfigFetchResult.InvalidNotModified -> result(QRemoteConfigFetchStatus.Failed)
+ RemoteConfigFetchResult.Superseded -> result(QRemoteConfigFetchStatus.Superseded)
+ // A permanent addressing fault the transport has already reported: no snapshot was read at
+ // all, so there is nothing to report beyond the failure itself.
+ RemoteConfigFetchResult.ProjectMismatch -> result(QRemoteConfigFetchStatus.Failed)
+ }
+
+ private fun RemoteConfigSnapshotTransitionResult.toFetchStatus(): QRemoteConfigFetchStatus = when (status) {
+ // Rejected covers a malformed envelope AND a snapshot addressed to another project or
+ // environment than the session it was served for. The latter is a permanent server-side
+ // fault that otherwise looks exactly like a network failure. A changed targeting context is
+ // NOT in this class: it rotates on any app/OS update, locale change, purchase or property
+ // edit.
+ RemoteConfigSnapshotTransitionStatus.Accepted,
+ RemoteConfigSnapshotTransitionStatus.Activated,
+ RemoteConfigSnapshotTransitionStatus.Unchanged,
+ -> QRemoteConfigFetchStatus.Fetched
+ RemoteConfigSnapshotTransitionStatus.Ignored -> QRemoteConfigFetchStatus.Superseded
+ RemoteConfigSnapshotTransitionStatus.PersistenceFailed -> QRemoteConfigFetchStatus.Failed
+ RemoteConfigSnapshotTransitionStatus.Rejected -> {
+ logger.error(
+ "Remote Config v2 refused a snapshot: it did not match the project id or " +
+ "environment uid of the session it was served for, or the envelope was malformed",
+ )
+ QRemoteConfigFetchStatus.Failed
+ }
+ }
+
+ private fun submit(action: () -> Unit): Boolean = try {
+ worker.execute {
+ try {
+ action()
+ } catch (@Suppress("TooGenericExceptionCaught") error: RuntimeException) {
+ logger.debug("Remote Config v2 background work failed: ${error.javaClass.simpleName}")
+ }
+ }
+ true
+ } catch (@Suppress("TooGenericExceptionCaught") _: RuntimeException) {
+ false
+ }
+
+ private fun RemoteConfigFetchScheduledTask?.cancelSafely() {
+ try {
+ this?.cancel()
+ } catch (@Suppress("TooGenericExceptionCaught") _: RuntimeException) {
+ // Single-delivery is enforced independently of best-effort timer cancellation.
+ }
+ }
+
+ private inner class SingleDelivery(private val callback: (T) -> Unit) {
+ private val delivered = AtomicBoolean(false)
+
+ fun deliver(value: T) {
+ if (!delivered.compareAndSet(false, true)) return
+ mainDispatcher.post {
+ try {
+ callback(value)
+ } catch (@Suppress("TooGenericExceptionCaught") error: RuntimeException) {
+ logger.debug("Remote Config v2 callback threw: ${error.javaClass.simpleName}")
+ }
+ }
+ }
+ }
+}
diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/repository/DefaultRepository.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/repository/DefaultRepository.kt
index 9fd600a90..5d238db03 100644
--- a/sdk/src/main/java/com/qonversion/android/sdk/internal/repository/DefaultRepository.kt
+++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/repository/DefaultRepository.kt
@@ -134,9 +134,10 @@ internal class DefaultRepository internal constructor(
val body = it.body()
if (body == null) {
callback.onError(errorMapper.getErrorFromResponse(it))
+ } else if (body.any { config -> !config.isCorrect }) {
+ callback.onError(invalidRemoteConfigListError())
} else {
- val res = QRemoteConfigList(body.filter { config -> config.isCorrect })
- callback.onSuccess(res)
+ callback.onSuccess(QRemoteConfigList(body))
}
}
@@ -154,9 +155,10 @@ internal class DefaultRepository internal constructor(
val body = it.body()
if (body == null) {
callback.onError(errorMapper.getErrorFromResponse(it))
+ } else if (body.any { config -> !config.isCorrect }) {
+ callback.onError(invalidRemoteConfigListError())
} else {
- val res = QRemoteConfigList(body.filter { config -> config.isCorrect })
- callback.onSuccess(res)
+ callback.onSuccess(QRemoteConfigList(body))
}
}
@@ -167,6 +169,11 @@ internal class DefaultRepository internal constructor(
}
}
+ private fun invalidRemoteConfigListError() = QonversionError(
+ QonversionErrorCode.ResponseParsingFailed,
+ "Remote Config list contains an invalid element",
+ )
+
override fun attachUserToExperiment(
experimentId: String,
groupId: String,
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..cb2ece45a
--- /dev/null
+++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/services/BundledRemoteConfigDefaults.kt
@@ -0,0 +1,460 @@
+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 allDefaults(): List = orderedDefaults
+
+ 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
+}
+
+internal fun isPortableRemoteConfigJson(bytes: ByteArray): Boolean = parsePortableJson(bytes) != null
+
+/**
+ * Holds one decoded portable JSON value.
+ *
+ * The wrapper exists so a valid JSON `null` stays distinguishable from "these bytes are not
+ * portable JSON": both would otherwise be a bare `null`, and the snapshot resolution ladder reads
+ * a `null` decode as "reject this value and try the next ladder position".
+ */
+internal class PortableRemoteConfigJson(val value: Any?)
+
+internal fun decodePortableRemoteConfigJson(bytes: ByteArray): PortableRemoteConfigJson? =
+ parsePortableJson(bytes)?.let { parsed -> PortableRemoteConfigJson(parsed.value) }
+
+@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/main/java/com/qonversion/android/sdk/internal/storage/Cache.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/storage/Cache.kt
index 91b59fba9..9f887c25e 100644
--- a/sdk/src/main/java/com/qonversion/android/sdk/internal/storage/Cache.kt
+++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/storage/Cache.kt
@@ -27,6 +27,16 @@ internal interface Cache {
fun getLong(key: String, defValue: Long): Long
fun putString(key: String, value: String?)
+
+ fun updateStrings(values: Map, removedKeys: Set) {
+ removedKeys.forEach(::remove)
+ values.forEach(::putString)
+ }
+
+ fun updateStringsDurably(values: Map, removedKeys: Set): Boolean {
+ throw UnsupportedOperationException("This cache does not provide durable atomic string updates")
+ }
+
/**
* @param defValue is returned if the String preference for key does not exist
*/
diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/storage/PersistentRemoteConfigCache.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/storage/PersistentRemoteConfigCache.kt
new file mode 100644
index 000000000..05a77d391
--- /dev/null
+++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/storage/PersistentRemoteConfigCache.kt
@@ -0,0 +1,747 @@
+package com.qonversion.android.sdk.internal.storage
+
+import com.qonversion.android.sdk.dto.QRemoteConfig
+import com.qonversion.android.sdk.dto.QRemoteConfigList
+import com.qonversion.android.sdk.internal.InternalConfig
+import com.squareup.moshi.JsonClass
+import com.squareup.moshi.Moshi
+import java.security.MessageDigest
+import java.util.concurrent.Executor
+import java.util.concurrent.Executors
+import java.util.concurrent.RejectedExecutionException
+
+private const val DEFAULT_MAX_REMOTE_CONFIG_CACHE_BYTES = 512 * 1024
+private const val MAX_REMOTE_CONFIG_INDEX_BYTES = 64 * 1024
+
+private fun String?.normalizedRemoteConfigContextKey(): String? = takeUnless { it.isNullOrEmpty() }
+
+internal data class RemoteConfigCacheScope(
+ val projectKey: String,
+ val environment: String,
+ val userId: String,
+)
+
+internal data class RemoteConfigCacheLimits(
+ val maxScopes: Int = 8,
+ val maxEntriesPerScope: Int = 64,
+ val maxTotalBytes: Int = DEFAULT_MAX_REMOTE_CONFIG_CACHE_BYTES,
+) {
+ init {
+ require(maxScopes > 0)
+ require(maxEntriesPerScope > 0)
+ require(maxTotalBytes > 0)
+ }
+}
+
+internal interface RemoteConfigCache {
+ fun currentScope(): RemoteConfigCacheScope? = null
+ fun save(remoteConfig: QRemoteConfig)
+ fun save(scope: RemoteConfigCacheScope, remoteConfig: QRemoteConfig) = save(remoteConfig)
+ fun save(
+ scope: RemoteConfigCacheScope,
+ remoteConfig: QRemoteConfig,
+ completion: (Boolean) -> Unit,
+ )
+ fun remove(contextKey: String?)
+ fun remove(scope: RemoteConfigCacheScope, contextKey: String?) = remove(contextKey)
+ fun remove(
+ scope: RemoteConfigCacheScope,
+ contextKey: String?,
+ completion: (Boolean) -> Unit,
+ )
+ fun replaceAll(remoteConfigs: List)
+ fun replaceAll(scope: RemoteConfigCacheScope, remoteConfigs: List) = replaceAll(remoteConfigs)
+ fun replaceAll(
+ scope: RemoteConfigCacheScope,
+ remoteConfigs: List,
+ completion: (Boolean) -> Unit,
+ )
+ fun replaceRequested(requestedContextKeys: Set, remoteConfigs: List)
+ fun replaceRequested(
+ scope: RemoteConfigCacheScope,
+ requestedContextKeys: Set,
+ remoteConfigs: List,
+ ) = replaceRequested(requestedContextKeys, remoteConfigs)
+ fun replaceRequested(
+ scope: RemoteConfigCacheScope,
+ requestedContextKeys: Set,
+ remoteConfigs: List,
+ completion: (Boolean) -> Unit,
+ )
+ fun get(contextKey: String?): QRemoteConfig?
+ fun get(scope: RemoteConfigCacheScope, contextKey: String?): QRemoteConfig? = get(contextKey)
+ fun getAll(): QRemoteConfigList
+ fun getAll(scope: RemoteConfigCacheScope): QRemoteConfigList = getAll()
+}
+
+internal class PersistentRemoteConfigCache(
+ private val cache: Cache,
+ private val config: InternalConfig,
+ moshi: Moshi,
+ private val limits: RemoteConfigCacheLimits = RemoteConfigCacheLimits(),
+ private val persistenceExecutor: Executor = DEFAULT_PERSISTENCE_EXECUTOR,
+) : RemoteConfigCache {
+ private val adapter = moshi.adapter(PersistentRemoteConfigEnvelope::class.java)
+ private val remoteConfigAdapter = moshi.adapter(QRemoteConfig::class.java)
+ private val indexAdapter = moshi.adapter(PersistentRemoteConfigIndex::class.java)
+ private val memoryEnvelopes = mutableMapOf()
+ private val pendingRevisions = mutableMapOf()
+ private val pendingEnvelopes = mutableMapOf()
+ private val pendingCompletions = mutableMapOf>()
+ private var nextRevision = 0L
+
+ @Synchronized
+ override fun save(remoteConfig: QRemoteConfig) {
+ val scope = currentScope() ?: return
+ save(scope, remoteConfig)
+ }
+
+ @Synchronized
+ override fun save(scope: RemoteConfigCacheScope, remoteConfig: QRemoteConfig) {
+ save(scope, remoteConfig, requireExactPersistence = false) {}
+ }
+
+ @Synchronized
+ override fun save(
+ scope: RemoteConfigCacheScope,
+ remoteConfig: QRemoteConfig,
+ completion: (Boolean) -> Unit,
+ ) = save(scope, remoteConfig, requireExactPersistence = false, completion)
+
+ private fun save(
+ scope: RemoteConfigCacheScope,
+ remoteConfig: QRemoteConfig,
+ requireExactPersistence: Boolean,
+ completion: (Boolean) -> Unit,
+ ) {
+ if (!remoteConfig.isCorrect) {
+ completion(false)
+ return
+ }
+
+ val currentConfigs = loadLatestEnvelope(scope)?.remoteConfigs.orEmpty()
+ val contextKey = remoteConfig.source.contextKey.normalizedRemoteConfigContextKey()
+ val updatedConfigs = currentConfigs
+ .filterNot { it.source.contextKey.normalizedRemoteConfigContextKey() == contextKey }
+ .plus(remoteConfig)
+ .takeLast(limits.maxEntriesPerScope)
+ scheduleWrite(
+ scope,
+ updatedConfigs,
+ completion,
+ requireExactPersistence,
+ ) { committedEnvelope ->
+ committedEnvelope?.remoteConfigs?.any { it == remoteConfig } == true
+ }
+ }
+
+ @Synchronized
+ override fun remove(contextKey: String?) {
+ val scope = currentScope() ?: return
+ remove(scope, contextKey)
+ }
+
+ @Synchronized
+ override fun remove(scope: RemoteConfigCacheScope, contextKey: String?) {
+ remove(scope, contextKey, requireExactPersistence = false) {}
+ }
+
+ @Synchronized
+ override fun remove(
+ scope: RemoteConfigCacheScope,
+ contextKey: String?,
+ completion: (Boolean) -> Unit,
+ ) = remove(scope, contextKey, requireExactPersistence = true, completion)
+
+ private fun remove(
+ scope: RemoteConfigCacheScope,
+ contextKey: String?,
+ requireExactPersistence: Boolean,
+ completion: (Boolean) -> Unit,
+ ) {
+ val normalizedContextKey = contextKey.normalizedRemoteConfigContextKey()
+ val updatedConfigs = loadLatestEnvelope(scope)?.remoteConfigs.orEmpty()
+ .filterNot { it.source.contextKey.normalizedRemoteConfigContextKey() == normalizedContextKey }
+ scheduleWrite(
+ scope,
+ updatedConfigs,
+ completion,
+ requireExactPersistence,
+ ) { committedEnvelope ->
+ committedEnvelope?.remoteConfigs.orEmpty().none {
+ it.source.contextKey.normalizedRemoteConfigContextKey() == normalizedContextKey
+ }
+ }
+ }
+
+ @Synchronized
+ override fun replaceAll(remoteConfigs: List) {
+ val scope = currentScope() ?: return
+ replaceAll(scope, remoteConfigs)
+ }
+
+ @Synchronized
+ override fun replaceAll(scope: RemoteConfigCacheScope, remoteConfigs: List) {
+ replaceAll(scope, remoteConfigs, requireExactPersistence = false) {}
+ }
+
+ @Synchronized
+ override fun replaceAll(
+ scope: RemoteConfigCacheScope,
+ remoteConfigs: List,
+ completion: (Boolean) -> Unit,
+ ) = replaceAll(scope, remoteConfigs, requireExactPersistence = true, completion)
+
+ private fun replaceAll(
+ scope: RemoteConfigCacheScope,
+ remoteConfigs: List,
+ requireExactPersistence: Boolean,
+ completion: (Boolean) -> Unit,
+ ) {
+ if (!remoteConfigs.areValidForPersistence()) {
+ completion(false)
+ return
+ }
+ if (requireExactPersistence && remoteConfigs.size > limits.maxEntriesPerScope) {
+ completion(false)
+ return
+ }
+
+ scheduleWrite(
+ scope,
+ remoteConfigs.takeLast(limits.maxEntriesPerScope),
+ completion,
+ requireExactPersistence,
+ )
+ }
+
+ @Synchronized
+ override fun replaceRequested(
+ requestedContextKeys: Set,
+ remoteConfigs: List,
+ ) {
+ val scope = currentScope() ?: return
+ replaceRequested(scope, requestedContextKeys, remoteConfigs)
+ }
+
+ @Synchronized
+ override fun replaceRequested(
+ scope: RemoteConfigCacheScope,
+ requestedContextKeys: Set,
+ remoteConfigs: List,
+ ) {
+ replaceRequested(
+ scope,
+ requestedContextKeys,
+ remoteConfigs,
+ requireExactPersistence = false,
+ ) {}
+ }
+
+ @Synchronized
+ override fun replaceRequested(
+ scope: RemoteConfigCacheScope,
+ requestedContextKeys: Set,
+ remoteConfigs: List,
+ completion: (Boolean) -> Unit,
+ ) = replaceRequested(
+ scope,
+ requestedContextKeys,
+ remoteConfigs,
+ requireExactPersistence = true,
+ completion,
+ )
+
+ private fun replaceRequested(
+ scope: RemoteConfigCacheScope,
+ requestedContextKeys: Set,
+ remoteConfigs: List,
+ requireExactPersistence: Boolean,
+ completion: (Boolean) -> Unit,
+ ) {
+ val normalizedRequestedKeys = requestedContextKeys
+ .mapTo(mutableSetOf()) { it.normalizedRemoteConfigContextKey() }
+ if (!remoteConfigs.areValidForRequestedPersistence(normalizedRequestedKeys)) {
+ completion(false)
+ return
+ }
+
+ val requestedUpdate = loadLatestEnvelope(scope)?.remoteConfigs.orEmpty()
+ .filterNot { config ->
+ config.source.contextKey.normalizedRemoteConfigContextKey() in normalizedRequestedKeys
+ }
+ .plus(remoteConfigs)
+ if (requireExactPersistence && requestedUpdate.size > limits.maxEntriesPerScope) {
+ completion(false)
+ return
+ }
+ val updatedConfigs = requestedUpdate.takeLast(limits.maxEntriesPerScope)
+ val expectedConfigs = remoteConfigs.associateBy {
+ it.source.contextKey.normalizedRemoteConfigContextKey()
+ }
+ val omittedContextKeys = normalizedRequestedKeys - expectedConfigs.keys
+ scheduleWrite(
+ scope,
+ updatedConfigs,
+ completion,
+ requireExactPersistence,
+ ) { committedEnvelope ->
+ val committedConfigs = committedEnvelope?.remoteConfigs.orEmpty().associateBy {
+ it.source.contextKey.normalizedRemoteConfigContextKey()
+ }
+ expectedConfigs.all { (contextKey, expected) -> committedConfigs[contextKey] == expected } &&
+ omittedContextKeys.none { it in committedConfigs }
+ }
+ }
+
+ @Synchronized
+ override fun get(contextKey: String?): QRemoteConfig? {
+ val scope = currentScope() ?: return null
+ return get(scope, contextKey)
+ }
+
+ @Synchronized
+ override fun get(scope: RemoteConfigCacheScope, contextKey: String?): QRemoteConfig? {
+ val envelope = loadEnvelope(scope) ?: return null
+ val normalizedContextKey = contextKey.normalizedRemoteConfigContextKey()
+ val remoteConfig = envelope.remoteConfigs.firstOrNull {
+ it.source.contextKey.normalizedRemoteConfigContextKey() == normalizedContextKey
+ }
+ remoteConfig?.let { accessed ->
+ if (!pendingEnvelopes.containsKey(scope.storageKey)) {
+ scheduleWrite(
+ scope,
+ envelope.remoteConfigs.filterNot {
+ it.source.contextKey.normalizedRemoteConfigContextKey() == normalizedContextKey
+ } + accessed,
+ )
+ }
+ }
+ return remoteConfig
+ }
+
+ @Synchronized
+ override fun getAll(): QRemoteConfigList {
+ val scope = currentScope() ?: return QRemoteConfigList(emptyList())
+ return getAll(scope)
+ }
+
+ @Synchronized
+ override fun getAll(scope: RemoteConfigCacheScope): QRemoteConfigList {
+ val remoteConfigs = loadEnvelope(scope)?.remoteConfigs.orEmpty()
+ if (remoteConfigs.isNotEmpty() && !pendingEnvelopes.containsKey(scope.storageKey)) {
+ scheduleWrite(scope, remoteConfigs)
+ }
+ return QRemoteConfigList(remoteConfigs)
+ }
+
+ private fun scheduleWrite(
+ scope: RemoteConfigCacheScope,
+ remoteConfigs: List,
+ completion: (Boolean) -> Unit = {},
+ requireExactPersistence: Boolean = false,
+ isSuccessfulCommit: ((PersistentRemoteConfigEnvelope?) -> Boolean)? = null,
+ ) {
+ val storageKey = scope.storageKey
+ val envelope = remoteConfigs.takeIf { it.isNotEmpty() }?.let {
+ PersistentRemoteConfigEnvelope(
+ version = CACHE_VERSION,
+ projectKey = scope.projectKey,
+ environment = scope.environment,
+ userId = scope.userId,
+ remoteConfigs = it,
+ )
+ }
+ // Admission must finish before publishing a new pending revision: otherwise an
+ // unpersistable newer mutation can cancel an already accepted write. This bounded
+ // serialization runs on the caller; production evaluates at most 64 entries and
+ // admits at most 512 KiB (an oversized entry is serialized once to reject it), while
+ // the durable SharedPreferences commit remains on persistenceExecutor.
+ val persistencePayload = preparePersistencePayload(envelope, requireExactPersistence)
+ if (persistencePayload == null) {
+ completion(false)
+ return
+ }
+
+ val previousPendingState = PendingState(
+ revision = pendingRevisions[storageKey],
+ hasEnvelope = pendingEnvelopes.containsKey(storageKey),
+ envelope = pendingEnvelopes[storageKey],
+ completions = pendingCompletions[storageKey],
+ )
+ val revision = ++nextRevision
+ pendingRevisions[storageKey] = revision
+ // Subsequent coalesced mutations must build from what can actually become durable,
+ // not from entries removed by byte-bound admission.
+ pendingEnvelopes[storageKey] = persistencePayload.envelope
+ pendingCompletions[storageKey] = previousPendingState.completions.orEmpty().toMutableList().apply {
+ add(PendingCompletion(isSuccessfulCommit ?: { committed -> committed == envelope }, completion))
+ }
+ try {
+ persistenceExecutor.execute {
+ persistLatest(storageKey, revision, persistencePayload)
+ }
+ } catch (_: RejectedExecutionException) {
+ if (pendingRevisions[storageKey] == revision) {
+ restorePendingState(storageKey, previousPendingState)
+ completion(false)
+ }
+ }
+ }
+
+ private fun persistLatest(
+ storageKey: String,
+ revision: Long,
+ persistencePayload: PersistencePayload,
+ ) {
+ val completionResults = commitLatestPersistence(storageKey, revision, persistencePayload)
+ completionResults.forEach { (completion, committed) -> completion(committed) }
+ }
+
+ private fun restorePendingState(storageKey: String, previous: PendingState) {
+ previous.revision?.let { pendingRevisions[storageKey] = it }
+ ?: pendingRevisions.remove(storageKey)
+ if (previous.hasEnvelope) {
+ pendingEnvelopes[storageKey] = previous.envelope
+ } else {
+ pendingEnvelopes.remove(storageKey)
+ }
+ previous.completions?.let { pendingCompletions[storageKey] = it }
+ ?: pendingCompletions.remove(storageKey)
+ }
+
+ private fun preparePersistencePayload(
+ envelope: PersistentRemoteConfigEnvelope?,
+ requireExactPersistence: Boolean,
+ ): PersistencePayload? = try {
+ if (envelope == null) {
+ PersistencePayload(null, null)
+ } else {
+ envelope.let(::fitWithinByteLimit)
+ ?.takeUnless { (boundedEnvelope) ->
+ requireExactPersistence && boundedEnvelope != envelope
+ }
+ ?.let { (boundedEnvelope, json) -> PersistencePayload(boundedEnvelope, json) }
+ }
+ } catch (_: Exception) {
+ null
+ }
+
+ @Synchronized
+ private fun commitLatestPersistence(
+ storageKey: String,
+ revision: Long,
+ persistencePayload: PersistencePayload?,
+ ): List Unit, Boolean>> {
+ if (pendingRevisions[storageKey] != revision) return emptyList()
+
+ val attempt = persistPayload(storageKey, persistencePayload)
+ if (attempt.committed) {
+ updateCommittedMemory(storageKey, persistencePayload, attempt.indexUpdate)
+ }
+ pendingRevisions.remove(storageKey)
+ pendingEnvelopes.remove(storageKey)
+ return pendingCompletions.remove(storageKey).orEmpty().map { pending ->
+ pending.callback to (
+ attempt.committed && pending.isSuccessfulCommit(persistencePayload?.envelope)
+ )
+ }
+ }
+
+ private fun persistPayload(
+ storageKey: String,
+ persistencePayload: PersistencePayload?,
+ ): PersistenceAttempt {
+ if (persistencePayload == null) return PersistenceAttempt.failed()
+
+ return try {
+ val json = persistencePayload.json
+ val indexUpdate = createIndexUpdate(
+ storageKey,
+ json?.toByteArray(Charsets.UTF_8)?.size,
+ )
+ val values = buildMap {
+ json?.let { put(storageKey, it) }
+ indexUpdate.index?.let { put(CACHE_INDEX_KEY, indexAdapter.toJson(it)) }
+ }
+ val removedKeys = buildSet {
+ if (json == null) add(storageKey)
+ addAll(indexUpdate.evictedStorageKeys)
+ if (indexUpdate.index == null) add(CACHE_INDEX_KEY)
+ } - values.keys
+ PersistenceAttempt(cache.updateStringsDurably(values, removedKeys), indexUpdate)
+ } catch (_: Exception) {
+ PersistenceAttempt.failed()
+ }
+ }
+
+ private fun updateCommittedMemory(
+ storageKey: String,
+ persistencePayload: PersistencePayload?,
+ indexUpdate: PersistentRemoteConfigIndexUpdate?,
+ ) {
+ persistencePayload?.envelope?.let { memoryEnvelopes[storageKey] = it }
+ ?: memoryEnvelopes.remove(storageKey)
+ indexUpdate?.evictedStorageKeys.orEmpty().forEach { evictedStorageKey ->
+ if (!pendingRevisions.containsKey(evictedStorageKey)) {
+ memoryEnvelopes.remove(evictedStorageKey)
+ }
+ }
+ }
+
+ @Synchronized
+ private fun loadLatestEnvelope(scope: RemoteConfigCacheScope): PersistentRemoteConfigEnvelope? {
+ val storageKey = scope.storageKey
+ return if (pendingEnvelopes.containsKey(storageKey)) {
+ pendingEnvelopes[storageKey]
+ } else {
+ loadEnvelope(scope)
+ }
+ }
+
+ private fun fitWithinByteLimit(
+ original: PersistentRemoteConfigEnvelope,
+ ): Pair? {
+ val emptyEnvelopeBytes = adapter.toJson(original.copy(remoteConfigs = emptyList()))
+ .toByteArray(Charsets.UTF_8)
+ .size
+ var suffixStart = original.remoteConfigs.size
+ var suffixEntriesBytes = 0
+ for (index in original.remoteConfigs.lastIndex downTo 0) {
+ val entryBytes = remoteConfigAdapter.toJson(original.remoteConfigs[index])
+ .toByteArray(Charsets.UTF_8)
+ .size
+ val separatorBytes = if (suffixStart == original.remoteConfigs.size) 0 else 1
+ if (emptyEnvelopeBytes + suffixEntriesBytes + separatorBytes + entryBytes > limits.maxTotalBytes) {
+ break
+ }
+ suffixEntriesBytes += separatorBytes + entryBytes
+ suffixStart = index
+ }
+ if (suffixStart == original.remoteConfigs.size) return null
+
+ val boundedEnvelope = original.copy(
+ remoteConfigs = original.remoteConfigs.subList(suffixStart, original.remoteConfigs.size),
+ )
+ val json = adapter.toJson(boundedEnvelope)
+ return (boundedEnvelope to json).takeIf {
+ json.toByteArray(Charsets.UTF_8).size <= limits.maxTotalBytes
+ }
+ }
+
+ private fun createIndexUpdate(storageKey: String, bytes: Int?): PersistentRemoteConfigIndexUpdate {
+ val existing = loadIndex().scopes.filterNot { it.storageKey == storageKey }.toMutableList()
+ if (bytes != null) {
+ existing += PersistentRemoteConfigScopeMetadata(storageKey, bytes)
+ }
+
+ val evictedStorageKeys = mutableSetOf()
+ while (existing.size > limits.maxScopes ||
+ existing.sumOf { it.bytes.toLong() } > limits.maxTotalBytes.toLong()
+ ) {
+ val evicted = existing.removeFirst()
+ evictedStorageKeys += evicted.storageKey
+ }
+
+ val index = existing.takeIf { it.isNotEmpty() }?.let {
+ PersistentRemoteConfigIndex(INDEX_VERSION, it)
+ }
+ return PersistentRemoteConfigIndexUpdate(index, evictedStorageKeys)
+ }
+
+ private fun loadIndex(): PersistentRemoteConfigIndex {
+ val raw = cache.getString(CACHE_INDEX_KEY, null) ?: return emptyIndex()
+ val rawBytes = raw.toByteArray(Charsets.UTF_8).size
+ val index = if (rawBytes <= MAX_REMOTE_CONFIG_INDEX_BYTES) {
+ try {
+ indexAdapter.fromJson(raw)
+ } catch (_: Exception) {
+ null
+ }
+ } else {
+ null
+ }
+ return index?.takeIf { it.isValid() } ?: emptyIndex()
+ }
+
+ private fun PersistentRemoteConfigIndex.isValid(): Boolean {
+ val storageKeys = scopes.map { it.storageKey }
+ return version == INDEX_VERSION &&
+ scopes.size <= limits.maxScopes &&
+ storageKeys.size == storageKeys.distinct().size &&
+ scopes.all { metadata ->
+ CACHE_STORAGE_KEY_PATTERN.matches(metadata.storageKey) &&
+ metadata.bytes > 0 &&
+ metadata.bytes <= limits.maxTotalBytes
+ } &&
+ scopes.sumOf { it.bytes.toLong() } <= limits.maxTotalBytes.toLong() &&
+ scopes.all { it.matchesStoredEnvelope() }
+ }
+
+ private fun PersistentRemoteConfigScopeMetadata.matchesStoredEnvelope(): Boolean {
+ val raw = cache.getString(storageKey, null)
+ val actualBytes = raw?.utf8Size() ?: 0
+ val envelope = raw
+ ?.takeIf { actualBytes <= limits.maxTotalBytes }
+ ?.let(::decodeEnvelope)
+ return actualBytes == bytes && envelope.isValidForStorageKey(storageKey)
+ }
+
+ private fun emptyIndex() = PersistentRemoteConfigIndex(version = INDEX_VERSION, scopes = emptyList())
+
+ private fun loadEnvelope(scope: RemoteConfigCacheScope): PersistentRemoteConfigEnvelope? {
+ val storageKey = scope.storageKey
+ memoryEnvelopes[storageKey]?.let {
+ return it.takeIf { envelope -> envelope.isValidFor(scope, storageKey) }
+ }
+ val raw = cache.getString(storageKey, null)
+ val envelope = raw
+ ?.takeIf { it.utf8Size() <= limits.maxTotalBytes }
+ ?.let(::decodeEnvelope)
+ return when {
+ raw == null -> null
+ envelope.isValidFor(scope, storageKey) -> envelope.also { memoryEnvelopes[storageKey] = it!! }
+ else -> {
+ scheduleWrite(scope, emptyList())
+ null
+ }
+ }
+ }
+
+ private fun decodeEnvelope(raw: String): PersistentRemoteConfigEnvelope? = try {
+ adapter.fromJson(raw)
+ } catch (_: Exception) {
+ null
+ }
+
+ private fun PersistentRemoteConfigEnvelope?.isValidFor(
+ scope: RemoteConfigCacheScope,
+ storageKey: String,
+ ): Boolean = isValidForStorageKey(storageKey) &&
+ this?.projectKey == scope.projectKey &&
+ environment == scope.environment &&
+ userId == scope.userId
+
+ private fun PersistentRemoteConfigEnvelope?.isValidForStorageKey(storageKey: String): Boolean =
+ this != null &&
+ version == CACHE_VERSION &&
+ projectKey.isNotBlank() &&
+ environment.isNotBlank() &&
+ userId.isNotBlank() &&
+ remoteConfigs.isNotEmpty() &&
+ remoteConfigs.size <= limits.maxEntriesPerScope &&
+ remoteConfigs.areValidForPersistence() &&
+ RemoteConfigCacheScope(projectKey, environment, userId).storageKey == storageKey
+
+ private fun List.areValidForPersistence(): Boolean {
+ if (any { !it.isCorrect }) return false
+ val contextKeys = map { it.source.contextKey.normalizedRemoteConfigContextKey() }
+ return contextKeys.size == contextKeys.distinct().size
+ }
+
+ private fun List.areValidForRequestedPersistence(
+ normalizedRequestedKeys: Set,
+ ): Boolean = if (any { !it.isCorrect }) {
+ false
+ } else {
+ val returnedKeys = map { config ->
+ config.source.contextKey.normalizedRemoteConfigContextKey()
+ }
+ returnedKeys.size == returnedKeys.distinct().size &&
+ returnedKeys.all { it in normalizedRequestedKeys }
+ }
+
+ private fun String.utf8Size(): Int = toByteArray(Charsets.UTF_8).size
+
+ override fun currentScope(): RemoteConfigCacheScope? {
+ val projectKey = config.primaryConfig.projectKey
+ val environment = config.environment.name
+ val userId = config.uid
+ if (projectKey.isBlank() || userId.isBlank()) return null
+
+ return RemoteConfigCacheScope(
+ projectKey = projectKey,
+ environment = environment,
+ userId = userId,
+ )
+ }
+
+ private val RemoteConfigCacheScope.storageKey: String
+ get() {
+ val digest = MessageDigest.getInstance("SHA-256")
+ .digest("$projectKey\u0000$environment\u0000$userId".toByteArray(Charsets.UTF_8))
+ .joinToString(separator = "") { byte ->
+ val value = byte.toInt() and BYTE_MASK
+ "${HEX[value ushr NIBBLE_SHIFT]}${HEX[value and LOW_NIBBLE_MASK]}"
+ }
+ return "$CACHE_KEY_PREFIX$digest"
+ }
+
+ private companion object {
+ const val CACHE_VERSION = 2
+ const val INDEX_VERSION = 1
+ const val CACHE_KEY_PREFIX = "qonversion_remote_config_lkg_"
+ const val CACHE_INDEX_KEY = "qonversion_remote_config_lkg_index"
+ const val HEX = "0123456789abcdef"
+ const val BYTE_MASK = 0xff
+ const val LOW_NIBBLE_MASK = 0x0f
+ const val NIBBLE_SHIFT = 4
+ val CACHE_STORAGE_KEY_PATTERN = Regex("^${Regex.escape(CACHE_KEY_PREFIX)}[0-9a-f]{64}$")
+
+ val DEFAULT_PERSISTENCE_EXECUTOR: Executor = Executors.newSingleThreadExecutor { runnable ->
+ Thread(runnable, "qonversion-remote-config-cache").apply { isDaemon = true }
+ }
+ }
+}
+
+@JsonClass(generateAdapter = true)
+internal data class PersistentRemoteConfigEnvelope(
+ val version: Int,
+ val projectKey: String,
+ val environment: String,
+ val userId: String,
+ val remoteConfigs: List,
+)
+
+@JsonClass(generateAdapter = true)
+internal data class PersistentRemoteConfigIndex(
+ val version: Int,
+ val scopes: List,
+)
+
+@JsonClass(generateAdapter = true)
+internal data class PersistentRemoteConfigScopeMetadata(
+ val storageKey: String,
+ val bytes: Int,
+)
+
+private data class PersistentRemoteConfigIndexUpdate(
+ val index: PersistentRemoteConfigIndex?,
+ val evictedStorageKeys: Set,
+)
+
+private data class PersistencePayload(
+ val envelope: PersistentRemoteConfigEnvelope?,
+ val json: String?,
+)
+
+private data class PendingCompletion(
+ val isSuccessfulCommit: (PersistentRemoteConfigEnvelope?) -> Boolean,
+ val callback: (Boolean) -> Unit,
+)
+
+private data class PendingState(
+ val revision: Long?,
+ val hasEnvelope: Boolean,
+ val envelope: PersistentRemoteConfigEnvelope?,
+ val completions: MutableList?,
+)
+
+private data class PersistenceAttempt(
+ val committed: Boolean,
+ val indexUpdate: PersistentRemoteConfigIndexUpdate?,
+) {
+ companion object {
+ fun failed() = PersistenceAttempt(false, null)
+ }
+}
diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/storage/PersistentRemoteConfigSnapshotStore.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/storage/PersistentRemoteConfigSnapshotStore.kt
new file mode 100644
index 000000000..84bdafcdd
--- /dev/null
+++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/storage/PersistentRemoteConfigSnapshotStore.kt
@@ -0,0 +1,775 @@
+package com.qonversion.android.sdk.internal.storage
+
+import com.qonversion.android.sdk.internal.remoteconfig.RemoteConfigSnapshotApplyPolicy
+import com.qonversion.android.sdk.internal.remoteconfig.RemoteConfigSnapshotEntry
+import com.qonversion.android.sdk.internal.remoteconfig.RemoteConfigSnapshotEnvelopeParser
+import com.qonversion.android.sdk.internal.remoteconfig.RemoteConfigSnapshotRelease
+import com.qonversion.android.sdk.internal.remoteconfig.RemoteConfigSnapshotScope
+import com.qonversion.android.sdk.internal.remoteconfig.RemoteConfigSnapshotState
+import com.squareup.moshi.JsonClass
+import com.squareup.moshi.Moshi
+import okio.ByteString.Companion.decodeBase64
+import okio.ByteString.Companion.toByteString
+import java.nio.ByteBuffer
+import java.security.MessageDigest
+
+internal const val REMOTE_CONFIG_SNAPSHOT_INDEX_KEY = "qonversion_remote_config_v2_snapshot_index"
+
+private const val REMOTE_CONFIG_SNAPSHOT_STORAGE_PREFIX = "qonversion_remote_config_v2_snapshot_"
+private const val REMOTE_CONFIG_SNAPSHOT_ENVELOPE_VERSION = 2
+private const val REMOTE_CONFIG_SNAPSHOT_INDEX_VERSION = 1
+private const val DEFAULT_REMOTE_CONFIG_SNAPSHOT_MAX_SCOPES = 16
+private const val DEFAULT_REMOTE_CONFIG_SNAPSHOT_MAX_STATE_BYTES = 20 * 1024 * 1024
+private const val DEFAULT_REMOTE_CONFIG_SNAPSHOT_MAX_TOTAL_BYTES = 32 * 1024 * 1024
+private const val REMOTE_CONFIG_SNAPSHOT_MAX_INDEX_BYTES = 64 * 1024
+private const val BYTE_MASK = 0xff
+private const val LOW_NIBBLE_MASK = 0x0f
+private const val NIBBLE_SHIFT = 4
+private const val HEX = "0123456789abcdef"
+private val REMOTE_CONFIG_SNAPSHOT_STORAGE_KEY_PATTERN =
+ Regex("^${Regex.escape(REMOTE_CONFIG_SNAPSHOT_STORAGE_PREFIX)}[0-9a-f]{64}$")
+
+internal enum class RemoteConfigSnapshotLoadStatus {
+ Found,
+ Missing,
+ Failed,
+ Corrupt,
+}
+
+internal data class RemoteConfigSnapshotLoadResult(
+ val status: RemoteConfigSnapshotLoadStatus,
+ val state: RemoteConfigSnapshotState? = null,
+) {
+ init {
+ require((status == RemoteConfigSnapshotLoadStatus.Found) == (state != null))
+ }
+}
+
+internal interface RemoteConfigSnapshotStore {
+ fun load(scope: RemoteConfigSnapshotScope): RemoteConfigSnapshotLoadResult
+ fun save(scope: RemoteConfigSnapshotScope, state: RemoteConfigSnapshotState): Boolean
+}
+
+internal class PersistentRemoteConfigSnapshotStore(
+ private val cache: Cache,
+ moshi: Moshi,
+ private val maxScopes: Int = DEFAULT_REMOTE_CONFIG_SNAPSHOT_MAX_SCOPES,
+ private val maxStateBytes: Int = DEFAULT_REMOTE_CONFIG_SNAPSHOT_MAX_STATE_BYTES,
+ private val maxTotalBytes: Int = DEFAULT_REMOTE_CONFIG_SNAPSHOT_MAX_TOTAL_BYTES,
+) : RemoteConfigSnapshotStore {
+ private val envelopeAdapter = moshi.adapter(PersistedRemoteConfigSnapshotEnvelope::class.java).failOnUnknown()
+ private val legacyEnvelopeAdapter =
+ moshi.adapter(PersistedRemoteConfigSnapshotEnvelopeV1::class.java).failOnUnknown()
+ private val indexAdapter = moshi.adapter(PersistedRemoteConfigSnapshotIndex::class.java).failOnUnknown()
+
+ init {
+ require(maxScopes > 0)
+ require(maxStateBytes > 0)
+ require(maxTotalBytes > 0)
+ }
+
+ @Synchronized
+ @Suppress("ReturnCount")
+ override fun load(scope: RemoteConfigSnapshotScope): RemoteConfigSnapshotLoadResult = try {
+ when (val result = loadTrusted(scope)) {
+ is TrustedSnapshotLoad.Found -> {
+ RemoteConfigSnapshotLoadResult(RemoteConfigSnapshotLoadStatus.Found, result.state)
+ }
+ TrustedSnapshotLoad.Missing -> {
+ RemoteConfigSnapshotLoadResult(RemoteConfigSnapshotLoadStatus.Missing)
+ }
+ TrustedSnapshotLoad.Corrupt -> {
+ RemoteConfigSnapshotLoadResult(RemoteConfigSnapshotLoadStatus.Corrupt)
+ }
+ }
+ } catch (_: Exception) {
+ RemoteConfigSnapshotLoadResult(RemoteConfigSnapshotLoadStatus.Failed)
+ }
+
+ @Suppress("ReturnCount")
+ private fun loadTrusted(scope: RemoteConfigSnapshotScope): TrustedSnapshotLoad {
+ val storageKey = remoteConfigSnapshotStorageKey(scope)
+ val index = loadIndex(clearInvalid = true)
+ val rawEnvelope = cache.getString(storageKey, null) ?: return TrustedSnapshotLoad.Missing
+ val envelopeBytes = rawEnvelope.toByteArray(Charsets.UTF_8).size
+ val envelope = rawEnvelope
+ .takeIf {
+ envelopeBytes <= maxStateBytes && envelopeBytes <= maxTotalBytes
+ }
+ ?.let(::decodeEnvelope)
+ val decoded = envelope
+ ?.takeIf { it.matches(scope, storageKey) }
+ ?.state
+ ?.toDecodedModel(scope.environment)
+ if (decoded != null) {
+ val rewritten = decoded.requiresRewrite && save(scope, decoded.state)
+ if (!rewritten) {
+ if (storageKey in index.storageKeys) {
+ promoteAfterRead(storageKey, index)
+ } else {
+ admitRecoveredAfterRead(storageKey, envelopeBytes, index)
+ }
+ }
+ return TrustedSnapshotLoad.Found(decoded.state)
+ }
+ removeInvalidEnvelope(storageKey, index)
+ return if (
+ envelopeBytes <= maxStateBytes && envelopeBytes <= maxTotalBytes &&
+ isLegacyEnvelopeV1(rawEnvelope, scope)
+ ) {
+ TrustedSnapshotLoad.Missing
+ } else {
+ TrustedSnapshotLoad.Corrupt
+ }
+ }
+
+ private fun isLegacyEnvelopeV1(raw: String, scope: RemoteConfigSnapshotScope): Boolean {
+ return try {
+ val envelope = legacyEnvelopeAdapter.fromJson(raw) ?: return false
+ envelope.version == 1 && envelope.projectKey == scope.projectKey &&
+ envelope.environment == scope.environment &&
+ envelope.canonicalUserId == scope.canonicalUserId &&
+ envelope.state.isValid(scope.environment)
+ } catch (_: Exception) {
+ false
+ }
+ }
+
+ @Synchronized
+ @Suppress("ReturnCount")
+ override fun save(scope: RemoteConfigSnapshotScope, state: RemoteConfigSnapshotState): Boolean {
+ val storageKey = remoteConfigSnapshotStorageKey(scope)
+ val envelope = PersistedRemoteConfigSnapshotEnvelope(
+ version = REMOTE_CONFIG_SNAPSHOT_ENVELOPE_VERSION,
+ projectKey = scope.projectKey,
+ environment = scope.environment,
+ canonicalUserId = scope.canonicalUserId,
+ state = state.toPersisted(),
+ )
+ val stateJson = try {
+ envelopeAdapter.toJson(envelope)
+ } catch (_: Exception) {
+ return false
+ }
+ val stateBytes = stateJson.toByteArray(Charsets.UTF_8).size
+ if (stateBytes > maxStateBytes || stateBytes > maxTotalBytes) return false
+
+ val existing = try {
+ loadIndex(clearInvalid = false).storageKeys
+ } catch (_: Exception) {
+ return false
+ }
+ val admitted = try {
+ existing.filter { it != storageKey }.mapNotNull { key ->
+ admittedEnvelopeSize(key)?.let { bytes -> StoredEnvelope(key, bytes) }
+ }
+ } catch (_: Exception) {
+ return false
+ }
+ val retained = boundedNewest(admitted + StoredEnvelope(storageKey, stateBytes))
+ val retainedKeys = retained.map(StoredEnvelope::storageKey)
+ val evicted = (existing.toSet() - retainedKeys.toSet()) - storageKey
+ val indexJson = try {
+ indexAdapter.toJson(
+ PersistedRemoteConfigSnapshotIndex(
+ version = REMOTE_CONFIG_SNAPSHOT_INDEX_VERSION,
+ storageKeys = retainedKeys,
+ ),
+ )
+ } catch (_: Exception) {
+ return false
+ }
+ if (indexJson.toByteArray(Charsets.UTF_8).size > REMOTE_CONFIG_SNAPSHOT_MAX_INDEX_BYTES) return false
+
+ return try {
+ cache.updateStringsDurably(
+ values = mapOf(
+ storageKey to stateJson,
+ REMOTE_CONFIG_SNAPSHOT_INDEX_KEY to indexJson,
+ ),
+ removedKeys = evicted,
+ )
+ } catch (_: Exception) {
+ false
+ }
+ }
+
+ @Suppress("ReturnCount")
+ private fun loadIndex(clearInvalid: Boolean): PersistedRemoteConfigSnapshotIndex {
+ val raw = cache.getString(REMOTE_CONFIG_SNAPSHOT_INDEX_KEY, null) ?: return emptyIndex()
+ val decoded = try {
+ raw.takeIf { it.toByteArray(Charsets.UTF_8).size <= REMOTE_CONFIG_SNAPSHOT_MAX_INDEX_BYTES }
+ ?.let(indexAdapter::fromJson)
+ ?.takeIf { it.isValid() }
+ } catch (_: Exception) {
+ null
+ }
+ if (decoded != null) return decoded
+ if (clearInvalid) clearInvalidIndex()
+ return emptyIndex()
+ }
+
+ private fun PersistedRemoteConfigSnapshotIndex.isValid(): Boolean =
+ version == REMOTE_CONFIG_SNAPSHOT_INDEX_VERSION &&
+ storageKeys.size <= maxScopes &&
+ storageKeys.size == storageKeys.distinct().size &&
+ storageKeys.all(REMOTE_CONFIG_SNAPSHOT_STORAGE_KEY_PATTERN::matches)
+
+ private fun PersistedRemoteConfigSnapshotEnvelope.matches(
+ scope: RemoteConfigSnapshotScope,
+ storageKey: String,
+ ): Boolean = version == REMOTE_CONFIG_SNAPSHOT_ENVELOPE_VERSION &&
+ projectKey == scope.projectKey && environment == scope.environment &&
+ canonicalUserId == scope.canonicalUserId &&
+ remoteConfigSnapshotStorageKey(scope) == storageKey
+
+ private fun decodeEnvelope(raw: String): PersistedRemoteConfigSnapshotEnvelope? = try {
+ envelopeAdapter.fromJson(raw)
+ } catch (_: Exception) {
+ null
+ }
+
+ @Suppress("ReturnCount")
+ private fun admittedEnvelopeSize(storageKey: String): Int? {
+ val raw = cache.getString(storageKey, null) ?: return null
+ val rawBytes = raw.toByteArray(Charsets.UTF_8).size
+ if (rawBytes > maxStateBytes || rawBytes > maxTotalBytes) return null
+ val envelope = decodeEnvelope(raw) ?: return null
+ val scope = try {
+ RemoteConfigSnapshotScope(
+ projectKey = envelope.projectKey,
+ environment = envelope.environment,
+ canonicalUserId = envelope.canonicalUserId,
+ )
+ } catch (_: IllegalArgumentException) {
+ return null
+ }
+ if (!envelope.matches(scope, storageKey)) return null
+ val decoded = envelope.state.toDecodedModel(scope.environment)
+ if (decoded.requiresRewrite && decoded.state == RemoteConfigSnapshotState()) return null
+ return rawBytes
+ }
+
+ private fun boundedNewest(envelopes: List): List {
+ val retained = envelopes.takeLast(maxScopes).toMutableList()
+ var totalBytes = retained.sumOf { envelope -> envelope.bytes.toLong() }
+ while (totalBytes > maxTotalBytes && retained.size > 1) {
+ totalBytes -= retained.removeAt(0).bytes
+ }
+ return retained
+ }
+
+ private fun removeInvalidEnvelope(
+ storageKey: String,
+ index: PersistedRemoteConfigSnapshotIndex,
+ ) {
+ val remaining = index.storageKeys - storageKey
+ val values = if (remaining.isEmpty()) {
+ emptyMap()
+ } else {
+ mapOf(
+ REMOTE_CONFIG_SNAPSHOT_INDEX_KEY to indexAdapter.toJson(
+ PersistedRemoteConfigSnapshotIndex(REMOTE_CONFIG_SNAPSHOT_INDEX_VERSION, remaining),
+ ),
+ )
+ }
+ val removed = buildSet {
+ add(storageKey)
+ if (remaining.isEmpty()) add(REMOTE_CONFIG_SNAPSHOT_INDEX_KEY)
+ }
+ try {
+ cache.updateStringsDurably(values, removed)
+ } catch (_: Exception) {
+ // Reads stay fail-closed if corruption cleanup cannot be committed.
+ }
+ }
+
+ @Suppress("ReturnCount")
+ private fun admitRecoveredAfterRead(
+ storageKey: String,
+ storageBytes: Int,
+ index: PersistedRemoteConfigSnapshotIndex,
+ ) {
+ val admitted = try {
+ index.storageKeys.mapNotNull { key ->
+ admittedEnvelopeSize(key)?.let { bytes -> StoredEnvelope(key, bytes) }
+ }
+ } catch (_: Exception) {
+ return
+ }
+ val retained = boundedNewest(admitted + StoredEnvelope(storageKey, storageBytes))
+ val retainedKeys = retained.map(StoredEnvelope::storageKey)
+ val removed = index.storageKeys.toSet() - retainedKeys.toSet()
+ val indexJson = try {
+ indexAdapter.toJson(
+ PersistedRemoteConfigSnapshotIndex(
+ version = REMOTE_CONFIG_SNAPSHOT_INDEX_VERSION,
+ storageKeys = retainedKeys,
+ ),
+ )
+ } catch (_: Exception) {
+ return
+ }
+ if (indexJson.toByteArray(Charsets.UTF_8).size > REMOTE_CONFIG_SNAPSHOT_MAX_INDEX_BYTES) return
+ try {
+ cache.updateStringsDurably(
+ values = mapOf(REMOTE_CONFIG_SNAPSHOT_INDEX_KEY to indexJson),
+ removedKeys = removed,
+ )
+ } catch (_: Exception) {
+ // An exact valid envelope remains safe to serve even if its index cannot be repaired.
+ }
+ }
+
+ @Suppress("ReturnCount")
+ private fun promoteAfterRead(
+ storageKey: String,
+ index: PersistedRemoteConfigSnapshotIndex,
+ ) {
+ if (index.storageKeys.lastOrNull() == storageKey) return
+ val promoted = PersistedRemoteConfigSnapshotIndex(
+ version = REMOTE_CONFIG_SNAPSHOT_INDEX_VERSION,
+ storageKeys = (index.storageKeys - storageKey) + storageKey,
+ )
+ val indexJson = try {
+ indexAdapter.toJson(promoted)
+ } catch (_: Exception) {
+ return
+ }
+ if (indexJson.toByteArray(Charsets.UTF_8).size > REMOTE_CONFIG_SNAPSHOT_MAX_INDEX_BYTES) return
+ try {
+ cache.updateStringsDurably(
+ values = mapOf(REMOTE_CONFIG_SNAPSHOT_INDEX_KEY to indexJson),
+ removedKeys = emptySet(),
+ )
+ } catch (_: Exception) {
+ // A failed recency hint must never make an otherwise valid snapshot unavailable.
+ }
+ }
+
+ private fun clearInvalidIndex() {
+ try {
+ cache.updateStringsDurably(emptyMap(), setOf(REMOTE_CONFIG_SNAPSHOT_INDEX_KEY))
+ } catch (_: Exception) {
+ // The invalid index remains unusable and no scope is trusted from it.
+ }
+ }
+
+ private fun emptyIndex() = PersistedRemoteConfigSnapshotIndex(
+ version = REMOTE_CONFIG_SNAPSHOT_INDEX_VERSION,
+ storageKeys = emptyList(),
+ )
+
+ private data class StoredEnvelope(
+ val storageKey: String,
+ val bytes: Int,
+ )
+
+ private sealed class TrustedSnapshotLoad {
+ data class Found(val state: RemoteConfigSnapshotState) : TrustedSnapshotLoad()
+ data object Missing : TrustedSnapshotLoad()
+ data object Corrupt : TrustedSnapshotLoad()
+ }
+}
+
+@JsonClass(generateAdapter = true)
+internal data class PersistedRemoteConfigSnapshotIndex(
+ val version: Int,
+ val storageKeys: List,
+)
+
+@JsonClass(generateAdapter = true)
+internal data class PersistedRemoteConfigSnapshotEnvelope(
+ val version: Int,
+ val projectKey: String,
+ val environment: String,
+ val canonicalUserId: String,
+ val state: PersistedRemoteConfigSnapshotState,
+)
+
+@JsonClass(generateAdapter = true)
+internal data class PersistedRemoteConfigSnapshotEnvelopeV1(
+ val version: Int,
+ val projectKey: String,
+ val environment: String,
+ val canonicalUserId: String,
+ val state: PersistedRemoteConfigSnapshotStateV1,
+)
+
+@JsonClass(generateAdapter = true)
+internal data class PersistedRemoteConfigSnapshotStateV1(
+ val candidate: PersistedRemoteConfigSnapshotReleaseV1?,
+ val active: PersistedRemoteConfigSnapshotReleaseV1?,
+ val previous: PersistedRemoteConfigSnapshotReleaseV1?,
+ val didActivate: Boolean,
+)
+
+@JsonClass(generateAdapter = true)
+internal data class PersistedRemoteConfigSnapshotReleaseV1(
+ val releaseUid: String,
+ val releaseNumber: Long,
+ val manifestContentHash: String,
+ val entries: List,
+ val canonicalBodyBase64: String?,
+ val strongETag: String?,
+)
+
+@JsonClass(generateAdapter = true)
+internal data class PersistedRemoteConfigSnapshotState(
+ val candidate: PersistedRemoteConfigSnapshotRelease?,
+ val active: PersistedRemoteConfigSnapshotRelease?,
+ val previous: PersistedRemoteConfigSnapshotRelease?,
+ val didActivate: Boolean,
+ val latestAdmissionToken: Long,
+ val stateDigest: String,
+)
+
+@JsonClass(generateAdapter = true)
+internal data class PersistedRemoteConfigSnapshotRelease(
+ val releaseUid: String,
+ val releaseNumber: Long,
+ val manifestContentHash: String,
+ val entries: List,
+ val canonicalBodyBase64: String? = null,
+ val strongETag: String? = null,
+ val contextFingerprint: String?,
+ val admissionToken: Long,
+ val contentDigest: String,
+)
+
+@JsonClass(generateAdapter = true)
+internal data class PersistedRemoteConfigSnapshotEntry(
+ val key: String,
+ val rawBase64: String?,
+ val variationUid: String?,
+ val applyPolicy: Int,
+ val metadataBase64: String?,
+)
+
+private data class DecodedRemoteConfigSnapshotState(
+ val state: RemoteConfigSnapshotState,
+ val requiresRewrite: Boolean,
+)
+
+@Suppress("ComplexCondition", "ReturnCount")
+private fun PersistedRemoteConfigSnapshotStateV1.isValid(expectedEnvironment: String): Boolean {
+ val candidateModel = candidate?.toLegacyModel(expectedEnvironment)
+ val activeModel = active?.toLegacyModel(expectedEnvironment)
+ val previousModel = previous?.toLegacyModel(expectedEnvironment)
+ if (candidate != null && candidateModel == null ||
+ active != null && activeModel == null ||
+ previous != null && previousModel == null
+ ) {
+ return false
+ }
+ if (!didActivate && (activeModel != null || previousModel != null)) return false
+ if (activeModel == null && previousModel != null) return false
+ return true
+}
+
+@Suppress("ComplexCondition", "ReturnCount")
+private fun PersistedRemoteConfigSnapshotReleaseV1.toLegacyModel(
+ expectedEnvironment: String,
+): RemoteConfigSnapshotRelease? {
+ return try {
+ val canonicalBody = canonicalBodyBase64.decodeCanonicalBase64()
+ if ((canonicalBodyBase64 == null) != (strongETag == null) ||
+ (canonicalBodyBase64 != null && canonicalBody == null)
+ ) {
+ return null
+ }
+ val decodedEntries = entries.map { it.toModel() ?: return null }
+ var contextFingerprint: String? = null
+ if (canonicalBody != null) {
+ val envelope = RemoteConfigSnapshotEnvelopeParser().parseBoundBody(
+ canonicalBody,
+ requireNotNull(strongETag),
+ ) ?: return null
+ if (envelope.environmentUid != expectedEnvironment ||
+ envelope.release.releaseUid != releaseUid ||
+ envelope.release.releaseNumber != releaseNumber ||
+ envelope.release.manifestContentHash != manifestContentHash
+ ) {
+ return null
+ }
+ val persistedValues = decodedEntries.filterNot(RemoteConfigSnapshotEntry::isTombstone)
+ if (persistedValues.size != envelope.release.entries.size ||
+ persistedValues.any { entry -> !entry.contentEquals(envelope.release.entry(entry.key)) }
+ ) {
+ return null
+ }
+ contextFingerprint = envelope.contextFingerprint
+ }
+ RemoteConfigSnapshotRelease(
+ releaseUid = releaseUid,
+ releaseNumber = releaseNumber,
+ manifestContentHash = manifestContentHash,
+ entries = decodedEntries,
+ canonicalBody = canonicalBody,
+ strongETag = strongETag,
+ contextFingerprint = contextFingerprint,
+ )
+ } catch (_: IllegalArgumentException) {
+ null
+ }
+}
+
+@Suppress("ComplexMethod", "LongMethod")
+private fun PersistedRemoteConfigSnapshotState.toDecodedModel(
+ expectedEnvironment: String,
+): DecodedRemoteConfigSnapshotState {
+ val stateDigestMatches = stateDigest == calculateRemoteConfigSnapshotStateDigest(
+ latestAdmissionToken = latestAdmissionToken,
+ didActivate = didActivate,
+ candidate = candidate,
+ active = active,
+ previous = previous,
+ )
+ var candidateModel = candidate?.toModel(expectedEnvironment)
+ var activeModel = active?.toModel(expectedEnvironment)
+ var previousModel = previous?.toModel(expectedEnvironment)
+ var requiresRewrite =
+ !stateDigestMatches ||
+ (candidate != null && candidateModel == null) ||
+ (active != null && activeModel == null) ||
+ (previous != null && previousModel == null)
+ val normalizedDidActivate = if (stateDigestMatches) didActivate else activeModel != null
+
+ val persistedCandidateAndActiveShareGeneration = candidate != null && active != null &&
+ candidate.admissionToken == active.admissionToken
+ if (persistedCandidateAndActiveShareGeneration) {
+ if (candidateModel == null) {
+ if (activeModel != null) requiresRewrite = true
+ activeModel = null
+ } else {
+ if (!candidateModel.contentEquals(activeModel)) requiresRewrite = true
+ activeModel = candidateModel
+ }
+ }
+
+ if (!normalizedDidActivate && activeModel != null) {
+ activeModel = null
+ previousModel = null
+ requiresRewrite = true
+ }
+ if (activeModel == null && previousModel != null) {
+ previousModel = null
+ requiresRewrite = true
+ }
+ if (candidateModel != null && activeModel != null &&
+ candidateModel.admissionToken < activeModel.admissionToken
+ ) {
+ if (candidateModel.canonicalBodyBytes != null) {
+ activeModel = null
+ previousModel = null
+ } else {
+ candidateModel = null
+ }
+ requiresRewrite = true
+ }
+ if (candidateModel != null && activeModel != null &&
+ candidateModel.admissionToken == activeModel.admissionToken
+ ) {
+ if (!candidateModel.contentEquals(activeModel)) requiresRewrite = true
+ activeModel = candidateModel
+ }
+ if (previousModel != null && activeModel != null &&
+ previousModel.admissionToken >= activeModel.admissionToken
+ ) {
+ previousModel = null
+ requiresRewrite = true
+ }
+ val highestSlotToken = maxOf(
+ candidateModel?.admissionToken ?: 0,
+ activeModel?.admissionToken ?: 0,
+ previousModel?.admissionToken ?: 0,
+ )
+ val normalizedLatestAdmissionToken = if (stateDigestMatches) {
+ latestAdmissionToken.coerceAtLeast(highestSlotToken)
+ } else {
+ highestSlotToken
+ }
+ if (normalizedLatestAdmissionToken != latestAdmissionToken) requiresRewrite = true
+ return DecodedRemoteConfigSnapshotState(
+ state = RemoteConfigSnapshotState(
+ candidate = candidateModel,
+ active = activeModel,
+ previous = previousModel,
+ didActivate = normalizedDidActivate,
+ latestAdmissionToken = normalizedLatestAdmissionToken,
+ ),
+ requiresRewrite = requiresRewrite,
+ )
+}
+
+@Suppress("ComplexCondition", "ComplexMethod", "ReturnCount")
+private fun PersistedRemoteConfigSnapshotRelease.toModel(
+ expectedEnvironment: String,
+): RemoteConfigSnapshotRelease? {
+ return try {
+ val canonicalBody = canonicalBodyBase64.decodeCanonicalBase64()
+ if ((canonicalBodyBase64 == null) != (strongETag == null) ||
+ (canonicalBodyBase64 != null && canonicalBody == null)
+ ) {
+ return null
+ }
+ val decodedEntries = entries.map { it.toModel() ?: return null }
+ if (canonicalBody != null) {
+ val envelope = RemoteConfigSnapshotEnvelopeParser().parseBoundBody(
+ canonicalBody,
+ requireNotNull(strongETag),
+ ) ?: return null
+ if (envelope.environmentUid != expectedEnvironment ||
+ envelope.release.releaseUid != releaseUid ||
+ envelope.release.releaseNumber != releaseNumber ||
+ envelope.release.manifestContentHash != manifestContentHash ||
+ envelope.contextFingerprint != contextFingerprint
+ ) {
+ return null
+ }
+ val persistedValues = decodedEntries.filterNot(RemoteConfigSnapshotEntry::isTombstone)
+ if (persistedValues.size != envelope.release.entries.size ||
+ persistedValues.any { entry -> !entry.contentEquals(envelope.release.entry(entry.key)) }
+ ) {
+ return null
+ }
+ }
+ RemoteConfigSnapshotRelease(
+ releaseUid = releaseUid,
+ releaseNumber = releaseNumber,
+ manifestContentHash = manifestContentHash,
+ entries = decodedEntries,
+ canonicalBody = canonicalBody,
+ strongETag = strongETag,
+ contextFingerprint = contextFingerprint,
+ admissionToken = admissionToken,
+ ).takeIf { it.contentDigest == contentDigest }
+ } catch (_: IllegalArgumentException) {
+ null
+ }
+}
+
+@Suppress("ReturnCount")
+private fun PersistedRemoteConfigSnapshotEntry.toModel(): RemoteConfigSnapshotEntry? {
+ return try {
+ val policy = when (applyPolicy) {
+ 1 -> RemoteConfigSnapshotApplyPolicy.OnNextActivate
+ 2 -> RemoteConfigSnapshotApplyPolicy.Immediate
+ else -> return null
+ }
+ val raw = rawBase64.decodeCanonicalBase64()
+ val metadata = metadataBase64.decodeCanonicalBase64()
+ when {
+ rawBase64 == null && metadataBase64 == null && variationUid == null ->
+ RemoteConfigSnapshotEntry.tombstone(key)
+ raw == null || variationUid == null || (metadataBase64 != null && metadata == null) -> null
+ else -> RemoteConfigSnapshotEntry.value(key, raw, variationUid, policy, metadata)
+ }
+ } catch (_: IllegalArgumentException) {
+ null
+ }
+}
+
+@Suppress("ReturnCount")
+private fun String?.decodeCanonicalBase64(): ByteArray? {
+ if (this == null) return null
+ val decoded = decodeBase64() ?: return null
+ return decoded.takeIf { it.base64() == this }?.toByteArray()
+}
+
+private fun RemoteConfigSnapshotState.toPersisted(): PersistedRemoteConfigSnapshotState {
+ val persistedCandidate = candidate?.toPersisted(includeTransportEvidence = true)
+ val persistedActive = active?.toPersisted(includeTransportEvidence = false)
+ val persistedPrevious = previous?.toPersisted(includeTransportEvidence = false)
+ return PersistedRemoteConfigSnapshotState(
+ candidate = persistedCandidate,
+ active = persistedActive,
+ previous = persistedPrevious,
+ didActivate = didActivate,
+ latestAdmissionToken = latestAdmissionToken,
+ stateDigest = calculateRemoteConfigSnapshotStateDigest(
+ latestAdmissionToken = latestAdmissionToken,
+ didActivate = didActivate,
+ candidate = persistedCandidate,
+ active = persistedActive,
+ previous = persistedPrevious,
+ ),
+ )
+}
+
+private fun RemoteConfigSnapshotRelease.toPersisted(
+ includeTransportEvidence: Boolean,
+) = PersistedRemoteConfigSnapshotRelease(
+ releaseUid = releaseUid,
+ releaseNumber = releaseNumber,
+ manifestContentHash = manifestContentHash,
+ entries = entries.values.sortedBy { it.key }.map { entry ->
+ PersistedRemoteConfigSnapshotEntry(
+ key = entry.key,
+ rawBase64 = entry.rawValueBytes?.toByteString()?.base64(),
+ variationUid = entry.variationUid,
+ applyPolicy = when (entry.applyPolicy) {
+ RemoteConfigSnapshotApplyPolicy.OnNextActivate -> 1
+ RemoteConfigSnapshotApplyPolicy.Immediate -> 2
+ },
+ metadataBase64 = entry.metadataBytes?.toByteString()?.base64(),
+ )
+ },
+ canonicalBodyBase64 = canonicalBodyBytes
+ ?.takeIf { includeTransportEvidence }
+ ?.toByteString()
+ ?.base64(),
+ strongETag = strongETag?.takeIf { includeTransportEvidence },
+ contextFingerprint = contextFingerprint,
+ admissionToken = admissionToken,
+ contentDigest = contentDigest,
+)
+
+private fun calculateRemoteConfigSnapshotStateDigest(
+ latestAdmissionToken: Long,
+ didActivate: Boolean,
+ candidate: PersistedRemoteConfigSnapshotRelease?,
+ active: PersistedRemoteConfigSnapshotRelease?,
+ previous: PersistedRemoteConfigSnapshotRelease?,
+): String {
+ val digest = MessageDigest.getInstance("SHA-256")
+ digest.updateLengthPrefixed("remote-config-snapshot-state-v1".encodeToByteArray())
+ digest.update(ByteBuffer.allocate(Long.SIZE_BYTES).putLong(latestAdmissionToken).array())
+ digest.update(if (didActivate) STATE_TRUE_MARKER else STATE_FALSE_MARKER)
+ listOf(candidate, active, previous).forEachIndexed { index, release ->
+ digest.update(index.toByte())
+ if (release == null) {
+ digest.update(STATE_ABSENT_MARKER)
+ } else {
+ digest.update(STATE_PRESENT_MARKER)
+ digest.updateLengthPrefixed(release.contentDigest.encodeToByteArray())
+ }
+ }
+ return digest.digest().toLowercaseHex()
+}
+
+private fun MessageDigest.updateLengthPrefixed(bytes: ByteArray) {
+ update(ByteBuffer.allocate(Int.SIZE_BYTES).putInt(bytes.size).array())
+ update(bytes)
+}
+
+private fun ByteArray.toLowercaseHex(): String = joinToString(separator = "") { byte ->
+ val value = byte.toInt() and BYTE_MASK
+ "${HEX[value ushr NIBBLE_SHIFT]}${HEX[value and LOW_NIBBLE_MASK]}"
+}
+
+internal fun remoteConfigSnapshotStorageKey(scope: RemoteConfigSnapshotScope): String {
+ val messageDigest = MessageDigest.getInstance("SHA-256")
+ listOf(scope.projectKey, scope.environment, scope.canonicalUserId).forEach { component ->
+ val bytes = component.toByteArray(Charsets.UTF_8)
+ messageDigest.update(ByteBuffer.allocate(Int.SIZE_BYTES).putInt(bytes.size).array())
+ messageDigest.update(bytes)
+ }
+ val digest = messageDigest.digest().toLowercaseHex()
+ return "$REMOTE_CONFIG_SNAPSHOT_STORAGE_PREFIX$digest"
+}
+
+private const val STATE_FALSE_MARKER: Byte = 0
+private const val STATE_TRUE_MARKER: Byte = 1
+private const val STATE_ABSENT_MARKER: Byte = 2
+private const val STATE_PRESENT_MARKER: Byte = 3
diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/storage/SharedPreferencesCache.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/storage/SharedPreferencesCache.kt
index 323ead25c..1141196d5 100644
--- a/sdk/src/main/java/com/qonversion/android/sdk/internal/storage/SharedPreferencesCache.kt
+++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/storage/SharedPreferencesCache.kt
@@ -31,6 +31,45 @@ internal class SharedPreferencesCache(
override fun putString(key: String, value: String?) =
preferences.edit().putString(key, value).apply()
+ override fun updateStrings(values: Map, removedKeys: Set) {
+ preferences.edit().also { editor ->
+ removedKeys.forEach { key -> editor.remove(key) }
+ values.forEach { (key, value) -> editor.putString(key, value) }
+ }.apply()
+ }
+
+ @Suppress("TooGenericExceptionCaught") // Any runtime commit failure needs the same in-memory rollback.
+ override fun updateStringsDurably(values: Map, removedKeys: Set): Boolean {
+ val affectedKeys = values.keys + removedKeys
+ val previousValues = affectedKeys.associateWith { key ->
+ val exists = preferences.contains(key)
+ exists to if (exists) preferences.getString(key, null) else null
+ }
+ val committed = try {
+ preferences.edit().also { editor ->
+ removedKeys.forEach { key -> editor.remove(key) }
+ values.forEach { (key, value) -> editor.putString(key, value) }
+ }.commit()
+ } catch (error: RuntimeException) {
+ restoreStrings(previousValues)
+ throw error
+ }
+ if (!committed) restoreStrings(previousValues)
+ return committed
+ }
+
+ private fun restoreStrings(previousValues: Map>) {
+ preferences.edit().also { editor ->
+ previousValues.forEach { (key, previous) ->
+ if (previous.first) {
+ editor.putString(key, previous.second)
+ } else {
+ editor.remove(key)
+ }
+ }
+ }.apply()
+ }
+
override fun getString(key: String, defValue: String?): String? =
preferences.getString(key, defValue)
diff --git a/sdk/src/main/java/com/qonversion/android/sdk/listeners/QRemoteConfigUpdateListener.kt b/sdk/src/main/java/com/qonversion/android/sdk/listeners/QRemoteConfigUpdateListener.kt
new file mode 100644
index 000000000..3504fabc2
--- /dev/null
+++ b/sdk/src/main/java/com/qonversion/android/sdk/listeners/QRemoteConfigUpdateListener.kt
@@ -0,0 +1,15 @@
+package com.qonversion.android.sdk.listeners
+
+import com.qonversion.android.sdk.ExperimentalQonversionApi
+import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigUpdate
+
+/**
+ * Notified whenever a Remote Config release becomes current.
+ *
+ * Delivered on the main thread, after the swap is committed, so reading
+ * `QRemoteConfigSnapshots.current` from the callback already observes the new release.
+ */
+@ExperimentalQonversionApi
+fun interface QRemoteConfigUpdateListener {
+ fun onRemoteConfigUpdated(update: QRemoteConfigUpdate)
+}
diff --git a/sdk/src/main/java/com/qonversion/android/sdk/listeners/QonversionRemoteConfigActivationCallback.kt b/sdk/src/main/java/com/qonversion/android/sdk/listeners/QonversionRemoteConfigActivationCallback.kt
new file mode 100644
index 000000000..1582af8c4
--- /dev/null
+++ b/sdk/src/main/java/com/qonversion/android/sdk/listeners/QonversionRemoteConfigActivationCallback.kt
@@ -0,0 +1,12 @@
+package com.qonversion.android.sdk.listeners
+
+import com.qonversion.android.sdk.ExperimentalQonversionApi
+import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigActivationResult
+
+/**
+ * Called exactly once, on the main thread, when a Remote Config activation completes.
+ */
+@ExperimentalQonversionApi
+fun interface QonversionRemoteConfigActivationCallback {
+ fun onResult(result: QRemoteConfigActivationResult)
+}
diff --git a/sdk/src/main/java/com/qonversion/android/sdk/listeners/QonversionRemoteConfigFetchCallback.kt b/sdk/src/main/java/com/qonversion/android/sdk/listeners/QonversionRemoteConfigFetchCallback.kt
new file mode 100644
index 000000000..6f6c14aa8
--- /dev/null
+++ b/sdk/src/main/java/com/qonversion/android/sdk/listeners/QonversionRemoteConfigFetchCallback.kt
@@ -0,0 +1,12 @@
+package com.qonversion.android.sdk.listeners
+
+import com.qonversion.android.sdk.ExperimentalQonversionApi
+import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigFetchResult
+
+/**
+ * Called exactly once, on the main thread, when a Remote Config fetch completes or times out.
+ */
+@ExperimentalQonversionApi
+fun interface QonversionRemoteConfigFetchCallback {
+ fun onResult(result: QRemoteConfigFetchResult)
+}
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/dto/remoteconfig/QRemoteConfigV2ConfigTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigV2ConfigTest.kt
new file mode 100644
index 000000000..3d90cd688
--- /dev/null
+++ b/sdk/src/test/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigV2ConfigTest.kt
@@ -0,0 +1,72 @@
+@file:OptIn(ExperimentalQonversionApi::class)
+
+package com.qonversion.android.sdk.dto.remoteconfig
+
+import com.qonversion.android.sdk.ExperimentalQonversionApi
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertThrows
+import org.junit.Test
+
+/**
+ * The v2 configuration is rejected at construction rather than at `build()`: it carries values the
+ * app cannot invent, so failing at the line that supplies them is what makes the mistake findable.
+ */
+internal class QRemoteConfigV2ConfigTest {
+
+ @Test
+ fun `a well formed configuration is accepted verbatim`() {
+ val config = QRemoteConfigV2Config(
+ baseUrl = "https://gateway.example.com/",
+ environmentUid = "production",
+ )
+
+ assertEquals("https://gateway.example.com/", config.baseUrl)
+ assertEquals("production", config.environmentUid)
+ // Unset interval means "auto": the build-mode-dependent default is resolved later, so the
+ // configuration itself carries the sentinel untouched.
+ assertEquals(0, config.minFetchIntervalSeconds)
+ assertEquals(null, config.identifyAssertionProvider)
+ }
+
+ @Test
+ fun `an explicit minimum fetch interval is accepted verbatim`() {
+ assertEquals(300, config(minFetchIntervalSeconds = 300).minFetchIntervalSeconds)
+ assertEquals(0, config(minFetchIntervalSeconds = 0).minFetchIntervalSeconds)
+ }
+
+ @Test
+ fun `every malformed field is rejected`() {
+ val malformed = listOf QRemoteConfigV2Config>>(
+ "relative base url" to { config(baseUrl = "gateway.example.com") },
+ "scheme-less base url" to { config(baseUrl = "//gateway.example.com") },
+ "empty environment" to { config(environmentUid = "") },
+ "over-long environment" to { config(environmentUid = "e".repeat(37)) },
+ "negative fetch interval" to { config(minFetchIntervalSeconds = -1) },
+ )
+
+ malformed.forEach { (name, build) ->
+ assertThrows(name, IllegalArgumentException::class.java) { build() }
+ }
+ }
+
+ @Test
+ fun `the configuration neither takes nor exposes a project id`() {
+ // The numeric project id is learned from the gateway session bootstrap. Re-introducing it
+ // here would put a value the app cannot verify back into the public surface. Asserted by
+ // name rather than by shape, so an unrelated field of the same type does not fail this.
+ val members = QRemoteConfigV2Config::class.java.declaredFields.map { it.name } +
+ QRemoteConfigV2Config::class.java.declaredMethods.map { it.name }
+ members.forEach { name -> assertFalse(name, name.contains("rojectId")) }
+ assertEquals(
+ setOf("baseUrl", "environmentUid", "minFetchIntervalSeconds", "identifyAssertionProvider"),
+ QRemoteConfigV2Config::class.java.declaredFields.map { it.name }.toSet(),
+ )
+ }
+
+ private fun config(
+ baseUrl: String = "https://gateway.example.com/",
+ environmentUid: String = "production",
+ minFetchIntervalSeconds: Long = 0,
+ ) = QRemoteConfigV2Config(baseUrl, environmentUid, minFetchIntervalSeconds)
+}
diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/ErrorsMoshiChainTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/ErrorsMoshiChainTest.kt
new file mode 100644
index 000000000..01e3f51f6
--- /dev/null
+++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/ErrorsMoshiChainTest.kt
@@ -0,0 +1,90 @@
+package com.qonversion.android.sdk.internal
+
+import com.qonversion.android.sdk.dto.QonversionErrorCode
+import com.qonversion.android.sdk.internal.api.Api
+import com.qonversion.android.sdk.internal.di.module.NetworkModule
+import com.squareup.moshi.JsonDataException
+import com.squareup.moshi.JsonEncodingException
+import okhttp3.OkHttpClient
+import okhttp3.mockwebserver.MockResponse
+import okhttp3.mockwebserver.MockWebServer
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Assert.fail
+import org.junit.Before
+import org.junit.Test
+import retrofit2.Retrofit
+import retrofit2.converter.moshi.MoshiConverterFactory
+
+/**
+ * Pins which exception types the real Retrofit+Moshi chain (the production converter setup from
+ * [NetworkModule]) produces for broken response bodies, and that [Throwable.toQonversionError]
+ * classifies both as [QonversionErrorCode.ResponseParsingFailed]:
+ *
+ * - a type mismatch (valid JSON, wrong shape) surfaces as Moshi's [JsonDataException];
+ * - syntactically malformed JSON surfaces as Moshi's [JsonEncodingException], which extends
+ * [java.io.IOException] — so without an explicit branch it would be misreported as
+ * NetworkConnectionFailed and retried forever by fallback logic keyed on that code.
+ */
+internal class ErrorsMoshiChainTest {
+ private lateinit var server: MockWebServer
+ private lateinit var api: Api
+
+ @Before
+ fun setUp() {
+ server = MockWebServer()
+ server.start()
+ val retrofit = Retrofit.Builder()
+ .addConverterFactory(MoshiConverterFactory.create(NetworkModule().provideMoshi()))
+ .baseUrl(server.url("/").toString())
+ .client(OkHttpClient())
+ .build()
+ api = retrofit.create(Api::class.java)
+ }
+
+ @After
+ fun tearDown() {
+ server.shutdown()
+ }
+
+ private fun fetchRemoteConfigThrowable(body: String): Throwable {
+ server.enqueue(
+ MockResponse()
+ .setResponseCode(200)
+ .setHeader("Content-Type", "application/json")
+ .setBody(body)
+ )
+ try {
+ api.remoteConfig("uid", null).execute()
+ } catch (e: Exception) {
+ return e
+ }
+ fail("Expected the Moshi converter to throw for body: $body")
+ throw AssertionError("unreachable")
+ }
+
+ @Test
+ fun `a type mismatch reaches the mapper as JsonDataException and maps to ResponseParsingFailed`() {
+ val thrown = fetchRemoteConfigThrowable(
+ """{"payload": "not an object", "experiment": null, "source": null}"""
+ )
+
+ assertTrue(
+ "Expected JsonDataException, got ${thrown.javaClass.name}",
+ thrown is JsonDataException
+ )
+ assertEquals(QonversionErrorCode.ResponseParsingFailed, thrown.toQonversionError().code)
+ }
+
+ @Test
+ fun `malformed JSON reaches the mapper as JsonEncodingException and maps to ResponseParsingFailed`() {
+ val thrown = fetchRemoteConfigThrowable("""{"payload": nul}""")
+
+ assertTrue(
+ "Expected JsonEncodingException, got ${thrown.javaClass.name}",
+ thrown is JsonEncodingException
+ )
+ assertEquals(QonversionErrorCode.ResponseParsingFailed, thrown.toQonversionError().code)
+ }
+}
diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/ErrorsTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/ErrorsTest.kt
new file mode 100644
index 000000000..d7e04c869
--- /dev/null
+++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/ErrorsTest.kt
@@ -0,0 +1,55 @@
+package com.qonversion.android.sdk.internal
+
+import com.qonversion.android.sdk.dto.QonversionErrorCode
+import com.squareup.moshi.JsonDataException
+import com.squareup.moshi.JsonEncodingException
+import org.json.JSONException
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import java.io.IOException
+
+/**
+ * Pins the [Throwable.toQonversionError] mapping — most importantly that Moshi's
+ * [JsonDataException] (a strict-parsing failure, e.g. an invalid remoteConfigList element) surfaces
+ * as [QonversionErrorCode.ResponseParsingFailed] rather than falling through to Unknown.
+ */
+internal class ErrorsTest {
+
+ @Test
+ fun `moshi JsonDataException maps to ResponseParsingFailed`() {
+ val error = JsonDataException("Expected a string but was BEGIN_OBJECT").toQonversionError()
+
+ assertEquals(QonversionErrorCode.ResponseParsingFailed, error.code)
+ assertEquals("Expected a string but was BEGIN_OBJECT", error.additionalMessage)
+ }
+
+ @Test
+ fun `JSONException maps to ResponseParsingFailed`() {
+ val error = JSONException("Unterminated object").toQonversionError()
+
+ assertEquals(QonversionErrorCode.ResponseParsingFailed, error.code)
+ }
+
+ @Test
+ fun `moshi JsonEncodingException maps to ResponseParsingFailed, not NetworkConnectionFailed`() {
+ // JsonEncodingException extends IOException; without an explicit branch it would fall
+ // through to the NetworkConnectionFailed mapping and be retried as a transient failure.
+ val error = JsonEncodingException("malformed JSON").toQonversionError()
+
+ assertEquals(QonversionErrorCode.ResponseParsingFailed, error.code)
+ }
+
+ @Test
+ fun `IOException maps to NetworkConnectionFailed`() {
+ val error = IOException("timeout").toQonversionError()
+
+ assertEquals(QonversionErrorCode.NetworkConnectionFailed, error.code)
+ }
+
+ @Test
+ fun `an unrecognized throwable maps to Unknown`() {
+ val error = IllegalStateException("boom").toQonversionError()
+
+ assertEquals(QonversionErrorCode.Unknown, error.code)
+ }
+}
diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/QProductCenterManagerIdentifyContractTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/QProductCenterManagerIdentifyContractTest.kt
index 9bf83c43b..bb0d04640 100644
--- a/sdk/src/test/java/com/qonversion/android/sdk/internal/QProductCenterManagerIdentifyContractTest.kt
+++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/QProductCenterManagerIdentifyContractTest.kt
@@ -77,6 +77,9 @@ internal class QProductCenterManagerIdentifyContractTest {
// would otherwise spin up a background Thread and break the
// synchronous verifyOrder window.
every { mockConfig.primaryConfig.isKidsMode } returns true
+ every { mockRemoteConfigManager.onUserUpdate(any(), any()) } answers {
+ secondArg<() -> Unit>().invoke()
+ }
// billingService.queryPurchases is the synchronous entry point
// into continueLaunchWithPurchasesInfo → processInit →
@@ -137,8 +140,8 @@ internal class QProductCenterManagerIdentifyContractTest {
// cache and finds stale permissions before clear, the UX is
// broken.
verifyOrder {
+ mockRemoteConfigManager.onUserUpdate(newIdentity, any())
mockConfig.uid = mergedUid
- mockRemoteConfigManager.onUserUpdate()
mockLaunchResultCacheWrapper.clearPermissionsCache()
mockRepository.init(match { it.requestTrigger == RequestTrigger.Identify })
}
@@ -176,12 +179,12 @@ internal class QProductCenterManagerIdentifyContractTest {
// the invalidation, or queued RC completions would be served the
// pre-identify evaluation straight from the cache.
verifyOrder {
- mockRemoteConfigManager.invalidateRemoteConfigsCache()
+ mockRemoteConfigManager.invalidateRemoteConfigsCache(newIdentity)
mockRemoteConfigManager.handlePendingRequests()
}
- verify(exactly = 1) { mockRemoteConfigManager.invalidateRemoteConfigsCache() }
+ verify(exactly = 1) { mockRemoteConfigManager.invalidateRemoteConfigsCache(newIdentity) }
// ...and the destructive user-switch path must NOT fire on same-uid
- verify(exactly = 0) { mockRemoteConfigManager.onUserUpdate() }
+ verify(exactly = 0) { mockRemoteConfigManager.onUserUpdate(any(), any()) }
}
/**
@@ -200,8 +203,8 @@ internal class QProductCenterManagerIdentifyContractTest {
pcm.identify(identity)
verify(exactly = 0) { mockIdentityManager.identify(any(), any()) }
- verify(exactly = 0) { mockRemoteConfigManager.invalidateRemoteConfigsCache() }
- verify(exactly = 0) { mockRemoteConfigManager.onUserUpdate() }
+ verify(exactly = 0) { mockRemoteConfigManager.invalidateRemoteConfigsCache(any()) }
+ verify(exactly = 0) { mockRemoteConfigManager.onUserUpdate(any(), any()) }
}
/**
diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/QProductCenterManagerTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/QProductCenterManagerTest.kt
index f7fd4d214..cb9e2e6b4 100644
--- a/sdk/src/test/java/com/qonversion/android/sdk/internal/QProductCenterManagerTest.kt
+++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/QProductCenterManagerTest.kt
@@ -60,6 +60,9 @@ internal class QProductCenterManagerTest {
mockInstallDate()
every { mockHandledPurchasesCache.shouldHandlePurchase(any()) } returns true
+ every { mockRemoteConfigManager.onUserUpdate(any(), any()) } answers {
+ secondArg<() -> Unit>().invoke()
+ }
productCenterManager = QProductCenterManager(
mockContext,
@@ -171,7 +174,7 @@ internal class QProductCenterManagerTest {
productCenterManager.restore(RequestTrigger.Restore, callback)
verify(exactly = 0) { mockUserInfoService.storeQonversionUserId(any()) }
- verify(exactly = 0) { mockRemoteConfigManager.onUserUpdate() }
+ verify(exactly = 0) { mockRemoteConfigManager.onUserUpdate(any(), any()) }
verify(exactly = 0) { mockLaunchResultCacheWrapper.clearPermissionsCache() }
verify { callback.onSuccess(any()) }
}
@@ -190,14 +193,33 @@ internal class QProductCenterManagerTest {
verifyOrder {
mockUserInfoService.storeQonversionUserId(originalOwnerUid)
+ mockRemoteConfigManager.onUserUpdate(null, any())
mockConfig.uid = originalOwnerUid
- mockRemoteConfigManager.onUserUpdate()
mockLaunchResultCacheWrapper.clearPermissionsCache()
}
verify { callback.onSuccess(any()) }
verify { mockLogger.debug(match { it.contains("User switch detected") }) }
}
+ @Test
+ fun `logout from background changes uid inside remote config identity transition`() {
+ val anonymousUid = "anonymous-user"
+ every { mockIdentityManager.logoutIfNeeded() } returns true
+ every { mockUserInfoService.obtainUserId() } returns anonymousUid
+
+ val logoutThread = Thread(productCenterManager::logout)
+ logoutThread.start()
+ logoutThread.join()
+
+ verifyOrder {
+ mockIdentityManager.logoutIfNeeded()
+ mockUserInfoService.obtainUserId()
+ mockRemoteConfigManager.onUserUpdate(null, any())
+ mockConfig.uid = anonymousUid
+ mockLaunchResultCacheWrapper.clearPermissionsCache()
+ }
+ }
+
@Test
fun `restore with error should not trigger user switch`() {
every { mockBillingService.queryPurchases(any(), captureLambda()) } answers {
@@ -220,7 +242,7 @@ internal class QProductCenterManagerTest {
productCenterManager.restore(RequestTrigger.Restore, callback)
verify(exactly = 0) { mockUserInfoService.storeQonversionUserId(any()) }
- verify(exactly = 0) { mockRemoteConfigManager.onUserUpdate() }
+ verify(exactly = 0) { mockRemoteConfigManager.onUserUpdate(any(), any()) }
verify(exactly = 0) { mockLaunchResultCacheWrapper.clearPermissionsCache() }
verify { callback.onError(any()) }
}
@@ -352,7 +374,7 @@ internal class QProductCenterManagerTest {
productCenterManager.restore(RequestTrigger.Restore, callback)
verify(exactly = 0) { mockUserInfoService.storeQonversionUserId(any()) }
- verify(exactly = 0) { mockRemoteConfigManager.onUserUpdate() }
+ verify(exactly = 0) { mockRemoteConfigManager.onUserUpdate(any(), any()) }
verify(exactly = 0) { mockLaunchResultCacheWrapper.clearPermissionsCache() }
verify { callback.onSuccess(any()) }
}
@@ -408,4 +430,4 @@ internal class QProductCenterManagerTest {
mockManager.getPackageInfo(packageName, PackageManager.GET_META_DATA)
} returns mockInfo
}
-}
\ No newline at end of file
+}
diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/QRemoteConfigManagerTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/QRemoteConfigManagerTest.kt
index 90b5d322c..e96941bc5 100644
--- a/sdk/src/test/java/com/qonversion/android/sdk/internal/QRemoteConfigManagerTest.kt
+++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/QRemoteConfigManagerTest.kt
@@ -11,6 +11,8 @@ import com.qonversion.android.sdk.getPrivateField
import com.qonversion.android.sdk.internal.provider.UserStateProvider
import com.qonversion.android.sdk.internal.services.QFallbacksService
import com.qonversion.android.sdk.internal.services.QRemoteConfigService
+import com.qonversion.android.sdk.internal.storage.RemoteConfigCache
+import com.qonversion.android.sdk.internal.storage.RemoteConfigCacheScope
import com.qonversion.android.sdk.listeners.QonversionRemoteConfigCallback
import com.qonversion.android.sdk.listeners.QonversionRemoteConfigListCallback
import com.qonversion.android.sdk.listeners.QonversionEmptyCallback
@@ -40,6 +42,7 @@ internal class QRemoteConfigManagerTest {
private val mockFallbacksService = mockk(relaxed = true)
private val userStateProvider = FakeUserStateProvider()
private val mockUserPropertiesManager = mockk(relaxed = true)
+ private lateinit var persistentCache: FakeRemoteConfigCache
private lateinit var manager: QRemoteConfigManager
@@ -47,11 +50,748 @@ internal class QRemoteConfigManagerTest {
fun setUp() {
clearAllMocks()
- manager = QRemoteConfigManager(mockRemoteConfigService, mockFallbacksService)
+ persistentCache = FakeRemoteConfigCache()
+ manager = QRemoteConfigManager(mockRemoteConfigService, mockFallbacksService, persistentCache)
manager.userStateProvider = userStateProvider
manager.userPropertiesManager = mockUserPropertiesManager
}
+ @Test
+ fun `successful server response is persisted as last known good`() {
+ userStateProvider.stable = true
+ val serverConfig = remoteConfigFor("ctx")
+ val callback = mockk(relaxed = true)
+ val serviceCallback = slot()
+ every { mockRemoteConfigService.loadRemoteConfig("ctx", capture(serviceCallback)) } just runs
+ every { mockUserPropertiesManager.forceSendProperties(any()) } answers {
+ firstArg()?.onComplete()
+ }
+
+ manager.loadRemoteConfig("ctx", callback)
+ shadowOf(Looper.getMainLooper()).idle()
+ serviceCallback.captured.onSuccess(serverConfig)
+
+ assertEquals(serverConfig, persistentCache.get("ctx"))
+ assertEquals(QRemoteConfigDeliveryOrigin.Network, manager.lastDeliveryOrigin("ctx"))
+ verify(exactly = 1) { callback.onSuccess(serverConfig) }
+ }
+
+ @Test
+ fun `single network success waits until its last known good is durably committed`() {
+ userStateProvider.stable = true
+ persistentCache.deferDurableMutations = true
+ val serverConfig = remoteConfigFor("ctx")
+ val callback = mockk(relaxed = true)
+ val serviceCallback = slot()
+ every { mockRemoteConfigService.loadRemoteConfig("ctx", capture(serviceCallback)) } just runs
+ every { mockUserPropertiesManager.forceSendProperties(any()) } answers {
+ firstArg()?.onComplete()
+ }
+
+ manager.loadRemoteConfig("ctx", callback)
+ shadowOf(Looper.getMainLooper()).idle()
+ serviceCallback.captured.onSuccess(serverConfig)
+
+ verify { callback wasNot Called }
+ assertEquals(null, persistentCache.get("ctx"))
+
+ persistentCache.completeNextDurableMutation(success = true)
+ shadowOf(Looper.getMainLooper()).idle()
+
+ assertEquals(serverConfig, persistentCache.get("ctx"))
+ verify(exactly = 1) { callback.onSuccess(serverConfig) }
+ }
+
+ @Test
+ fun `failed single persistence serves the prior committed last known good instead of fresh data`() {
+ userStateProvider.stable = true
+ val previous = remoteConfigFor("ctx")
+ val fresh = remoteConfigFor("ctx")
+ persistentCache.save(previous)
+ persistentCache.deferDurableMutations = true
+ val callback = mockk(relaxed = true)
+ val serviceCallback = slot()
+ every { mockRemoteConfigService.loadRemoteConfig("ctx", capture(serviceCallback)) } just runs
+ every { mockUserPropertiesManager.forceSendProperties(any()) } answers {
+ firstArg()?.onComplete()
+ }
+
+ manager.loadRemoteConfig("ctx", callback)
+ shadowOf(Looper.getMainLooper()).idle()
+ serviceCallback.captured.onSuccess(fresh)
+ persistentCache.completeNextDurableMutation(success = false)
+ shadowOf(Looper.getMainLooper()).idle()
+
+ assertEquals(previous, persistentCache.get("ctx"))
+ verify(exactly = 1) { callback.onSuccess(previous) }
+ verify(exactly = 0) { callback.onSuccess(fresh) }
+ verify(exactly = 0) { callback.onError(any()) }
+ }
+
+ @Test
+ fun `failed single persistence without fallback reports an error instead of fresh unsaved data`() {
+ userStateProvider.stable = true
+ persistentCache.deferDurableMutations = true
+ val fresh = remoteConfigFor("ctx")
+ val callback = mockk(relaxed = true)
+ val serviceCallback = slot()
+ every { mockFallbacksService.obtainFallbackData() } returns null
+ every { mockRemoteConfigService.loadRemoteConfig("ctx", capture(serviceCallback)) } just runs
+ every { mockUserPropertiesManager.forceSendProperties(any()) } answers {
+ firstArg()?.onComplete()
+ }
+
+ manager.loadRemoteConfig("ctx", callback)
+ shadowOf(Looper.getMainLooper()).idle()
+ serviceCallback.captured.onSuccess(fresh)
+ persistentCache.completeNextDurableMutation(success = false)
+ shadowOf(Looper.getMainLooper()).idle()
+
+ verify(exactly = 0) { callback.onSuccess(any()) }
+ verify(exactly = 1) {
+ callback.onError(match { it.code == QonversionErrorCode.ResponseParsingFailed })
+ }
+ }
+
+ @Test
+ fun `invalidation while persistence is pending reissues and never delivers the superseded response`() {
+ userStateProvider.stable = true
+ persistentCache.deferDurableMutations = true
+ val superseded = remoteConfigFor("ctx")
+ val fresh = remoteConfigFor("ctx")
+ val callback = mockk(relaxed = true)
+ val serviceCallbacks = mutableListOf()
+ every { mockRemoteConfigService.loadRemoteConfig("ctx", capture(serviceCallbacks)) } just runs
+ every { mockUserPropertiesManager.forceSendProperties(any()) } answers {
+ firstArg()?.onComplete()
+ }
+
+ manager.loadRemoteConfig("ctx", callback)
+ shadowOf(Looper.getMainLooper()).idle()
+ serviceCallbacks.first().onSuccess(superseded)
+ manager.invalidateRemoteConfigsCache()
+ persistentCache.completeNextDurableMutation(success = true)
+ shadowOf(Looper.getMainLooper()).idle()
+
+ verify(exactly = 0) { callback.onSuccess(any()) }
+ assertEquals(2, serviceCallbacks.size)
+
+ serviceCallbacks.last().onSuccess(fresh)
+ persistentCache.completeNextDurableMutation(success = true)
+ shadowOf(Looper.getMainLooper()).idle()
+
+ verify(exactly = 1) { callback.onSuccess(fresh) }
+ verify(exactly = 0) { callback.onSuccess(superseded) }
+ }
+
+ @Test
+ fun `list network success waits for atomic durable reconciliation`() {
+ userStateProvider.stable = true
+ persistentCache.deferDurableMutations = true
+ val fresh = remoteConfigFor("ctx")
+ val callback = mockk(relaxed = true)
+ val serviceCallback = slot()
+ every {
+ mockRemoteConfigService.loadRemoteConfigs(listOf("ctx"), false, capture(serviceCallback))
+ } just runs
+ every { mockUserPropertiesManager.forceSendProperties(any()) } answers {
+ firstArg()?.onComplete()
+ }
+
+ manager.loadRemoteConfigList(listOf("ctx"), false, callback)
+ shadowOf(Looper.getMainLooper()).idle()
+ serviceCallback.captured.onSuccess(QRemoteConfigList(listOf(fresh)))
+
+ verify { callback wasNot Called }
+ persistentCache.completeNextDurableMutation(success = true)
+ shadowOf(Looper.getMainLooper()).idle()
+
+ verify(exactly = 1) { callback.onSuccess(match { it.remoteConfigs == listOf(fresh) }) }
+ }
+
+ @Test
+ fun `failed list reconciliation preserves and serves the prior atomic snapshot`() {
+ userStateProvider.stable = true
+ val previous = remoteConfigFor("ctx")
+ val fresh = remoteConfigFor("ctx")
+ persistentCache.save(previous)
+ persistentCache.deferDurableMutations = true
+ val callback = mockk(relaxed = true)
+ val serviceCallback = slot()
+ every {
+ mockRemoteConfigService.loadRemoteConfigs(listOf("ctx"), false, capture(serviceCallback))
+ } just runs
+ every { mockUserPropertiesManager.forceSendProperties(any()) } answers {
+ firstArg()?.onComplete()
+ }
+
+ manager.loadRemoteConfigList(listOf("ctx"), false, callback)
+ shadowOf(Looper.getMainLooper()).idle()
+ serviceCallback.captured.onSuccess(QRemoteConfigList(listOf(fresh)))
+ persistentCache.completeNextDurableMutation(success = false)
+ shadowOf(Looper.getMainLooper()).idle()
+
+ assertEquals(listOf(previous), persistentCache.getAll().remoteConfigs)
+ verify(exactly = 1) { callback.onSuccess(match { it.remoteConfigs == listOf(previous) }) }
+ verify(exactly = 0) { callback.onSuccess(match { fresh in it.remoteConfigs }) }
+ }
+
+ @Test
+ fun `empty single context is canonicalized to the null context`() {
+ userStateProvider.stable = true
+ val callbacks = mutableListOf()
+ every { mockRemoteConfigService.loadRemoteConfig("", capture(callbacks)) } just runs
+ every { mockRemoteConfigService.loadRemoteConfig(null, capture(callbacks)) } just runs
+ every { mockUserPropertiesManager.forceSendProperties(any()) } answers {
+ firstArg()?.onComplete()
+ }
+ val callback = mockk(relaxed = true)
+
+ manager.loadRemoteConfig("", callback)
+ shadowOf(Looper.getMainLooper()).idle()
+ callbacks.single().onSuccess(remoteConfigFor(null))
+
+ verify(exactly = 1) { mockRemoteConfigService.loadRemoteConfig(null, any()) }
+ verify(exactly = 0) { mockRemoteConfigService.loadRemoteConfig("", any()) }
+ verify(exactly = 1) { callback.onSuccess(any()) }
+ assertTrue(loadingStates().containsKey(null))
+ assertEquals(false, loadingStates().containsKey(""))
+ assertNotNull(persistentCache.get(null))
+ }
+
+ @Test
+ fun `single response for a different context is rejected without poisoning last known good`() {
+ userStateProvider.stable = true
+ val lastKnownGood = remoteConfigFor("requested")
+ val poisonedResponse = remoteConfigFor("unexpected")
+ persistentCache.save(lastKnownGood)
+ val callback = mockk(relaxed = true)
+ val serviceCallback = slot()
+ every { mockRemoteConfigService.loadRemoteConfig("requested", capture(serviceCallback)) } just runs
+ every { mockUserPropertiesManager.forceSendProperties(any()) } answers {
+ firstArg()?.onComplete()
+ }
+
+ manager.loadRemoteConfig("requested", callback)
+ shadowOf(Looper.getMainLooper()).idle()
+ serviceCallback.captured.onSuccess(poisonedResponse)
+
+ verify(exactly = 1) { callback.onSuccess(lastKnownGood) }
+ verify(exactly = 0) { callback.onSuccess(poisonedResponse) }
+ verify(exactly = 0) { callback.onError(any()) }
+ assertEquals(lastKnownGood, persistentCache.get("requested"))
+ assertEquals(null, persistentCache.get("unexpected"))
+ assertEquals(QRemoteConfigDeliveryOrigin.PersistentLastKnownGood, manager.lastDeliveryOrigin("requested"))
+ }
+
+ @Test
+ fun `offline load after process restart serves persistent last known good before bundle`() {
+ userStateProvider.stable = true
+ val lastKnownGood = remoteConfigFor("ctx")
+ val bundledFallback = remoteConfigFor("ctx")
+ persistentCache.save(lastKnownGood)
+ every { mockFallbacksService.obtainFallbackData() } returns QFallbackObject(
+ offerings = null,
+ productPermissions = null,
+ remoteConfigList = QRemoteConfigList(listOf(bundledFallback)),
+ )
+ val callback = mockk(relaxed = true)
+ val serviceCallback = slot()
+ every { mockRemoteConfigService.loadRemoteConfig("ctx", capture(serviceCallback)) } just runs
+ every { mockUserPropertiesManager.forceSendProperties(any()) } answers {
+ firstArg()?.onComplete()
+ }
+
+ manager.loadRemoteConfig("ctx", callback)
+ shadowOf(Looper.getMainLooper()).idle()
+ serviceCallback.captured.onError(QonversionError(QonversionErrorCode.NetworkConnectionFailed))
+
+ verify(exactly = 1) { callback.onSuccess(lastKnownGood) }
+ verify(exactly = 0) { callback.onSuccess(bundledFallback) }
+ verify(exactly = 0) { callback.onError(any()) }
+ assertEquals(null, loadingStates()["ctx"]?.loadedConfig)
+ assertEquals(QRemoteConfigDeliveryOrigin.PersistentLastKnownGood, manager.lastDeliveryOrigin("ctx"))
+ }
+
+ @Test
+ fun `same identity invalidation forces network then degrades to persistent last known good`() {
+ userStateProvider.stable = true
+ val lastKnownGood = remoteConfigFor("ctx")
+ val callbacks = mutableListOf()
+ every { mockRemoteConfigService.loadRemoteConfig("ctx", capture(callbacks)) } just runs
+ every { mockUserPropertiesManager.forceSendProperties(any()) } answers {
+ firstArg()?.onComplete()
+ }
+
+ manager.loadRemoteConfig("ctx", mockk(relaxed = true))
+ shadowOf(Looper.getMainLooper()).idle()
+ callbacks.single().onSuccess(lastKnownGood)
+ manager.invalidateRemoteConfigsCache()
+ shadowOf(Looper.getMainLooper()).idle()
+
+ val afterInvalidation = mockk(relaxed = true)
+ manager.loadRemoteConfig("ctx", afterInvalidation)
+ shadowOf(Looper.getMainLooper()).idle()
+ assertEquals(2, callbacks.size)
+ callbacks.last().onError(QonversionError(QonversionErrorCode.NetworkConnectionFailed))
+
+ verify(exactly = 1) { afterInvalidation.onSuccess(lastKnownGood) }
+ verify(exactly = 0) { afterInvalidation.onError(any()) }
+ }
+
+ @Test
+ fun `authoritative single no-config evicts stale last known good`() {
+ userStateProvider.stable = true
+ val stale = remoteConfigFor("ctx")
+ persistentCache.save(stale)
+ val callback = mockk(relaxed = true)
+ val serviceCallback = slot()
+ every { mockRemoteConfigService.loadRemoteConfig("ctx", capture(serviceCallback)) } just runs
+ every { mockUserPropertiesManager.forceSendProperties(any()) } answers {
+ firstArg()?.onComplete()
+ }
+
+ manager.loadRemoteConfig("ctx", callback)
+ shadowOf(Looper.getMainLooper()).idle()
+ val noConfig = QonversionError(QonversionErrorCode.RemoteConfigurationNotAvailable)
+ serviceCallback.captured.onError(noConfig)
+
+ assertEquals(null, persistentCache.get("ctx"))
+ verify(exactly = 1) { callback.onError(noConfig) }
+ verify(exactly = 0) { callback.onSuccess(stale) }
+ }
+
+ @Test
+ fun `failed authoritative removal preserves prior LKG and reports persistence failure`() {
+ userStateProvider.stable = true
+ val stale = remoteConfigFor("ctx")
+ persistentCache.save(stale)
+ persistentCache.deferDurableMutations = true
+ val callback = mockk(relaxed = true)
+ val serviceCallback = slot()
+ every { mockRemoteConfigService.loadRemoteConfig("ctx", capture(serviceCallback)) } just runs
+ every { mockUserPropertiesManager.forceSendProperties(any()) } answers {
+ firstArg()?.onComplete()
+ }
+
+ manager.loadRemoteConfig("ctx", callback)
+ shadowOf(Looper.getMainLooper()).idle()
+ val noConfig = QonversionError(QonversionErrorCode.RemoteConfigurationNotAvailable)
+ serviceCallback.captured.onError(noConfig)
+
+ verify { callback wasNot Called }
+ assertEquals(stale, persistentCache.get("ctx"))
+
+ persistentCache.completeNextDurableMutation(success = false)
+ shadowOf(Looper.getMainLooper()).idle()
+
+ assertEquals(stale, persistentCache.get("ctx"))
+ verify(exactly = 0) { callback.onError(noConfig) }
+ verify(exactly = 1) {
+ callback.onError(match { it.code == QonversionErrorCode.ResponseParsingFailed })
+ }
+ verify(exactly = 0) { callback.onSuccess(any()) }
+ }
+
+ @Test
+ fun `invalidation before authoritative no-config response fences the stale removal`() {
+ userStateProvider.stable = true
+ val lastKnownGood = remoteConfigFor("ctx")
+ persistentCache.save(lastKnownGood)
+ val callback = mockk(relaxed = true)
+ val serviceCallbacks = mutableListOf()
+ every { mockRemoteConfigService.loadRemoteConfig("ctx", capture(serviceCallbacks)) } just runs
+ every { mockUserPropertiesManager.forceSendProperties(any()) } answers {
+ firstArg()?.onComplete()
+ }
+
+ manager.loadRemoteConfig("ctx", callback)
+ shadowOf(Looper.getMainLooper()).idle()
+ manager.invalidateRemoteConfigsCache()
+ serviceCallbacks.first().onError(
+ QonversionError(QonversionErrorCode.RemoteConfigurationNotAvailable),
+ )
+ shadowOf(Looper.getMainLooper()).idle()
+
+ assertEquals(lastKnownGood, persistentCache.get("ctx"))
+ assertEquals(2, serviceCallbacks.size)
+ verify { callback wasNot Called }
+ }
+
+ @Test
+ fun `bundled fallback is never persisted as last known good`() {
+ userStateProvider.stable = true
+ val bundledFallback = remoteConfigFor("ctx")
+ every { mockFallbacksService.obtainFallbackData() } returns QFallbackObject(
+ offerings = null,
+ productPermissions = null,
+ remoteConfigList = QRemoteConfigList(listOf(bundledFallback)),
+ )
+ val serviceCallback = slot()
+ every { mockRemoteConfigService.loadRemoteConfig("ctx", capture(serviceCallback)) } just runs
+ every { mockUserPropertiesManager.forceSendProperties(any()) } answers {
+ firstArg()?.onComplete()
+ }
+
+ manager.loadRemoteConfig("ctx", mockk(relaxed = true))
+ shadowOf(Looper.getMainLooper()).idle()
+ serviceCallback.captured.onError(QonversionError(QonversionErrorCode.NetworkConnectionFailed))
+
+ assertTrue(persistentCache.savedConfigs.isEmpty())
+ assertEquals(QRemoteConfigDeliveryOrigin.BundledFallback, manager.lastDeliveryOrigin("ctx"))
+ }
+
+ @Test
+ fun `successful server list response persists every config`() {
+ userStateProvider.stable = true
+ val first = remoteConfigFor("first")
+ val second = remoteConfigFor("second")
+ val callback = mockk(relaxed = true)
+ val serviceCallback = slot()
+ every {
+ mockRemoteConfigService.loadRemoteConfigs(listOf("first", "second"), false, capture(serviceCallback))
+ } just runs
+ every { mockUserPropertiesManager.forceSendProperties(any()) } answers {
+ firstArg()?.onComplete()
+ }
+
+ manager.loadRemoteConfigList(listOf("first", "second"), false, callback)
+ shadowOf(Looper.getMainLooper()).idle()
+ serviceCallback.captured.onSuccess(QRemoteConfigList(listOf(first, second)))
+
+ assertEquals(listOf(first, second), persistentCache.getAll().remoteConfigs)
+ }
+
+ @Test
+ fun `empty named contexts are filtered before a scoped list request`() {
+ userStateProvider.stable = true
+ val callback = mockk(relaxed = true)
+ val serviceCallback = slot()
+ every {
+ mockRemoteConfigService.loadRemoteConfigs(any>(), false, capture(serviceCallback))
+ } just runs
+ every { mockUserPropertiesManager.forceSendProperties(any()) } answers {
+ firstArg()?.onComplete()
+ }
+
+ manager.loadRemoteConfigList(listOf("", "ctx", ""), false, callback)
+ shadowOf(Looper.getMainLooper()).idle()
+ val response = remoteConfigFor("ctx")
+ serviceCallback.captured.onSuccess(QRemoteConfigList(listOf(response)))
+
+ verify(exactly = 1) {
+ mockRemoteConfigService.loadRemoteConfigs(listOf("ctx"), false, any())
+ }
+ verify(exactly = 1) { callback.onSuccess(match { it.remoteConfigs == listOf(response) }) }
+ assertEquals(false, loadingStates().containsKey(""))
+ }
+
+ @Test
+ fun `filtered list reconciliation is one persistent cache mutation`() {
+ userStateProvider.stable = true
+ val oldFirst = remoteConfigFor("first")
+ val omittedSecond = remoteConfigFor("second")
+ val unrelated = remoteConfigFor("unrelated")
+ persistentCache.save(oldFirst)
+ persistentCache.save(omittedSecond)
+ persistentCache.save(unrelated)
+ persistentCache.mutationCount = 0
+ val callback = mockk(relaxed = true)
+ val serviceCallback = slot()
+ every {
+ mockRemoteConfigService.loadRemoteConfigs(listOf("first", "second"), false, capture(serviceCallback))
+ } just runs
+ every { mockUserPropertiesManager.forceSendProperties(any()) } answers {
+ firstArg()?.onComplete()
+ }
+
+ manager.loadRemoteConfigList(listOf("first", "second"), false, callback)
+ shadowOf(Looper.getMainLooper()).idle()
+ val currentFirst = remoteConfigFor("first")
+ serviceCallback.captured.onSuccess(QRemoteConfigList(listOf(currentFirst)))
+
+ assertEquals(1, persistentCache.mutationCount)
+ assertEquals(currentFirst, persistentCache.get("first"))
+ assertEquals(null, persistentCache.get("second"))
+ assertEquals(unrelated, persistentCache.get("unrelated"))
+ }
+
+ @Test
+ fun `scoped list rejects unexpected context without mutating last known good`() {
+ userStateProvider.stable = true
+ val lastKnownGood = remoteConfigFor("requested")
+ val unexpected = remoteConfigFor("unexpected")
+ persistentCache.save(lastKnownGood)
+ val callback = mockk(relaxed = true)
+ val serviceCallback = slot()
+ every {
+ mockRemoteConfigService.loadRemoteConfigs(listOf("requested"), false, capture(serviceCallback))
+ } just runs
+ every { mockUserPropertiesManager.forceSendProperties(any()) } answers {
+ firstArg()?.onComplete()
+ }
+
+ manager.loadRemoteConfigList(listOf("requested"), false, callback)
+ shadowOf(Looper.getMainLooper()).idle()
+ serviceCallback.captured.onSuccess(QRemoteConfigList(listOf(unexpected)))
+
+ verify(exactly = 1) { callback.onSuccess(match { it.remoteConfigs == listOf(lastKnownGood) }) }
+ verify(exactly = 0) { callback.onSuccess(match { unexpected in it.remoteConfigs }) }
+ verify(exactly = 0) { callback.onError(any()) }
+ assertEquals(lastKnownGood, persistentCache.get("requested"))
+ assertEquals(null, persistentCache.get("unexpected"))
+ }
+
+ @Test
+ fun `scoped list rejects duplicate contexts as one malformed response`() {
+ userStateProvider.stable = true
+ val lastKnownGood = remoteConfigFor("requested")
+ val duplicateA = remoteConfigFor("requested")
+ val duplicateB = remoteConfigFor("requested")
+ persistentCache.save(lastKnownGood)
+ val callback = mockk(relaxed = true)
+ val serviceCallback = slot()
+ every {
+ mockRemoteConfigService.loadRemoteConfigs(listOf("requested"), false, capture(serviceCallback))
+ } just runs
+ every { mockUserPropertiesManager.forceSendProperties(any()) } answers {
+ firstArg()?.onComplete()
+ }
+
+ manager.loadRemoteConfigList(listOf("requested"), false, callback)
+ shadowOf(Looper.getMainLooper()).idle()
+ serviceCallback.captured.onSuccess(QRemoteConfigList(listOf(duplicateA, duplicateB)))
+
+ verify(exactly = 1) { callback.onSuccess(match { it.remoteConfigs == listOf(lastKnownGood) }) }
+ verify(exactly = 0) { callback.onSuccess(match { duplicateA in it.remoteConfigs || duplicateB in it.remoteConfigs }) }
+ verify(exactly = 0) { callback.onError(any()) }
+ assertEquals(lastKnownGood, persistentCache.get("requested"))
+ }
+
+ @Test
+ fun `all-context list rejects duplicate contexts and preserves the previous set`() {
+ userStateProvider.stable = true
+ val lastKnownGood = remoteConfigFor("previous")
+ val duplicateA = remoteConfigFor("duplicate")
+ val duplicateB = remoteConfigFor("duplicate")
+ persistentCache.save(lastKnownGood)
+ val callback = mockk(relaxed = true)
+ val serviceCallback = slot()
+ every { mockRemoteConfigService.loadRemoteConfigs(capture(serviceCallback)) } just runs
+ every { mockUserPropertiesManager.forceSendProperties(any()) } answers {
+ firstArg()?.onComplete()
+ }
+
+ manager.loadRemoteConfigList(callback)
+ shadowOf(Looper.getMainLooper()).idle()
+ serviceCallback.captured.onSuccess(QRemoteConfigList(listOf(duplicateA, duplicateB)))
+
+ verify(exactly = 1) { callback.onSuccess(match { it.remoteConfigs == listOf(lastKnownGood) }) }
+ verify(exactly = 0) { callback.onError(any()) }
+ assertEquals(listOf(lastKnownGood), persistentCache.getAll().remoteConfigs)
+ }
+
+ @Test
+ fun `requested server list omission evicts only the omitted requested context`() {
+ userStateProvider.stable = true
+ val staleRequested = remoteConfigFor("requested")
+ val unrelated = remoteConfigFor("unrelated")
+ persistentCache.save(staleRequested)
+ persistentCache.save(unrelated)
+ val callback = mockk(relaxed = true)
+ val serviceCallback = slot()
+ every {
+ mockRemoteConfigService.loadRemoteConfigs(listOf("requested"), false, capture(serviceCallback))
+ } just runs
+ every { mockUserPropertiesManager.forceSendProperties(any()) } answers {
+ firstArg()?.onComplete()
+ }
+
+ manager.loadRemoteConfigList(listOf("requested"), false, callback)
+ shadowOf(Looper.getMainLooper()).idle()
+ serviceCallback.captured.onSuccess(QRemoteConfigList(emptyList()))
+
+ assertEquals(null, persistentCache.get("requested"))
+ assertEquals(unrelated, persistentCache.get("unrelated"))
+ verify(exactly = 1) { callback.onSuccess(match { it.remoteConfigs.isEmpty() }) }
+ }
+
+ @Test
+ fun `all-context server list atomically replaces stale last known good set`() {
+ userStateProvider.stable = true
+ val stale = remoteConfigFor("stale")
+ val previousCurrent = remoteConfigFor("current")
+ val current = remoteConfigFor("current")
+ persistentCache.save(stale)
+ persistentCache.save(previousCurrent)
+ val callback = mockk(relaxed = true)
+ val serviceCallback = slot()
+ every { mockRemoteConfigService.loadRemoteConfigs(capture(serviceCallback)) } just runs
+ every { mockUserPropertiesManager.forceSendProperties(any()) } answers {
+ firstArg